Files
Rintaro Ishizaki 4a60c9c569 Resolve qualified names in workspace/symbolInfo
A name that parses as qualified is now resolved through its container chain
rather than looked up as a bare index name, so a name reported by
`workspace/symbolNames(containerName:)` can be passed back to resolve its
locations.

The chain is matched exactly, both in requiring every enclosing container to be
named and in comparing names case-sensitively, because such a name is spelled as
the index spells it. `Container.init(_:)` therefore does not resolve to
`Outer.Container.init(_:)`. The rebuilt qualified name is compared against the
request as a final check, which is what distinguishes a C++ `Foo::bar` from a
Swift `Foo.bar`: the chain is parsed after normalizing `::` to `.`, so the
separator the client used is not recoverable from it.

The items for a qualified name are labelled with the qualified name rather than
the bare member name, so a client can match each item to the name it requested.

The requested names are grouped by container chain, so names that share a
container resolve it and walk its members once per request rather than once
per name. The walk covers every member of the container and its extensions,
which for a type like `String` is most of the cost of the request.
2026-10-04 21:09:44 -07:00

768 lines
33 KiB
Swift

//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2026 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//
import BuildServerIntegration
import Foundation
import IndexStoreDB
@_spi(SourceKitLSP) import LanguageServerProtocol
@_spi(SourceKitLSP) import LanguageServerProtocolExtensions
@_spi(SourceKitLSP) import SKLogging
import SemanticIndex
import SwiftExtensions
@_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions
extension SourceKitLSPServer {
/// Whether a symbol whose only location is inside a generated interface may be reported.
///
/// Emitting a generated-interface reference document requires both that the client can open it and
/// that it will call `workspaceSymbol/resolve` to fill in the range. Every path that reports symbols
/// derives the answer from here, so that `symbolNames(containerName:)` never offers a name that
/// `symbolItems(forNames:)` cannot resolve.
private var canUseGeneratedInterfaceReferenceDocument: Bool {
return (self.capabilityRegistry?.clientHasWorkspaceGetReferenceDocumentSupport ?? false)
&& (self.capabilityRegistry?.clientSupportsWorkspaceSymbolResolve ?? false)
}
/// The names of the symbols in the indexes of all workspaces, sorted and de-duplicated.
///
/// If `containerName` is set, the fully-qualified names of that container's members are returned
/// instead of every symbol name in the workspaces.
func symbolNames(containerName: String?) async -> [String] {
if let containerName {
return await memberNames(ofContainer: containerName)
}
var symbols = await self.workspaces
.concurrentMap { workspace in
await orLog("Getting symbol names in workspace") {
try await workspace.uncheckedIndex?.allSymbolNames() ?? []
} ?? []
}
.flatMap { $0 }
if !symbols.isSortedAndUnique {
symbols.sortAndDedupe()
}
return symbols
}
/// The fully-qualified names of the members of the container(s) named by `containerName`, sorted and
/// de-duplicated.
///
/// The names are qualified because `containerName` is matched as a suffix of a container's chain, so
/// more than one container can match and a bare member name would be ambiguous between them. Each
/// returned name can be passed to `symbolItems(forNames:)`, which matches it exactly.
private func memberNames(ofContainer containerName: String) async -> [String] {
guard let chain = QualifiedWorkspaceSymbolQuery.containerChain(containerName) else {
return []
}
let includeSystemSymbols = canUseGeneratedInterfaceReferenceDocument
var names = await workspaces.concurrentMap { workspace -> [String] in
// Checked for deleted files like the paths that produce items, rather than at the unchecked level
// that the workspace-wide mode uses, because the two stages have to agree.
guard let index = await workspace.index(checkedFor: .deletedFiles) else {
return []
}
return orLog("Getting member names of \(containerName)") {
let containerUSRs = try self.containerUSRs(
matching: .suffixIgnoringCase(chain),
includeSystemSymbols: includeSystemSymbols,
in: index
)
return try self.members(
ofContainerUSRs: containerUSRs,
matching: .all,
includeSystemSymbols: includeSystemSymbols,
in: index
)
.compactMap { try? QualifiedSymbolName(of: $0, in: index).qualified }
} ?? []
}
.flatMap { $0 }
if !names.isSortedAndUnique {
names.sortAndDedupe()
}
return names
}
/// For each name in `names`, look up all canonical occurrences in every workspace index and convert them to
/// `WorkspaceSymbolItem` values:
/// - Source-file symbols get a `file://` URI with the exact 0-based line/column from the index.
/// - SDK/stdlib symbols (index location ends in `.swiftinterface` or `.swiftmodule`) get a
/// `WorkspaceSymbol` with `location: .uri(file:// URL?module=...)` and the USR in `data`, provided
/// the client advertises `workspace.symbol.resolveSupport`. The client should call
/// `workspaceSymbol/resolve` to obtain the exact location within the interface.
/// Without that capability the raw `file://` URI from the index record is returned instead.
///
/// The results are ordered by the position of their name in `names`; a name with no occurrences contributes
/// no items.
func symbolItems(forNames names: [String]) async throws -> [WorkspaceSymbolItem] {
// Bound here rather than read inside the closure, which is not isolated to the server.
let canUseGeneratedInterfaceReferenceDocument = self.canUseGeneratedInterfaceReferenceDocument
let groupedResultPerWorkspace = await workspaces.concurrentMap { workspace -> [String: [WorkspaceSymbolItem]] in
guard let index = await workspace.index(checkedFor: .deletedFiles) else {
return [:]
}
var occurrencesByName: [String: [SymbolOccurrence]] = [:]
// Qualified names are resolved per container chain rather than per name, so that names sharing a
// container resolve it and walk its members once. The walk visits every member of the container and
// of each of its extensions, which for a type like `String` is most of the cost of the request.
var qualifiedNamesByChain: [[String]: [(name: String, member: String)]] = [:]
for name in names {
if Task.isCancelled { return [:] }
if let query = QualifiedWorkspaceSymbolQuery(name) {
qualifiedNamesByChain[query.containerChain, default: []].append((name, query.member))
continue
}
var symbols: [SymbolOccurrence] = []
_ = orLog("Getting symbol occurrences") {
try index.forEachCanonicalSymbolOccurrence(byName: name) { symbolOccurrence in
symbols.append(symbolOccurrence)
return true
}
}
occurrencesByName[name] = symbols
}
for (chain, requested) in qualifiedNamesByChain {
if Task.isCancelled { return [:] }
let membersByName: [String: [SymbolOccurrence]] =
orLog("Getting members of \(chain.joined(separator: "."))") {
let containerUSRs = try self.containerUSRs(
matching: .exact(chain),
includeSystemSymbols: canUseGeneratedInterfaceReferenceDocument,
in: index
)
let members = try self.members(
ofContainerUSRs: containerUSRs,
matching: .exact(Set(requested.map(\.member))),
includeSystemSymbols: canUseGeneratedInterfaceReferenceDocument,
in: index
)
return Dictionary(grouping: members, by: \.symbol.name)
} ?? [:]
for (name, member) in requested {
// The chain is parsed after normalizing `::` to `.`, so which separator the client used is not
// recoverable from it. Comparing the rebuilt name keeps a request for a C++ `Foo::bar` from also
// matching a Swift `Foo.bar`.
occurrencesByName[name] = (membersByName[member] ?? []).filter {
(try? QualifiedSymbolName(of: $0, in: index).qualified) == name
}
}
}
// Items for these are labelled with their qualified name, so the client gets back the name it asked for.
let qualifiedNames = Set(qualifiedNamesByChain.values.joined().map(\.name))
var mainFiles: [DocumentURI: DocumentURI] = [:]
if canUseGeneratedInterfaceReferenceDocument {
let occurrences = occurrencesByName.values.flatMap { $0 }
mainFiles =
await orLog("Resolving generated interface main files") {
try await self.generatedInterfaceMainFiles(for: occurrences, in: workspace)
} ?? [:]
}
if Task.isCancelled { return [:] }
var result: [String: [WorkspaceSymbolItem]] = [:]
let copiedFileMap = await workspace.buildServerManager.cachedCopiedFileMap
for name in names {
result[name] = (occurrencesByName[name] ?? []).compactMap { symbol in
orLog("Getting symbol information") {
try self.workspaceSymbolItem(
for: symbol,
in: index,
copiedFileMap: copiedFileMap,
referenceDocumentMainFile: symbol.location.uri.flatMap { mainFiles[$0] },
useQualifiedName: qualifiedNames.contains(name)
)
}
}
}
return result
}
try Task.checkCancellation()
// Flatten the result.
var result: [WorkspaceSymbolItem] = []
for name in names {
for grouped in groupedResultPerWorkspace {
if let items = grouped[name] {
result.append(contentsOf: items)
}
}
}
return result
}
/// If the symbol has a `location: .uri(sourcekit-lsp://generated-swift-interface?...)` (as emitted by
/// `workspace/symbol` and `workspace/symbolInfo` for SDK/stdlib symbols), open the generated Swift
/// interface, resolve the symbol position using `data["usr"]`, and return the symbol with the exact
/// range. Symbols with an already-resolved `location: .location(...)` are returned unchanged.
func resolveGeneratedInterfaceLocation(of symbol: WorkspaceSymbol) async throws -> WorkspaceSymbol {
var symbol = symbol
guard
case .uri(let uriOnly) = symbol.location,
let referenceURL = try? ReferenceDocumentURL(from: uriOnly.uri),
case .generatedInterface(let urlData) = referenceURL
else {
return symbol
}
// A USR is always present in practice; this only guards against a malformed `data` payload with an
// empty USR string, treating it as absent so we don't run a position lookup that can't match.
let usr = (symbol.sourceKitData?.usr).flatMap { $0.isEmpty ? nil : $0 }
let buildSettingsFile = urlData.buildSettingsFrom
guard let workspace = await self.workspaceForDocument(uri: buildSettingsFile) else {
return symbol
}
let languageService = try await workspace.primaryLanguageService(for: buildSettingsFile, .swift)
let details = await orLog("Opening generated interface in workspaceSymbol/resolve") {
try await languageService.openGeneratedInterface(
document: buildSettingsFile,
moduleName: urlData.moduleName,
groupName: urlData.groupName,
symbolUSR: usr
)
}
symbol.location = .location(
Location(uri: uriOnly.uri, range: Range(details?.position ?? Position(line: 0, utf16index: 0)))
)
return symbol
}
/// The symbols in the indexes of all workspaces that match `query`.
///
/// A query containing a qualifier separator (`.` or `::`) is resolved as a container chain plus a member
/// name; any other query is matched as a subsequence against the symbol name.
func symbolItems(matching query: String) async throws -> [WorkspaceSymbolItem] {
// Ignore short queries since they are:
// - noisy and slow, since they can match many symbols
// - normally unintentional, triggered when the user types slowly or if the editor doesn't
// debounce events while the user is typing
guard query.count >= minWorkspaceSymbolPatternLength else {
return []
}
if let qualified = QualifiedWorkspaceSymbolQuery(query) {
return try await qualifiedWorkspaceSymbols(qualified)
}
return try await unqualifiedWorkspaceSymbols(query: query)
}
private func unqualifiedWorkspaceSymbols(query: String) async throws -> [WorkspaceSymbolItem] {
var items: [WorkspaceSymbolItem] = []
for workspace in workspaces {
guard let index = await workspace.index(checkedFor: .deletedFiles) else {
continue
}
let copiedFileMap = await workspace.buildServerManager.cachedCopiedFileMap
var symbols: [SymbolOccurrence] = []
try index.forEachCanonicalSymbolOccurrence(
containing: query,
anchorStart: false,
anchorEnd: false,
subsequence: true,
ignoreCase: true
) { symbol in
if Task.isCancelled {
return false
}
guard !symbol.location.isSystem && !symbol.roles.contains(.accessorOf) else {
return true
}
symbols.append(symbol)
return true
}
try Task.checkCancellation()
// `workspace/symbol` filters out system symbols above, so no result points into a generated
// interface and there is no main file to resolve.
items += try symbols.sorted(by: <).compactMap {
try self.workspaceSymbolItem(
for: $0,
in: index,
copiedFileMap: copiedFileMap,
referenceDocumentMainFile: nil
)
}
}
return items
}
/// Handle a `workspace/symbol` request whose query contains a qualifier separator (`.` or `::`).
///
/// Resolves the container named by the query's container chain and returns the container's members whose
/// name matches the query's member component.
/// See `members(ofContainerChain:matching:includeSystemSymbols:in:)`.
private func qualifiedWorkspaceSymbols(
_ query: QualifiedWorkspaceSymbolQuery
) async throws -> [WorkspaceSymbolItem] {
// Unlike `unqualifiedWorkspaceSymbols`, qualified queries can match SDK/stdlib members
// (e.g. `String.count`), so they need main files.
var items: [WorkspaceSymbolItem] = []
for workspace in workspaces {
guard let index = await workspace.index(checkedFor: .deletedFiles) else {
continue
}
let copiedFileMap = await workspace.buildServerManager.cachedCopiedFileMap
// Resolve the container named by the container chain, then take its direct members, keeping those
// whose name matches `query.member`. An empty member (e.g. the query `Foo.`) lists all members.
// System members are only useful if we can point at their generated interface.
let containerUSRs = try self.containerUSRs(
matching: .suffixIgnoringCase(query.containerChain),
includeSystemSymbols: canUseGeneratedInterfaceReferenceDocument,
in: index
)
let symbols = try self.members(
ofContainerUSRs: containerUSRs,
matching: query.member.isEmpty ? .all : .fuzzy(query.member),
includeSystemSymbols: canUseGeneratedInterfaceReferenceDocument,
in: index
)
var mainFiles: [DocumentURI: DocumentURI] = [:]
if canUseGeneratedInterfaceReferenceDocument {
mainFiles =
await orLog("Resolving generated interface main files") {
try await self.generatedInterfaceMainFiles(for: symbols, in: workspace)
} ?? [:]
}
items += try symbols.sorted(by: <).compactMap {
try self.workspaceSymbolItem(
for: $0,
in: index,
copiedFileMap: copiedFileMap,
referenceDocumentMainFile: $0.location.uri.flatMap { mainFiles[$0] },
useQualifiedName: true
)
}
}
return items
}
/// Map a `SymbolOccurrence` from the index to a `WorkspaceSymbolItem`, or `nil` if it has no
/// representable location.
///
/// If `useQualifiedName` is `true` and the symbol has a container, the item's `name` is the fully-qualified
/// name (e.g. `Foo.bar`) and `containerName` is dropped. This is used for qualified queries so that clients
/// which filter workspace symbols by matching the query against the item's `name` (e.g. VS Code) keep the
/// result — the qualified query wouldn't match the bare member name otherwise.
///
/// - Parameter referenceDocumentMainFile: The project file whose build settings are used to open the
/// symbol's generated interface. **Passing a non-`nil` value changes the shape of the result**: the
/// symbol is returned as a `WorkspaceSymbol` with a `sourcekit-lsp://generated-swift-interface`
/// reference-document location (its range resolved lazily via `workspaceSymbol/resolve`) and the USR
/// in `data`, instead of a `SymbolInformation` with a plain `file://` location. A non-`nil` value must
/// therefore only be passed when the client supports **both** `workspace/getReferenceDocument` and
/// `workspaceSymbol/resolve`; enforcing that is the caller's responsibility.
private nonisolated func workspaceSymbolItem(
for symbolOccurrence: SymbolOccurrence,
in index: CheckedIndex,
copiedFileMap: CopiedFileMap,
referenceDocumentMainFile: DocumentURI?,
useQualifiedName: Bool = false
) throws -> WorkspaceSymbolItem? {
let qualifiedName = try QualifiedSymbolName(of: symbolOccurrence, in: index)
// For qualified queries, put the qualified name in the label (which clients filter against) and drop the
// now-redundant container name.
let name: String
let displayContainerName: String?
if useQualifiedName, qualifiedName.containerName != nil {
name = qualifiedName.qualified
displayContainerName = nil
} else {
name = symbolOccurrence.symbol.name
displayContainerName = qualifiedName.containerName
}
if let referenceDocumentMainFile {
let (interfaceModuleName, groupName) = Self.splitModuleNameAndGroup(symbolOccurrence.location.moduleName)
let urlData = GeneratedInterfaceDocumentURLData(
moduleName: interfaceModuleName,
groupName: groupName,
primaryFile: referenceDocumentMainFile
)
let usr = symbolOccurrence.symbol.usr
// Include the interface path and module name in `data` so clients can render the candidate without
// parsing the opaque location URI.
let data = SourceKitWorkspaceSymbolData(
usr: usr,
interfaceURI: symbolOccurrence.location.uri,
moduleName: symbolOccurrence.location.moduleName
)
return WorkspaceSymbolItem.workspaceSymbol(
WorkspaceSymbol(
name: name,
kind: symbolOccurrence.symbol.kind.asLspSymbolKind(),
containerName: displayContainerName,
location: .uri(.init(uri: try urlData.uri)),
data: data.encodeToLSPAny()
)
)
}
guard let symbolLocation = symbolOccurrence.location.lspLocation else { return nil }
let location = symbolLocation.adjusted(for: copiedFileMap)
return WorkspaceSymbolItem.symbolInformation(
SymbolInformation(
name: name,
kind: symbolOccurrence.symbol.kind.asLspSymbolKind(),
deprecated: nil,
location: location,
containerName: displayContainerName
)
)
}
/// The pieces of a symbol's name that depend on its containers.
struct QualifiedSymbolName {
/// The symbol's container names joined with `separator`, or `nil` if the symbol has no container.
let containerName: String?
/// The separator between a container and its members in the symbol's language.
let separator: String
/// The symbol's name prefixed with `containerName`.
let qualified: String
/// The name of `symbolOccurrence` prefixed with the names of its containers, joined with the separator
/// that its language uses.
///
/// This is the spelling that `workspace/symbolNames` returns for the members of a container and that
/// `workspace/symbolInfo` parses and compares a requested name against, so it must be built here and
/// nowhere else.
init(of symbolOccurrence: SymbolOccurrence, in index: CheckedIndex) throws {
let containerNames = try index.containerNames(of: symbolOccurrence)
separator =
switch symbolOccurrence.symbol.language {
case .cxx, .c, .objc: "::"
case .swift: "."
}
if containerNames.isEmpty {
containerName = nil
qualified = symbolOccurrence.symbol.name
} else {
let containerName = containerNames.joined(separator: separator)
self.containerName = containerName
qualified = "\(containerName)\(separator)\(symbolOccurrence.symbol.name)"
}
}
}
/// Split an index module name into its module and optional group components.
private nonisolated static func splitModuleNameAndGroup(
_ fullModuleName: String
) -> (module: String, group: String?) {
// A dotted index module name is ambiguous: `Foo.Bar` could be module `Foo` with group `Bar`, or a real
// submodule named `Foo.Bar`, and SourceKit-LSP can't tell the two apart. In practice only the `Swift`
// module is divided into groups (and it has no submodules), so only there is the trailing component
// treated as a group; every other module name is kept whole.
let swiftModulePrefix = "Swift."
guard fullModuleName.hasPrefix(swiftModulePrefix) else {
return (fullModuleName, nil)
}
// The index spells a group's levels with `.` (`Swift.Math.Integers`) but sourcekitd expects '/'
// separated group name.
let group = fullModuleName.dropFirst(swiftModulePrefix.count).replacing(".", with: "/")
return ("Swift", String(group))
}
/// For each distinct SDK interface (`.swiftinterface`/`.swiftmodule`) among `symbols`, resolve the main
/// file — a project file that imports the module, found via `mainFiles(containing:)` — whose build
/// settings are used to open the generated interface. The lookup runs once per interface so a
/// `workspace/symbol` response with many members of the same module doesn't repeat it. Interfaces with no
/// main file are omitted, so callers skip those symbols.
private func generatedInterfaceMainFiles(
for symbols: [SymbolOccurrence],
in workspace: Workspace
) async throws -> [DocumentURI: DocumentURI] {
var mainFiles: [DocumentURI: DocumentURI] = [:]
for symbol in symbols {
let path = symbol.location.path
guard path.hasSuffix(".swiftinterface") || path.hasSuffix(".swiftmodule"),
let interfaceURI = symbol.location.uri,
mainFiles[interfaceURI] == nil
else {
continue
}
try Task.checkCancellation()
let mainFile = await workspace.buildServerManager
.mainFiles(containing: interfaceURI)
.sorted(by: { $0.arbitrarySchemeURL.absoluteString < $1.arbitrarySchemeURL.absoluteString })
.first
if let mainFile {
mainFiles[interfaceURI] = mainFile
}
}
return mainFiles
}
/// The USRs of the containers named by `filter`, together with the extensions that declare their
/// members.
///
/// Members declared in an extension are `childOf` the extension symbol rather than the extended type,
/// so the extensions of every matching container are resolved here as well. A type's occurrences at
/// `extension` sites carry `extendedBy` relations pointing at those extensions.
///
/// System (SDK/stdlib) containers are matched even if `includeSystemSymbols` is `false`, because a
/// system type can be extended from the user's own modules and those members stay reachable. Only the
/// containers whose members may be reported are returned.
private nonisolated func containerUSRs(
matching filter: ContainerChainFilter,
includeSystemSymbols: Bool,
in index: CheckedIndex
) throws -> Set<String> {
guard let containerName = filter.chain.last else {
throw ResponseError.internalError("\(#function) requires a non-empty chain")
}
let ancestors = filter.chain.dropLast()
// Resolve the innermost container(s) by exact name, verifying the outer scope chain.
//
// `matchedContainerUSRs` holds every resolved container, including system ones, so that extensions
// written in the user's modules are found for a system type. `result` is the subset whose children
// may be walked.
var matchedContainerUSRs: Set<String> = []
var result: Set<String> = []
try index.forEachCanonicalSymbolOccurrence(
containing: containerName,
anchorStart: true,
anchorEnd: true,
subsequence: false,
ignoreCase: filter.ignoresCase
) { symbol in
if Task.isCancelled {
return false
}
// Resolving a system namespace (e.g. `std`, or a whole module) would enumerate an entire system scope,
// so skip those.
let isSystemNamespace: Bool =
switch symbol.symbol.kind {
case .namespace, .namespaceAlias, .module:
symbol.location.isSystem
default:
false
}
let enclosingScopes = (try? index.containerNames(of: symbol)) ?? []
guard !isSystemNamespace, filter.matches(enclosingScopes: enclosingScopes, ancestors: ancestors) else {
return true
}
matchedContainerUSRs.insert(symbol.symbol.usr)
if includeSystemSymbols || !symbol.location.isSystem {
result.insert(symbol.symbol.usr)
}
return true
}
try Task.checkCancellation()
// Filtering the occurrences by the `.extendedBy` role restricts the lookup to the `extension` sites
// instead of returning every reference of the type.
//
// The extension site's location distinguishes the user's extensions from the SDK's, so system
// extensions are skipped before any of their members are enumerated.
for typeUSR in matchedContainerUSRs {
try Task.checkCancellation()
for occurrence in try index.occurrences(ofUSR: typeUSR, roles: .extendedBy) {
guard includeSystemSymbols || !occurrence.location.isSystem else {
continue
}
for relation in occurrence.relations where relation.roles.contains(.extendedBy) {
result.insert(relation.symbol.usr)
}
}
}
return result
}
/// The direct members (`childOf`) of the containers with the given USRs that `filter` selects,
/// de-duplicated by USR.
///
/// System (SDK/stdlib) members are only included if `includeSystemSymbols` is `true`. They can only be
/// navigated to through a generated interface, so callers pass `false` when the client can't open one.
private nonisolated func members(
ofContainerUSRs containerUSRs: Set<String>,
matching filter: MemberFilter,
includeSystemSymbols: Bool,
in index: CheckedIndex
) throws -> [SymbolOccurrence] {
var members: [SymbolOccurrence] = []
var seenUSRs: Set<String> = []
for containerUSR in containerUSRs {
try Task.checkCancellation()
let children = try index.occurrences(relatedToUSR: containerUSR, roles: .childOf)
for child in children {
guard
!child.roles.contains(.accessorOf),
includeSystemSymbols || !child.location.isSystem,
filter.matches(memberName: child.symbol.name),
seenUSRs.insert(child.symbol.usr).inserted
else {
continue
}
switch child.symbol.language {
case .c, .cxx, .objc:
// A C-family symbol can have separate declaration and definition occurrences.
members.append((try? index.primaryDefinitionOrDeclarationOccurrence(ofUSR: child.symbol.usr)) ?? child)
case .swift:
// Swift members have a single declaration site, so use the occurrence directly.
members.append(child)
}
}
}
return members
}
}
/// Which containers a query names, and how their names are matched.
enum ContainerChainFilter {
/// The chain names the innermost containers and may omit the enclosing ones, matched
/// case-insensitively, so `container` matches a container declared as `Outer.Container`.
///
/// Used for the queries that a user types, where the chain is a hint rather than a full path.
case suffixIgnoringCase([String])
/// The chain names every enclosing container, matched case-sensitively.
///
/// Used for a name that `workspace/symbolNames` produced, which spells the containers exactly as the
/// index does.
case exact([String])
/// The container names, outermost first.
var chain: [String] {
switch self {
case .suffixIgnoringCase(let chain), .exact(let chain): return chain
}
}
var ignoresCase: Bool {
switch self {
case .suffixIgnoringCase: return true
case .exact: return false
}
}
/// Whether a candidate container declared inside `enclosingScopes` is named by this filter's chain.
func matches(enclosingScopes: [String], ancestors: some Collection<String>) -> Bool {
switch self {
case .suffixIgnoringCase:
// Match only the suffix of the enclosing scopes, so a chain may name just the inner scopes, e.g.
// `Inner` for a container declared as `Outer.Inner`.
return enclosingScopes.suffix(ancestors.count).map { $0.lowercased() }
== ancestors.map { $0.lowercased() }
case .exact:
// Comparing the scopes in full also rejects a container nested deeper than the chain, which
// produces more scopes than there are ancestors.
return enclosingScopes == Array(ancestors)
}
}
}
/// Which members of a container a query selects.
enum MemberFilter {
/// Every member, which is what a query ending in a separator (`Foo.`) and a request for a container's
/// member names ask for.
case all
/// The members whose name contains the string as a case-insensitive subsequence.
case fuzzy(String)
/// The members whose name equals one of the strings.
case exact(Set<String>)
func matches(memberName: String) -> Bool {
switch self {
case .all: return true
case .fuzzy(let pattern): return memberName.fuzzilyContains(subsequence: pattern)
case .exact(let names): return names.contains(memberName)
}
}
}
/// A `workspace/symbol` query split into its qualified parts.
///
/// For example:
/// `"String.description"` → `containerChain: ["String"], member: "description"`
/// `"Foo::bar"` → `containerChain: ["Foo"], member: "bar"`
/// `"Outer.Inner.method"` → `containerChain: ["Outer", "Inner"], member: "method"`
/// `"Foo."` → `containerChain: ["Foo"], member: ""`
/// `"Foo.bar(baz:)"` → `containerChain: ["Foo"], member: "bar(baz:)"` (a lone `:` is literal)
///
/// An unqualified query (no separator, e.g. `"description"`) fails to parse and returns `nil`.
package struct QualifiedWorkspaceSymbolQuery: Equatable {
/// Container chain in outer-to-inner order. Always non-empty for a successfully-parsed query.
package let containerChain: [String]
/// The trailing component the user is searching for. May be empty (e.g. for the query `Foo.`).
package let member: String
/// Parse a `workspace/symbol` query, splitting on the trailing `.` or `::` qualifier separators.
///
/// Returns `nil` if the query has no qualifier or the container chain is empty (so callers can fall
/// back to the unqualified search path). A trailing separator with an empty member (e.g. `Foo.`)
/// is a valid qualified query that lists all members of the container.
package init?(_ query: String) {
let components = Self.split(query)
// Without a separator the query isn't qualified; callers fall back to the unqualified search path. An
// empty member is allowed (e.g. `Foo.` lists all members of the container).
guard components.count >= 2, let member = components.last,
let containerChain = Self.validatedChain(components.dropLast())
else {
return nil
}
self.containerChain = containerChain
self.member = member
}
/// Split a qualified name or query on its `.` and `::` separators.
///
/// A lone `:` is not a separator — it is a literal character, e.g. an argument label in
/// `Collection.append(contentsOf:)`. Empty components are preserved so that callers can reject a
/// leading or doubled separator.
private static func split(_ string: String) -> [String] {
return
string
.replacing("::", with: ".")
.split(separator: ".", omittingEmptySubsequences: false)
.map(String.init)
}
/// Parse a chain of container names, e.g. `Outer.Inner` or `Outer::Inner`.
///
/// Returns `nil` if any name in the chain is empty, which rejects the empty string as well as a
/// leading, trailing or doubled separator.
package static func containerChain(_ containerName: String) -> [String]? {
return validatedChain(split(containerName)[...])
}
/// `components` as a container chain, or `nil` if any of them is empty.
private static func validatedChain(_ components: ArraySlice<String>) -> [String]? {
guard !components.contains(where: \.isEmpty) else { return nil }
return Array(components)
}
}
extension String {
/// Returns `true` if the characters of `subsequence` appear in order within `self`, compared
/// case-insensitively. An empty `subsequence` always matches.
package func fuzzilyContains(subsequence: String) -> Bool {
var remaining = Substring(subsequence.lowercased())
for character in self.lowercased() where character == remaining.first {
remaining = remaining.dropFirst()
}
return remaining.isEmpty
}
}
/// Minimum supported pattern length for a `workspace/symbol` request, smaller pattern
/// strings are not queried and instead we return no results.
private let minWorkspaceSymbolPatternLength = 3
/// The maximum number of results to return from a `workspace/symbol` request.
private let maxWorkspaceSymbolResults = 4096