// Copyright (C) 2024 The Qt Company Ltd. // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only #include "qqmllsutils_p.h" #include "documentsymbolutils_p.h" #include #include #include #include QT_BEGIN_NAMESPACE namespace DocumentSymbolUtils { using QLspSpecification::SymbolKind; using namespace QQmlJS::Dom; struct TypeSymbolRelation { DomType domType; SymbolKind symbolKind; }; constexpr static std::array s_TypeSymbolRelations = { { { DomType::Binding, SymbolKind::Variable }, { DomType::PropertyDefinition, SymbolKind::Property }, // Although MethodInfo simply relates to Method, SymbolKind requires special handling: // When MethodInfo represents a Signal, its SymbolKind is set to Event. // This distinction is explicitly managed in the symbolKindOf() helper function. // see also QTBUG-128423 { DomType::MethodInfo, SymbolKind::Method }, { DomType::Id, SymbolKind::Key }, { DomType::QmlObject, SymbolKind::Object }, { DomType::EnumDecl, SymbolKind::Enum }, { DomType::EnumItem, SymbolKind::EnumMember }, { DomType::QmlComponent, SymbolKind::Module }, { DomType::QmlFile, SymbolKind::File }, } }; [[nodiscard]] constexpr static inline SymbolKind symbolKindFor(const DomType &type) { // constexpr std::find_if is only from c++20 for (const auto &mapping : s_TypeSymbolRelations) { if (mapping.domType == type) { return mapping.symbolKind; } } return SymbolKind::Null; } constexpr static inline bool documentSymbolNotSupportedFor(const DomType &type) { return symbolKindFor(type) == SymbolKind::Null; } static bool propertyBoundAtDefinitionLine(const DomItem &propertyDefinition) { Q_ASSERT(propertyDefinition.internalKind() == DomType::PropertyDefinition); return FileLocations::treeOf(propertyDefinition)->info().regions[ColonTokenRegion].isValid(); } static inline bool shouldFilterOut(const DomItem &item) { const auto itemType = item.internalKind(); if (documentSymbolNotSupportedFor(itemType)) { return true; } if (itemType == DomType::PropertyDefinition && propertyBoundAtDefinitionLine(item)) { // without this check there is a "duplication" of symbols. // one representing PropertyDefinition another one - Binding return true; } return false; } static std::optional tryGetQmlObjectDetail(const DomItem &qmlObj) { using namespace QQmlJS::Dom; Q_ASSERT(qmlObj.internalKind() == DomType::QmlObject); bool hasId = !qmlObj.idStr().isEmpty(); if (hasId) { return qmlObj.idStr().toUtf8(); } const bool isRootObject = qmlObj.component().field(Fields::objects).index(0) == qmlObj; if (isRootObject) { return "root"; } return std::nullopt; } static std::optional tryGetBindingDetail(const DomItem &bItem) { const auto *bindingPtr = bItem.as(); Q_ASSERT(bindingPtr); switch (bindingPtr->valueKind()) { case BindingValueKind::ScriptExpression: { auto exprCode = bindingPtr->scriptExpressionValue()->code(); if (exprCode.length() > 25) { return QStringView(exprCode).first(22).toUtf8().append("..."); } if (exprCode.endsWith(QStringLiteral(";"))) { exprCode.chop(1); } return exprCode.toUtf8(); } default: // Value is QmlObject or QList => no detail return std::nullopt; } } static inline QByteArray getMethodDetail(const DomItem &mItem) { const auto *methodInfoPtr = mItem.as(); Q_ASSERT(methodInfoPtr); return methodInfoPtr->signature(mItem).toUtf8(); } std::optional tryGetDetailOf(const DomItem &item) { switch (item.internalKind()) { case DomType::Id: { const auto name = item.name(); return name.isEmpty() ? std::nullopt : std::make_optional(name.toUtf8()); } case DomType::EnumItem: return QByteArray::number(item.as()->value()); case DomType::QmlObject: return tryGetQmlObjectDetail(item); case DomType::MethodInfo: return getMethodDetail(item); case DomType::Binding: return tryGetBindingDetail(item); default: return std::nullopt; } } // TODO move to qmllsUtils? static inline bool isSubRange(const QLspSpecification::Range &potentialSubRange, const QLspSpecification::Range &range) { // Check if the start of a is greater than or equal to the start of b bool startContained = (potentialSubRange.start.line > range.start.line || (potentialSubRange.start.line == range.start.line && potentialSubRange.start.character >= range.start.character)); // Check if the end of a is less than or equal to the end of b bool endContained = (potentialSubRange.end.line < range.end.line || (potentialSubRange.end.line == range.end.line && potentialSubRange.end.character <= range.end.character)); return startContained && endContained; } using MutableRefToDocumentSymbol = QLspSpecification::DocumentSymbol &; [[nodiscard]] static MutableRefToDocumentSymbol findDirectParentFor(const QLspSpecification::DocumentSymbol &child, MutableRefToDocumentSymbol currentParent) { const auto containsChildRange = [&range = child.range](const QLspSpecification::DocumentSymbol &symbol) { return isSubRange(range, symbol.range); }; // Parent's Range covers children's Ranges // all children, grand-children, grand-grand-children and so forth // are not supposed to have overlapping Ranges, hence it's just "gready" approach // 1. find a Symbol among children, containing a Range // 2. set it as currentCandidate // 3. repeat std::reference_wrapper currentCandidate(currentParent); while (containsChildRange(currentCandidate) && currentCandidate.get().children.has_value()) { auto newCandidate = std::find_if(currentCandidate.get().children->begin(), currentCandidate.get().children->end(), containsChildRange); if (newCandidate == currentCandidate.get().children->end()) { break; } currentCandidate = std::ref(*newCandidate); } return currentCandidate; } using DocumentSymbolPredicate = qxp::function_ref; [[nodiscard]] static SymbolsList extractChildrenIf(const DocumentSymbolPredicate shouldBeReadopted, MutableRefToDocumentSymbol currentParent) { if (!currentParent.children.has_value()) { return {}; } auto &parentsChildren = currentParent.children.value(); SymbolsList extractedChildren; extractedChildren.reserve(parentsChildren.size()); auto [_, toBeRemoved] = std::partition_copy(parentsChildren.cbegin(), parentsChildren.cend(), std::back_inserter(extractedChildren), parentsChildren.begin(), shouldBeReadopted); parentsChildren.erase(toBeRemoved, parentsChildren.end()); return extractedChildren; } static inline void adopt(QLspSpecification::DocumentSymbol &&child, MutableRefToDocumentSymbol parent) { if (!parent.children.has_value()) { parent.children.emplace({ std::move(child) }); return; } parent.children->emplace_back(std::move(child)); } static void readoptChildrenIf(const DocumentSymbolPredicate unaryPred, MutableRefToDocumentSymbol currentParent) { auto childrenToBeReadopted = extractChildrenIf(unaryPred, currentParent); for (auto &&child : childrenToBeReadopted) { auto &newParentRef = findDirectParentFor(child, currentParent); adopt(std::move(child), newParentRef); } } // Readopts all Enum-s and Id-s static void reorganizeQmlComponentSymbol(MutableRefToDocumentSymbol qmlCompSymbol) { Q_ASSERT(qmlCompSymbol.kind == symbolKindFor(DomType::QmlComponent)); if (!qmlCompSymbol.children.has_value()) { // nothing to reorganize return; } constexpr auto enumDeclSymbolKind = symbolKindFor(DomType::EnumDecl); const auto symbolIsEnumDecl = [](const QLspSpecification::DocumentSymbol &symbol) -> bool { return symbol.kind == enumDeclSymbolKind; }; readoptChildrenIf(symbolIsEnumDecl, qmlCompSymbol); } /*! \internal * This function reorganizes \c qmlFileSymbols (result of assembleSymbolsForQmlFile) * in the following way: * 1. Moves Symbol-s representing Enum-s and inline QmlComponent-s * to their respective range-containing parents , a.k.a. direct structural parents. * 2. Reassignes head to the DocumentSymbol representing root QmlObject of the main * QmlComponent */ void reorganizeForOutlineView(SymbolsList &qmlFileSymbols) { Q_ASSERT(qmlFileSymbols.at(0).kind == symbolKindFor(DomType::QmlFile) && qmlFileSymbols.at(0).children.has_value()); auto &qmlFileSymbol = qmlFileSymbols[0]; constexpr auto qmlCompSymbolKind = symbolKindFor(DomType::QmlComponent); for (auto &childSymbol : qmlFileSymbol.children.value()) { if (childSymbol.kind == qmlCompSymbolKind) { reorganizeQmlComponentSymbol(childSymbol); } } const auto symbolIsInlineComp = [](const QLspSpecification::DocumentSymbol &symbol) -> bool { return symbol.kind == qmlCompSymbolKind && symbol.name.contains("."); }; readoptChildrenIf(symbolIsInlineComp, qmlFileSymbol); // move pointer from the documentSymbol representing QmlFile // to the documentSymbols representing children of main QmlComponent // a.k.a. ignore / not to show QmlFile and mainComponent symbols qmlFileSymbols = qmlFileSymbol.children->at(0).children.value(); } /*! \internal * Constructs a \c DocumentSymbol for an \c Item with the provided \c children. * Returns \c children if the current \c Item should not be represented via a \c DocumentSymbol. */ SymbolsList buildSymbolOrReturnChildren(const DomItem &item, SymbolsList &&children) { if (shouldFilterOut(item)) { // nothing to build, just returning children return std::move(children); } const auto buildPartialSymbol = [](const DomItem &item) { QLspSpecification::DocumentSymbol symbol; symbol.kind = symbolKindOf(item); symbol.name = symbolNameOf(item); symbol.detail = tryGetDetailOf(item); std::tie(symbol.range, symbol.selectionRange) = symbolRangesOf(item); return symbol; }; auto symbol = buildPartialSymbol(item); if (!children.empty()) { symbol.children.emplace(std::move(children)); } /* To avoid pushing down Id items through the DocumentSymbol tree, as part of rearrangement step, it was decided to handle them here explicitly. That Id issue atm only affects objects If / when Id is moving from component level to Object level this should be reflected also in the visiting logic. TODO(QTBUG-128274) */ if (const auto objPtr = item.as()) { if (const auto idItem = item.component().field(Fields::ids).key(objPtr->idStr()).index(0)) { auto idSymbol = buildPartialSymbol(idItem); adopt(std::move(idSymbol), symbol); } } return SymbolsList{ std::move(symbol) }; } std::pair symbolRangesOf(const DomItem &item) { const auto &fLoc = FileLocations::treeOf(item)->info(); const auto fullRangeSourceloc = fLoc.fullRegion; const auto selectionRangeSourceLoc = fLoc.regions[IdentifierRegion].isValid() ? fLoc.regions[IdentifierRegion] : fullRangeSourceloc; auto fItem = item.containingFile(); Q_ASSERT(fItem); const QString &code = fItem.ownerAs()->code(); return { QQmlLSUtils::qmlLocationToLspLocation( QQmlLSUtils::Location::from({}, fullRangeSourceloc, code)), QQmlLSUtils::qmlLocationToLspLocation( QQmlLSUtils::Location::from({}, selectionRangeSourceLoc, code)) }; } QByteArray symbolNameOf(const DomItem &item) { if (item.internalKind() == DomType::Id) { return "id"; } return (item.name().isEmpty() ? item.internalKindStr() : item.name()).toUtf8(); } QLspSpecification::SymbolKind symbolKindOf(const DomItem &item) { if (item.internalKind() == DomType::MethodInfo) { const auto *methodInfoPtr = item.as(); Q_ASSERT(methodInfoPtr); return methodInfoPtr->methodType == MethodInfo::MethodType::Signal ? SymbolKind::Event : symbolKindFor(DomType::MethodInfo); } return symbolKindFor(item.internalKind()); } /*! \internal * Design decisions behind this class are the following: * 1. It is an implementation detail of the free \c assembleSymbolsForQmlFile function * 2. It can only be initialized and used once per \c Item. * This is enforced by its \c refToRootItem reference member. * 3. It is tested via the public \c assembleSymbolsForQmlFile function. */ class DocumentSymbolVisitor { public: DocumentSymbolVisitor(const DomItem &item, const AssemblingFunction af) : m_assemble(af), m_refToRootItem(item) {}; static const FieldFilter &fieldsFilter(); [[nodiscard]] SymbolsList assembleSymbols(); private: [[nodiscard]] SymbolsList popAndAssembleSymbolsFor(const DomItem &item); void appendToTop(const SymbolsList &symbols); private: const AssemblingFunction m_assemble; const DomItem &m_refToRootItem; std::stack m_stackOfChildrenSymbols; }; const FieldFilter &DocumentSymbolVisitor::fieldsFilter() { // TODO(QTBUG-128118) add only fields to be visited and not the ones // to be removed. static const FieldFilter ff{ {}, // to add { // to remove { QString(), Fields::code.toString() }, { QString(), Fields::postCode.toString() }, { QString(), Fields::preCode.toString() }, { QString(), Fields::importScope.toString() }, { QString(), Fields::fileLocationsTree.toString() }, { QString(), Fields::astComments.toString() }, { QString(), Fields::comments.toString() }, { QString(), Fields::exports.toString() }, { QString(), Fields::propertyInfos.toString() }, { QLatin1String("FileLocationsNode"), Fields::parent.toString() }, //^^^ FieldFilter::default { QString(), Fields::errors.toString() }, { QString(), Fields::imports.toString() }, { QString(), Fields::prototypes.toString() }, { QString(), Fields::annotations.toString() }, { QString(), Fields::attachedType.toString() }, { QString(), Fields::canonicalFilePath.toString() }, { QString(), Fields::isValid.toString() }, { QString(), Fields::isSingleton.toString() }, { QString(), Fields::isCreatable.toString() }, { QString(), Fields::isComposite.toString() }, { QString(), Fields::attachedTypeName.toString() }, { QString(), Fields::pragmas.toString() }, { QString(), Fields::defaultPropertyName.toString() }, { QString(), Fields::name.toString() }, { QString(), Fields::nameIdentifiers.toString() }, { QString(), Fields::prototypes.toString() }, { QString(), Fields::nextScope.toString() }, { QString(), Fields::parameters.toString() }, { QString(), Fields::methodType.toString() }, { QString(), Fields::type.toString() }, { QString(), Fields::isConstructor.toString() }, { QString(), Fields::returnType.toString() }, { QString(), Fields::body.toString() }, { QString(), Fields::access.toString() }, { QString(), Fields::typeName.toString() }, { QString(), Fields::isReadonly.toString() }, { QString(), Fields::isList.toString() }, { QString(), Fields::bindingIdentifiers.toString() }, { QString(), Fields::bindingType.toString() }, { QString(), Fields::isSignalHandler.toString() }, // prop def? { QString(), Fields::isPointer.toString() }, { QString(), Fields::isFinal.toString() }, { QString(), Fields::isAlias.toString() }, { QString(), Fields::isDefaultMember.toString() }, { QString(), Fields::isRequired.toString() }, { QString(), Fields::read.toString() }, { QString(), Fields::write.toString() }, { QString(), Fields::bindable.toString() }, { QString(), Fields::notify.toString() }, { QString(), Fields::type.toString() }, // scriptExpr { QString(), Fields::scriptElement.toString() }, { QString(), Fields::localOffset.toString() }, { QString(), Fields::astRelocatableDump.toString() }, { QString(), Fields::expressionType.toString() }, // components { QString(), Fields::subComponents.toString() }, // BEWARE // Ids and IdStr are filtered out during the visit, because // documentSymbol-s for them will be explicitly handled as part of the // creation of symbol for QmlObject { QString(), Fields::ids.toString() }, { QString(), Fields::idStr.toString() }, // id { QString(), Fields::referredObject.toString() }, // enum item { QLatin1String("EnumItem"), Fields::value.toString() }, } }; return ff; } SymbolsList DocumentSymbolVisitor::assembleSymbols() { using namespace QQmlJS::Dom; auto openingVisitor = [this](const Path &, const DomItem &, bool) -> bool { m_stackOfChildrenSymbols.emplace(); return true; }; auto closingVisitor = [this](const Path &, const DomItem &item, bool) -> bool { // it's closing Visitor, openingVisitor must have pushed something Q_ASSERT(!m_stackOfChildrenSymbols.empty()); if (m_stackOfChildrenSymbols.size() == 1) { // reached children of root, nothing to do return false; } auto symbols = popAndAssembleSymbolsFor(item); appendToTop(symbols); return true; }; m_refToRootItem.visitTree(Path(), emptyChildrenVisitor, VisitOption::Default, openingVisitor, closingVisitor, fieldsFilter()); return popAndAssembleSymbolsFor(m_refToRootItem); } SymbolsList DocumentSymbolVisitor::popAndAssembleSymbolsFor(const DomItem &item) { Q_ASSERT(!m_stackOfChildrenSymbols.empty()); auto atEnd = qScopeGuard([this]() { m_stackOfChildrenSymbols.pop(); }); return m_assemble(item, std::move(m_stackOfChildrenSymbols.top())); } void DocumentSymbolVisitor::appendToTop(const SymbolsList &symbols) { Q_ASSERT(!m_stackOfChildrenSymbols.empty()); m_stackOfChildrenSymbols.top().append(symbols); } SymbolsList assembleSymbolsForQmlFile(const DomItem &item, const AssemblingFunction af) { Q_ASSERT(item.internalKind() == DomType::QmlFile); DocumentSymbolVisitor visitor(item, af); return visitor.assembleSymbols(); } } // namespace DocumentSymbolUtils QT_END_NAMESPACE