diff --git a/Contributor Documentation/LSP Extensions.md b/Contributor Documentation/LSP Extensions.md index 25e5a2f7a..bb50e437c 100644 --- a/Contributor Documentation/LSP Extensions.md +++ b/Contributor Documentation/LSP Extensions.md @@ -389,6 +389,65 @@ export interface IsIndexingResult { } ``` +## `sourcekit/workspace/symbolInfo` + +New request that returns structured location information for a list of exact symbol names. + +Unlike the standard `workspace/symbol` request (which accepts a fuzzy query string), this request takes exact names — typically obtained from `sourcekit/workspace/symbolNames` — and returns all index occurrences for each name across every workspace. + +For each name the response contains zero or more `WorkspaceSymbolItem` values: +- Source-file symbols carry a `SymbolInformation` with a `file://` URI and the exact position. +- SDK/stdlib symbols carry a `WorkspaceSymbol` with `location: .uri(...)` pointing at the `file://` URI of the `.swiftinterface` or `.swiftmodule` file, with the fully-qualified module name as a `?module=` query parameter. The symbol's USR is stored in `data["usr"]`. Call `workspaceSymbol/resolve` to obtain the exact `Location` within the generated interface. The client must advertise `workspace.symbol.resolveSupport`; without it, the raw `file://` URI is returned as `SymbolInformation` instead. + +Every requested name is present in the response as a flat array; items carry their name in the `name` field of the `SymbolInformation` or `WorkspaceSymbol`. + +> [!IMPORTANT] +> This request is experimental and may be modified or removed in future versions of SourceKit-LSP without notice. Do not rely on it. + +- params: `WorkspaceSymbolInfoParams` +- result: `WorkspaceSymbolInfoResult` + +```ts +export interface WorkspaceSymbolInfoParams { + /** + * Symbol names to resolve. + */ + names: string[]; +} + +export interface WorkspaceSymbolInfoResult { + /** + * Flat list of all symbols matching the requested names. + * Each item carries the symbol name in its `name` field. + */ + results: WorkspaceSymbolItem[]; +} +``` + +## `sourcekit/workspace/symbolNames` + +New request that returns the flat, deduplicated list of every symbol name in the workspace index, including names from indexed system modules (stdlib, SDK frameworks). + +Clients use this list to drive a local search UI (fuzzy matching, prefix filtering, etc.) without a round-trip per keystroke. After the user selects a name, send a `sourcekit/workspace/symbolInfo` request to resolve it to concrete locations. + +> [!IMPORTANT] +> This request is experimental and may be modified or removed in future versions of SourceKit-LSP without notice. Do not rely on it. + +- params: `WorkspaceSymbolNamesParams` +- result: `WorkspaceSymbolNamesResult` + +```ts +export interface WorkspaceSymbolNamesParams {} + +export interface WorkspaceSymbolNamesResult { + /** + * Flat, deduplicated list of all symbol names in the workspace index, + * including names from system modules (stdlib, SDK). + */ + names: string[]; +} +``` + ## `window/didChangeActiveDocument` New notification from the client to the server, telling SourceKit-LSP which document is the currently active primary document. diff --git a/Documentation/Jump to Definition for SDK Symbols.md b/Documentation/Jump to Definition for SDK Symbols.md new file mode 100644 index 000000000..a13af12da --- /dev/null +++ b/Documentation/Jump to Definition for SDK Symbols.md @@ -0,0 +1,146 @@ +# Jump to Definition for SDK Symbols + +Jump to definition for SDK/stdlib symbols works by generating a textual Swift interface on demand and returning a +`sourcekit-lsp://` URI that the client can fetch via `workspace/getReferenceDocument`. + +`sourcekit-lsp://` URIs are opaque. Clients must treat them as identifiers and must not parse their structure to +extract information. + +## Requests Involved + +| Request | Direction | Purpose | +|---|---|---| +| `textDocument/definition` | Client → Server | Resolve the symbol under the cursor to a location | +| `workspace/getReferenceDocument` | Client → Server | Fetch the content of a `sourcekit-lsp://` URI | +| `textDocument/didOpen` | Client → Server | Notify the server that the generated interface is open | +| `textDocument/didClose` | Client → Server | Notify the server that the generated interface was closed | + +`workspace/getReferenceDocument` is a SourceKit-LSP extension. The client must advertise support in +`ClientCapabilities.experimental`: + +```json +{ "workspace/getReferenceDocument": { "supported": true } } +``` + +Without this capability the server writes the interface to a temporary file and returns a `file://` URI instead. + +## Workflow + +``` +Client Server + │ │ + │── textDocument/definition ──────────────▶│ + │◀─ Location { │ + │ uri: "sourcekit-lsp://...", │ + │ range: { line: 42, character: 14 } │ + │ } │ + │ │ + │── workspace/getReferenceDocument ───────▶│ + │◀─ { content: "..." } ────────────────────│ + │ │ + │ [open tab, scroll to range] │ + │ │ + │── textDocument/didOpen { │ + │ uri: "sourcekit-lsp://...", │ + │ languageId: "swift", │ + │ version: 1, │ + │ text: "" │ [increment ref count] + │ } ────────────────────────────────────▶│ + │ │ + │ [user closes the tab] │ + │ │ + │── textDocument/didClose ────────────────▶│ [decrement ref count; + │ │ close in sourcekitd + │ │ when it reaches 0] +``` + +1. **Definition** — the client requests the definition of the symbol at the cursor. For source-defined symbols the + server returns a `file://` URI with the exact source location. For SDK/stdlib symbols it returns a + `sourcekit-lsp://` URI and sets `range` to the symbol's position within the generated interface (computed + server-side via `editor.find_usr`). +2. **Content retrieval** — the client fetches the generated interface via `workspace/getReferenceDocument` to display + its content. The client scrolls to `range` from the definition response. +3. **Open notification** — once the client opens the tab it sends `textDocument/didOpen` with the interface content + (already fetched in step 2) as `text`, `languageId` set to `"swift"`, and `version` set to `1`. The server + increments the ref count in `GeneratedInterfaceManager`, keeping the interface open in sourcekitd as long as the + tab is open. +4. **Close notification** — when the client closes the tab it sends `textDocument/didClose`, which decrements the ref + count. When the ref count reaches zero the interface is eligible for eviction from the cache and will eventually be + closed in sourcekitd. + +## Server-Side Flow + +### 1. `textDocument/definition` handling + +The server first attempts an index-based lookup (`indexBasedDefinition`). For system/SDK symbols the index record +points to a `.swiftinterface` or `.swiftmodule` file, so the handler calls: + +``` +definitionInInterface( + moduleName: , + groupName: , + symbolUSR: , + originatorUri: +) +``` + +### 2. `openGeneratedInterface` + +`definitionInInterface` delegates to `SwiftLanguageService.openGeneratedInterface`, which: + +1. Constructs a fully-resolved `GeneratedInterfaceDocumentURLData` using `init(moduleName:groupName:primaryFile:)`: + - `sourcekitdDocumentName` is synthesised as `..` where `hash` is + `abs(buildSettingsFile.stringValue.hashValue)`. + - `buildSettingsFrom` is set to `originatorUri.buildSettingsFile` — the build settings file of the **requesting + source file**, not the module file. This ensures sourcekitd uses the same compiler arguments as the file that + triggered the request. +2. Calls `generatedInterfaceManager.position(ofUsr:in:)` to find the symbol's position within the generated interface + (see below). +3. Returns `GeneratedInterfaceDetails(uri: sourcekit-lsp://..., position: )`. + +### 3. Interface generation and caching + +`GeneratedInterfaceManager` opens the interface in sourcekitd via `editor.open.interface`: + +``` +keys.name: ".." +keys.moduleName: "" +keys.groupName: "" // if present +keys.synthesizedExtension: 1 +keys.compilerArgs: [... compiler arguments from build settings ...] +``` + +The resulting `sourceText` is cached in memory keyed by `sourcekitdDocumentName`. Subsequent requests for the same +module + build context reuse the cached snapshot. + +### 4. Symbol position within the interface + +`GeneratedInterfaceManager.position(ofUsr:in:)` sends `editor.find_usr` to sourcekitd: + +``` +keys.sourceFile: "" +keys.usr: "" +``` + +sourcekitd returns a byte offset, which is converted to a 0-based `Position` via +`DocumentSnapshot.positionOf(utf8Offset:)`. + +### 5. URI returned to the client + +``` +sourcekit-lsp://generated-swift-interface/Swift.String.swiftinterface + ?moduleName=Swift + &groupName=String + &sourcekitdDocument=Swift.String.12345678 + &buildSettingsFrom=file:///path/to/main.swift +``` + +### 6. `workspace/getReferenceDocument` handling + +The server extracts `buildSettingsFrom` from the URI to determine the language service: + +```swift +primaryLanguageService(for: buildSettingsUri, ...).getReferenceDocument(req) +``` + +`SwiftLanguageService.getReferenceDocument` retrieves the cached interface snapshot. diff --git a/Documentation/Open Quickly.md b/Documentation/Open Quickly.md new file mode 100644 index 000000000..7075ab0f3 --- /dev/null +++ b/Documentation/Open Quickly.md @@ -0,0 +1,305 @@ +# Open Quickly + +Open Quickly is a feature that lets editors provide fast symbol navigation across the entire workspace, including +symbols defined in SDK `.swiftinterface` files. It is built on four LSP extensions that work together in a four-phase +flow. + +## LSP Extensions + +### `sourcekit/workspace/symbolNames` — Discovery + +Returns the flat list of every symbol name currently in the workspace index. The client uses this list to drive its +search UI (fuzzy matching, prefix filtering, etc.). + +``` +→ WorkspaceSymbolNamesRequest {} +← WorkspaceSymbolNamesResponse { + names: ["String", "Array", "Dictionary", "MyViewController", ...] + } +``` + +### `sourcekit/workspace/symbolInfo` — Resolution + +Given a list of names selected by the client after searching, returns structured location information for each name. +Unlike the standard `workspace/symbol` request (which maps a query string to matching symbols), this request takes +exact names and returns all occurrences. + +The shape of each result item depends on the symbol's origin: + +**Source-file symbols** — returned as `SymbolInformation` with a `file://` URI and the range from the index. + +``` +→ WorkspaceSymbolInfoRequest { names: ["MyViewController"] } +← WorkspaceSymbolInfoResponse { + results: [ + SymbolInformation { + name: "MyViewController", + kind: .class, + location: Location { + uri: "file:///path/to/MyViewController.swift", + range: { line: 3, character: 0 } + } + } + ] + } +``` + +**SDK/stdlib symbols** — returned as `WorkspaceSymbol` with `location: .uri(file:// URL)` (no range) pointing to the +`.swiftinterface` or `.swiftmodule` file from the index record, when the client advertises +`workspace.symbol.resolveSupport.properties` containing `"location"` or both `"location.uri"` and `"location.range"`. +The fully-qualified module name (e.g. `Swift.String`) is appended as a `?module=` query parameter on the location URL +so clients can derive a display path without inspecting the `data` dictionary. The symbol's USR is stored in the +`data` dictionary. The client must call `workspaceSymbol/resolve` to obtain the exact location within the generated +interface. + +``` +→ WorkspaceSymbolInfoRequest { names: ["String"] } +← WorkspaceSymbolInfoResponse { + results: [ + WorkspaceSymbol { + name: "String", + kind: .struct, + location: { uri: "file:///path/to/Swift.swiftmodule/arm64-apple-macosx.swiftinterface?module=Swift.String" }, + data: { "usr": "s:SS" } + } + ] + } +``` + +Without that capability, the raw `file://` URI of the `.swiftinterface` or `.swiftmodule` file from the index record +is returned as `SymbolInformation` instead. + +The response is a flat array of `WorkspaceSymbolItem` values. Each item carries the symbol name in its `name` field. + +### `workspaceSymbol/resolve` — Location Resolution + +Resolves the lazy location of a `WorkspaceSymbol` returned by `sourcekit/workspace/symbolInfo`. The server parses +`moduleName` and `groupName` from the `?module=` query parameter of the location URL, reads `usr` from the `data` +dictionary, opens the generated Swift interface for the symbol's module, finds the symbol's position using the USR, +and returns the same symbol with `location` replaced by a full `Location` (URI + range). + +> [!NOTE] +> The LSP 3.17 spec defines `workspaceSymbol/resolve` as filling in a missing `range` for a URI-only location — it +> does not anticipate replacing `location.uri` itself. SourceKit-LSP goes beyond the spec here: it substitutes the +> `file://` module-file URL with a `sourcekit-lsp://` URI that the client can pass to `workspace/getReferenceDocument` +> to retrieve the generated interface content. + +``` +→ WorkspaceSymbolResolveRequest { + workspaceSymbol: WorkspaceSymbol { + name: "String", + kind: .struct, + location: { uri: "file:///path/to/Swift.swiftmodule/arm64-apple-macosx.swiftinterface?module=Swift.String" }, + data: { "usr": "s:SS" } + } + } +← WorkspaceSymbol { + name: "String", + kind: .struct, + location: Location { + uri: "sourcekit-lsp://generated-swift-interface/Swift.String.swiftinterface?moduleName=Swift&groupName=String&sourcekitdDocument=Swift.String.12345678&buildSettingsFrom=file:///path/to/Sources/main.swift", + range: { line: 42, character: 14 } + } + } +``` + +The resolved URI is a fully-parameterized `sourcekit-lsp://generated-swift-interface/` URL containing +`sourcekitdDocument` and `buildSettingsFrom` derived from a real source file in the workspace via +`mainFiles(containing:)`. + +The client must treat the resolved `sourcekit-lsp://` URI as **opaque** — it should not parse or extract information +from the query parameters. The path component (e.g. `Swift.String.swiftinterface`) may be used as the editor tab +title. The URI is otherwise only valid as an input to `workspace/getReferenceDocument`; its query parameter structure +is an implementation detail subject to change. + +### `workspace/getReferenceDocument` — Content Retrieval + +Fetches the text content of a reference document URI (e.g. a generated Swift interface). This is a pure content +provider — it returns the document text and nothing else. + +``` +→ GetReferenceDocumentRequest { uri: "sourcekit-lsp://generated-swift-interface/...?sourcekitdDocument=...&..." } +← GetReferenceDocumentResponse { + content: "// Swift.String\n...\npublic struct String { ... }" + } +``` + +The URI passed here must be a fully resolved URI (with `sourcekitdDocument` set), as returned by +`workspaceSymbol/resolve`. + +## Workflow + +``` +Client Server + │ │ + │── sourcekit/workspace/symbolNames ──────────────▶│ + │◀─ { ["String", "Array", ...] } ──────────────────│ + │ │ + │ [user types "Str"] │ + │ │ + │── sourcekit/workspace/symbolInfo │ + │ {["String", "Stride", ...]} ────────────────▶│ + │◀─ [WorkspaceSymbol] ─────────────────────────────│ + │ (location: "file://...?module=Swift.String") │ + │ │ + │ [user selects "String"] │ + │ │ + │── workspaceSymbol/resolve ──────────────────────▶│ + │◀─ WorkspaceSymbol │ + │ (url: "sourcekit-lsp://...", range: ...) ────│ + │ │ + │── workspace/getReferenceDocument ───────────────▶│ + │◀─ { content: "..." } ────────────────────────────│ + │ │ + │ [open tab, scroll to range.start] │ +``` + +1. **Discovery** — fetch all names; client filters locally. +2. **Resolution** — send matching name(s) to populate the search result list; server returns symbol details (kind, + container name, location) for display. + - Source symbols: `SymbolInformation` with a `file://` URI and exact position. No further steps required. + - SDK/stdlib symbols: `WorkspaceSymbol` with `location: .uri(file:// URL?module=...)` pointing to the module file + and the USR in `data["usr"]`, when the client advertises `workspace.symbol.resolveSupport.properties` containing + `"location"` or both `"location.uri"` and `"location.range"`. Otherwise falls back to `SymbolInformation` with + the raw `file://` URI. +3. **Location resolution** — call `workspaceSymbol/resolve` with the selected `WorkspaceSymbol` to open the generated + interface and resolve the symbol position. The server synthesizes the final `sourcekit-lsp://` URI and fills in + `location.range`. +4. **Content retrieval** — fetch the generated interface text. The editor scrolls to `location.range.start` from the + resolve step. + +## Pre-resolve Location Design for SDK/stdlib Symbols + +When `sourcekit/workspace/symbolInfo` returns a `WorkspaceSymbol` for an SDK or stdlib symbol, the location is a +`file://` URL pointing to the `.swiftinterface` or `.swiftmodule` file recorded in the index, with the +fully-qualified module name appended as a `?module=` query parameter. The `data` field carries only the USR. There is +no special URI scheme to parse. + +> [!NOTE] +> The `file://path/to/module.{swiftinterface,swiftmodule}?module=` URL format is intended to become a +> general-purpose format used throughout sourcekit-lsp wherever a module file location with associated module name +> needs to be represented. It is currently only produced by `sourcekit/workspace/symbolInfo`. +> +> In the future, `sourcekit/workspace/symbolInfo` may return other URL schemes. Clients should treat the location URI +> as opaque and pass it back to the server via `workspaceSymbol/resolve` without attempting to parse or interpret it. + +`DocumentURI` equality and hashing use the filesystem path (via `withUnsafeFileSystemRepresentation`), which strips +query parameters, so the URL with `?module=` compares equal to the clean path for all index and build-system lookups. + +### `WorkspaceSymbol` fields + +| Field | Value | +|---|---| +| `location` | `.uri(LocationURI)` — a `file://` URL to the `.swiftinterface` or `.swiftmodule` file, with `?module=` appended (e.g. `?module=Swift.String`) | +| `data["usr"]` | Unified Symbol Reference string (e.g. `"s:SS"`) — used by `workspaceSymbol/resolve` to pinpoint the symbol's line/column within the generated interface | + +### `?module=` query parameter + +The `?module=` value is the fully-qualified dotted module name recorded in the index (e.g. `Swift.String`, +`Foundation`). The server appends it when constructing the location URL: + +```swift +var urlComponents = URLComponents(string: moduleFileURI.stringValue)! +urlComponents.queryItems = [URLQueryItem(name: "module", value: fullModuleName)] +``` + +`workspaceSymbol/resolve` splits the value on the first `.` to derive `moduleName` and `groupName` for sourcekitd: + +```swift +// fullModuleName = "Swift.String" +moduleName = "Swift" // passed to openGeneratedInterface +groupName = "String" // passed to openGeneratedInterface +``` + +If there is no dot (e.g. `Foundation`), `groupName` is absent. + +### Example pre-resolve `WorkspaceSymbol` + +**`Swift.String`** (USR `s:SS`) + +``` +WorkspaceSymbol { + name: "String", + kind: .struct, + location: .uri("file:///path/to/Swift.swiftmodule/arm64-apple-macosx.swiftinterface?module=Swift.String"), + data: { "usr": "s:SS" } +} +``` + +### `workspaceSymbol/resolve` transformation + +The resolve step parses the location URL, extracts the clean file path (query excluded via `urlComponents.path`) for +`mainFiles(containing:)`, then opens the generated interface via sourcekitd: + +1. Parse `?module=Swift.String` from `uriOnly.uri.arbitrarySchemeURL`; split at first `.` → `moduleName`, `groupName` +2. Read `usr` from `data["usr"]` +3. Look up a real source file via `mainFiles(containing: moduleFileURI)`, sorted by URL string for determinism; pick + `.first` +4. Call `openGeneratedInterface(document: primaryFile, moduleName:, groupName:, symbolUSR:)` +5. Return the symbol with `location` replaced by a full `Location` (resolved `sourcekit-lsp://` URI + range) + +## `sourcekit-lsp://` URI for Resolved Locations + +After `workspaceSymbol/resolve`, the location URI is a fully-parameterized `sourcekit-lsp://generated-swift-interface/` +URL. + +### URL Structure + +``` +sourcekit-lsp:///? +``` + +| Component | Value for generated interfaces | +|---|---| +| `document-type` | `generated-swift-interface` | +| `display-name` | Human-readable filename shown in the editor tab/breadcrumb (e.g. `Swift.String.swiftinterface`). **Not used when parsing the URI** — all functional data is in the query parameters. | +| `moduleName` | Top-level module name (e.g. `Swift`) | +| `groupName` | Sub-module / group within the module, if any (e.g. `String`) | +| `sourcekitdDocument` | Passed as `keys.name` to sourcekitd's `editor.open.interface` request — the buffer handle by which sourcekitd tracks the open interface. Synthesized by `workspaceSymbol/resolve` as `..` (e.g. `Swift.String.12345678`) to make the buffer name unique per build-settings context. | +| `buildSettingsFrom` | URI of a real source file in the workspace, obtained via `mainFiles(containing:)`. Used to derive build settings for the generated interface. | + +### `display-name` derivation + +| `moduleName` | `groupName` | `display-name` | +|---|---|---| +| `Swift` | `String` | `Swift.String.swiftinterface` | +| `Foundation` | `NSURLSession` | `Foundation.NSURLSession.swiftinterface` | +| `Foundation` | _(none)_ | `Foundation.swiftinterface` | + +If `groupName` contains `/` (possible for nested groups), the slashes are replaced with `.` in the display name. + +### Example resolved URI + +**`Swift.String`** after `workspaceSymbol/resolve`: + +``` +sourcekit-lsp://generated-swift-interface/Swift.String.swiftinterface + ?moduleName=Swift + &groupName=String + &sourcekitdDocument=Swift.String.12345678 + &buildSettingsFrom=file:///path/to/MyProject/Sources/main.swift +``` + +## Notes + +- _User_ binary `.swiftmodule` files compiled without `-index-store-path` are **not** indexed — there is no index + store record for them, so their symbols do not appear in `sourcekit/workspace/symbolNames` or + `sourcekit/workspace/symbolInfo`. +- _System/non-user_ binary modules (`isNonUserModule() == true`) **are** indexed by the Swift compiler when + `indexSystemModules` is enabled (`IndexRecord.cpp: emitDataForSwiftSerializedModule`): + - *Resilient* system modules: the compiler reloads from the adjacent `.swiftinterface` before indexing. If no + interface is available, the module is skipped entirely. + - *Non-resilient* system modules and the stdlib: indexed directly from the binary; symbol locations in the index + point to the `.swiftmodule` file. +- Both `.swiftinterface` and `.swiftmodule` location paths are handled identically in `workspaceSymbolItem` — both + produce a `WorkspaceSymbol` with a `file://` location URI (carrying `?module=`) and a `data` dictionary with the + USR, when the client has the required capabilities. sourcekitd can synthesize a textual interface from either form. +- One client capability gates the `WorkspaceSymbol`/`.uri` path in `sourcekit/workspace/symbolInfo`: + - `ClientCapabilities.workspace.symbol.resolveSupport.properties` containing `"location"` or both `"location.uri"` + and `"location.range"` (LSP 3.17) — signals that the client can call `workspaceSymbol/resolve` to obtain a + range-bearing location. + - Without it, `sourcekit/workspace/symbolInfo` returns `SymbolInformation` with the raw `file://` URI from the + index record. +- The resolved location URI from `workspaceSymbol/resolve` uses a `sourcekit-lsp://generated-swift-interface/` scheme + when the client advertises `GetReferenceDocumentRequest` support, or a temp `file://` path otherwise. Both forms can + be used to open the generated interface. diff --git a/Sources/SemanticIndex/CheckedIndex.swift b/Sources/SemanticIndex/CheckedIndex.swift index 61ddf62ce..d0e0fbb93 100644 --- a/Sources/SemanticIndex/CheckedIndex.swift +++ b/Sources/SemanticIndex/CheckedIndex.swift @@ -379,6 +379,13 @@ package final actor UncheckedIndex: Sendable { return CheckedIndex(unchecked: self, checkLevel: checkLevel) } + package nonisolated func allSymbolNames() throws -> [String] { + guard let underlyingIndexStoreDB else { + throw IndexClosedError() + } + return underlyingIndexStoreDB.allSymbolNames() + } + /// Wait for IndexStoreDB to be updated based on new unit files written to disk. package nonisolated func pollForUnitChangesAndWait() { guard let underlyingIndexStoreDB else { diff --git a/Sources/SourceKitLSP/CapabilityRegistry.swift b/Sources/SourceKitLSP/CapabilityRegistry.swift index 0a91d9dc3..49e2fca6e 100644 --- a/Sources/SourceKitLSP/CapabilityRegistry.swift +++ b/Sources/SourceKitLSP/CapabilityRegistry.swift @@ -114,6 +114,21 @@ package final actor CapabilityRegistry { return clientHasExperimentalCapability(WorkspacePlaygroundsRefreshRequest.method) } + package nonisolated var clientHasWorkspaceGetReferenceDocumentSupport: Bool { + return clientHasExperimentalCapability(GetReferenceDocumentRequest.method) + } + + /// Whether the client supports `workspaceSymbol/resolve` for lazy location resolution. + /// Requires `workspace.symbol.resolveSupport.properties` to contain `"location"` or + /// both `"location.uri"` and `"location.range"`. + package nonisolated var clientSupportsWorkspaceSymbolResolve: Bool { + guard let properties = clientCapabilities.workspace?.symbol?.resolveSupport?.properties else { + return false + } + return properties.contains("location") + || (properties.contains("location.uri") && properties.contains("location.range")) + } + package nonisolated func clientHasExperimentalCapability(_ name: String) -> Bool { guard case .dictionary(let experimentalCapabilities) = clientCapabilities.experimental else { return false diff --git a/Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift b/Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift index d6cdbc3a8..0c75ce5c8 100644 --- a/Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift +++ b/Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift @@ -242,6 +242,10 @@ package enum MessageHandlingDependencyTracker: QueueBasedMessageHandlerDependenc self = .freestanding case is WorkspaceSemanticTokensRefreshRequest: self = .freestanding + case is WorkspaceSymbolInfoRequest: + self = .freestanding + case is WorkspaceSymbolNamesRequest: + self = .freestanding case is WorkspaceSymbolResolveRequest: self = .freestanding case is WorkspaceSymbolsRequest: diff --git a/Sources/SourceKitLSP/MessageMetadata.swift b/Sources/SourceKitLSP/MessageMetadata.swift index 7a3898d29..02c76f91f 100644 --- a/Sources/SourceKitLSP/MessageMetadata.swift +++ b/Sources/SourceKitLSP/MessageMetadata.swift @@ -33,3 +33,12 @@ struct ResolveItemData: Codable, Hashable, LSPAnyCodable { self.uri = uri } } + +/// Metadata stored in `WorkspaceSymbol.data` to support `workspaceSymbol/resolve` for SDK symbols. +struct WorkspaceSymbolData: Codable, Hashable, LSPAnyCodable { + var usr: String + + init(usr: String) { + self.usr = usr + } +} diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index 2f829c867..211c70afa 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -890,6 +890,12 @@ extension SourceKitLSPServer: QueueBasedMessageHandler { await request.reply { try await subtypes(request.params) } case let request as RequestAndReply: await request.reply { try await supertypes(request.params) } + case let request as RequestAndReply: + await request.reply { try await workspaceSymbolNames(request.params) } + case let request as RequestAndReply: + await request.reply { try await workspaceSymbolInfo(request.params) } + case let request as RequestAndReply: + await request.reply { try await workspaceSymbolResolve(request.params) } case let request as RequestAndReply: await request.reply { try await workspaceSymbols(request.params) } case let request as RequestAndReply: @@ -1169,6 +1175,8 @@ extension SourceKitLSPServer { GetReferenceDocumentRequest.method: .dictionary(["version": .int(1)]), DidChangeActiveDocumentNotification.method: .dictionary(["version": .int(1)]), WorkspacePlaygroundsRefreshRequest.method: .dictionary(["version": .int(1)]), + WorkspaceSymbolNamesRequest.method: .dictionary(["version": .int(1)]), + WorkspaceSymbolInfoRequest.method: .dictionary(["version": .int(1)]), ] if let toolchain = await toolchainRegistry.preferredToolchain(containing: [\.swiftc]), toolchain.swiftPlay != nil { experimentalCapabilities[WorkspacePlaygroundsRequest.method] = .dictionary(["version": .int(1)]) @@ -1315,7 +1323,7 @@ extension SourceKitLSPServer { await orLog("Shutting down build server") { await workspace.buildServerManager.shutdown() } - await workspace.index(checkedFor: .deletedFiles)?.unchecked.close() + await workspace.uncheckedIndex?.close() } } } @@ -1761,6 +1769,215 @@ extension SourceKitLSPServer { return try await languageService.signatureHelp(req) } + /// Handle a workspace/symbolNames request, returning the name list. + func workspaceSymbolNames(_ req: WorkspaceSymbolNamesRequest) async throws -> WorkspaceSymbolNamesResponse { + 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 WorkspaceSymbolNamesResponse(names: symbols) + } + + /// Map a `SymbolOccurrence` from the index to a `WorkspaceSymbolItem` suitable for returning in a + /// `workspace/symbolInfo` response. + private nonisolated func workspaceSymbolItem( + for symbolOccurrence: SymbolOccurrence, + in index: CheckedIndex, + copiedFileMap: CopiedFileMap, + canUseWorkspaceSymbolResolve: Bool + ) throws -> WorkspaceSymbolItem? { + let containerNames = try index.containerNames(of: symbolOccurrence) + let containerName: String? = + if !containerNames.isEmpty { + switch symbolOccurrence.symbol.language { + case .cxx, .c, .objc: containerNames.joined(separator: "::") + case .swift: containerNames.joined(separator: ".") + } + } else { + nil + } + + // For SDK symbols (location in `.swiftinterface`/`.swiftmodule`), return a `WorkspaceSymbol` + // with a deferred location so the client can resolve it via `workspaceSymbol/resolve`. + // Falls through to `SymbolInformation` with regular file:// URL for clients without `workspace.symbol.resolveSupport`. + if canUseWorkspaceSymbolResolve, + symbolOccurrence.location.path.hasSuffix(".swiftinterface") + || symbolOccurrence.location.path.hasSuffix(".swiftmodule") + { + // URL: file://.swiftinterface?module= + // Clients use `module` to show e.g. "Swift > String". + guard let documentURL = symbolOccurrence.location.uri?.fileURL else { + return nil + } + guard var urlComponents = URLComponents(url: documentURL, resolvingAgainstBaseURL: false) else { + return nil + } + urlComponents.queryItems = [ + URLQueryItem(name: "module", value: symbolOccurrence.location.moduleName) + ] + guard let locationURL = urlComponents.url else { + return nil + } + + let usr = symbolOccurrence.symbol.usr + let data: LSPAny? = usr.isEmpty ? nil : WorkspaceSymbolData(usr: usr).encodeToLSPAny() + + return WorkspaceSymbolItem.workspaceSymbol( + WorkspaceSymbol( + name: symbolOccurrence.symbol.name, + kind: symbolOccurrence.symbol.kind.asLspSymbolKind(), + containerName: containerName, + location: .uri(.init(uri: DocumentURI(locationURL))), + data: data + ) + ) + } + + guard let symbolLocation = symbolOccurrence.location.lspLocation else { return nil } + let location = symbolLocation.adjusted(for: copiedFileMap) + return WorkspaceSymbolItem.symbolInformation( + SymbolInformation( + name: symbolOccurrence.symbol.name, + kind: symbolOccurrence.symbol.kind.asLspSymbolKind(), + deprecated: nil, + location: location, + containerName: containerName + ) + ) + } + + /// Handle a `workspace/symbolInfo` request. + /// + /// For each name in `req.names`, looks up all canonical occurrences in every workspace index and + /// converts 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. + /// + /// Every requested name is present as a key in the response, mapping to an empty array when there + /// are no occurrences. + func workspaceSymbolInfo(_ req: WorkspaceSymbolInfoRequest) async throws -> WorkspaceSymbolInfoResponse { + let canUseWorkspaceSymbolResolve = self.capabilityRegistry?.clientSupportsWorkspaceSymbolResolve ?? false + + var groupedResultPerWorkspace = await workspaces.concurrentMap { workspace -> [String: [WorkspaceSymbolItem]] in + guard let index = await workspace.index(checkedFor: .deletedFiles) else { + return [:] + } + var result: [String: [WorkspaceSymbolItem]] = [:] + let copiedFileMap = await workspace.buildServerManager.cachedCopiedFileMap + for name in req.names { + if Task.isCancelled { return [:] } + var symbols: [SymbolOccurrence] = [] + _ = orLog("Getting symbol occurrences") { + try index.forEachCanonicalSymbolOccurrence(byName: name) { symbolOccurrence in + symbols.append(symbolOccurrence) + return true + } + } + if Task.isCancelled { return [:] } + result[name] = symbols.compactMap { symbol in + orLog("Getting symbol information") { + try self.workspaceSymbolItem( + for: symbol, + in: index, + copiedFileMap: copiedFileMap, + canUseWorkspaceSymbolResolve: canUseWorkspaceSymbolResolve + ) + } + } + } + return result + } + + try Task.checkCancellation() + + // Flatten the result. + var result: [WorkspaceSymbolItem] = [] + for name in req.names { + for grouped in groupedResultPerWorkspace { + if let items = grouped[name] { + result.append(contentsOf: items) + } + } + } + return WorkspaceSymbolInfoResponse(results: result) + } + + /// Handle a `workspaceSymbol/resolve` request. + /// + /// If the symbol has a `location: .uri(moduleFileURL?module=...)` (as emitted by + /// `workspace/symbolInfo` for SDK/stdlib symbols), opens the generated Swift interface, resolves + /// the symbol position using `data["usr"]`, and returns the symbol with `location: .location(...)`. + /// Symbols with an already-resolved `location: .location(...)` are returned unchanged. + func workspaceSymbolResolve(_ req: WorkspaceSymbolResolveRequest) async throws -> WorkspaceSymbol { + var symbol = req.workspaceSymbol + guard + case .uri(let uriOnly) = symbol.location, + let urlComponents = URLComponents(url: uriOnly.uri.arbitrarySchemeURL, resolvingAgainstBaseURL: false), + let fullModuleName = urlComponents.queryItems?.last(where: { $0.name == "module" })?.value + else { + return symbol + } + + let moduleName: String + let groupName: String? + if let dotIndex = fullModuleName.firstIndex(of: ".") { + moduleName = String(fullModuleName[fullModuleName.startIndex.. [WorkspaceSymbolItem]? { @@ -1771,6 +1988,7 @@ extension SourceKitLSPServer { guard req.query.count >= minWorkspaceSymbolPatternLength else { return [] } + let canUseWorkspaceSymbolResolve = self.capabilityRegistry?.clientSupportsWorkspaceSymbolResolve ?? false var items: [WorkspaceSymbolItem] = [] for workspace in workspaces { guard let index = await workspace.index(checkedFor: .deletedFiles) else { @@ -1795,32 +2013,12 @@ extension SourceKitLSPServer { return true } try Task.checkCancellation() - for symbolOccurrence in symbols.sorted(by: <) { - try Task.checkCancellation() - guard let symbolLocation = symbolOccurrence.location.lspLocation else { continue } - let location = symbolLocation.adjusted(for: copiedFileMap) - - let containerNames = try index.containerNames(of: symbolOccurrence) - let containerName: String? - if containerNames.isEmpty { - containerName = nil - } else { - switch symbolOccurrence.symbol.language { - case .cxx, .c, .objc: containerName = containerNames.joined(separator: "::") - case .swift: containerName = containerNames.joined(separator: ".") - } - } - - items.append( - WorkspaceSymbolItem.symbolInformation( - SymbolInformation( - name: symbolOccurrence.symbol.name, - kind: symbolOccurrence.symbol.kind.asLspSymbolKind(), - deprecated: nil, - location: location, - containerName: containerName - ) - ) + items += try symbols.sorted(by: <).compactMap { + try self.workspaceSymbolItem( + for: $0, + in: index, + copiedFileMap: copiedFileMap, + canUseWorkspaceSymbolResolve: canUseWorkspaceSymbolResolve ) } } diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index 2e6a15061..142e23624 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -176,7 +176,7 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { /// The source code index, if available. /// /// Usually a checked index (retrieved using `index(checkedFor:)`) should be used instead of the unchecked index. - private var uncheckedIndex: UncheckedIndex? { + package var uncheckedIndex: UncheckedIndex? { get async { return await buildServerManager.mainFilesProvider(as: UncheckedIndex.self) } diff --git a/Sources/SwiftExtensions/Array+SortAndDedupe.swift b/Sources/SwiftExtensions/Array+SortAndDedupe.swift new file mode 100644 index 000000000..fbfe32e9c --- /dev/null +++ b/Sources/SwiftExtensions/Array+SortAndDedupe.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +extension Array where Element: Comparable { + /// Whether the array's elements are in strictly ascending order with no duplicates. + package var isSortedAndUnique: Bool { + @_specialize(where Element == String) + get { + self.indices.dropFirst().allSatisfy { i in + self[self.index(before: i)] < self[i] + } + } + } + + /// Sorts the array in place and removes duplicate elements. + @_specialize(where Element == String) + package mutating func sortAndDedupe() { + guard self.count > 1 else { + return + } + let remaining = withUnsafeMutableBufferPointer { buf -> Int in + buf.sort() + var writeIdx = buf.startIndex + for readIdx in buf.indices.dropFirst() { + if buf[readIdx] == buf[writeIdx] { + continue + } + buf.formIndex(after: &writeIdx) + buf.swapAt(writeIdx, readIdx) + } + buf.formIndex(after: &writeIdx) + return buf.distance(from: writeIdx, to: buf.endIndex) + } + self.removeLast(remaining) + } +} diff --git a/Sources/SwiftExtensions/CMakeLists.txt b/Sources/SwiftExtensions/CMakeLists.txt index 0165ffe02..893f257f1 100644 --- a/Sources/SwiftExtensions/CMakeLists.txt +++ b/Sources/SwiftExtensions/CMakeLists.txt @@ -1,5 +1,6 @@ set(sources Array+Safe.swift + Array+SortAndDedupe.swift AsyncUtils.swift Cache.swift CartesianProduct.swift diff --git a/Sources/SwiftLanguageService/MacroExpansion.swift b/Sources/SwiftLanguageService/MacroExpansion.swift index d7829b94e..02298fd69 100644 --- a/Sources/SwiftLanguageService/MacroExpansion.swift +++ b/Sources/SwiftLanguageService/MacroExpansion.swift @@ -191,7 +191,7 @@ extension SwiftLanguageService { } if self.capabilityRegistry.clientHasExperimentalCapability(PeekDocumentsRequest.method), - self.capabilityRegistry.clientHasExperimentalCapability(GetReferenceDocumentRequest.method) + self.capabilityRegistry.clientHasWorkspaceGetReferenceDocumentSupport { let expansionURIs = try macroExpansionReferenceDocumentURLs.map { try $0.uri } diff --git a/Sources/SwiftLanguageService/OpenInterface.swift b/Sources/SwiftLanguageService/OpenInterface.swift index 52a77e9ed..f24e1a07c 100644 --- a/Sources/SwiftLanguageService/OpenInterface.swift +++ b/Sources/SwiftLanguageService/OpenInterface.swift @@ -42,7 +42,7 @@ extension SwiftLanguageService { nil } - if self.capabilityRegistry.clientHasExperimentalCapability(GetReferenceDocumentRequest.method) { + if self.capabilityRegistry.clientHasWorkspaceGetReferenceDocumentSupport { return GeneratedInterfaceDetails(uri: try urlData.uri, position: position) } let interfaceFilePath = self.generatedInterfacesPath diff --git a/Tests/SourceKitLSPTests/WorkspaceSymbolInfoTests.swift b/Tests/SourceKitLSPTests/WorkspaceSymbolInfoTests.swift new file mode 100644 index 000000000..ccda576c2 --- /dev/null +++ b/Tests/SourceKitLSPTests/WorkspaceSymbolInfoTests.swift @@ -0,0 +1,201 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation +@_spi(SourceKitLSP) import LanguageServerProtocol +import SKTestSupport +import SKUtilities +import SwiftExtensions +import TSCBasic +import ToolchainRegistry +import XCTest + +final class WorkspaceSymbolInfoTests: XCTestCase { + /// Returns the first `WorkspaceSymbol` in a `workspaceSymbolInfo` response for `name` whose + /// location is a `file://` URI to a module file (`.swiftinterface` or `.swiftmodule`). + private func generatedInterfaceSymbol( + for name: String, + in response: WorkspaceSymbolInfoResponse + ) -> WorkspaceSymbol? { + for case .workspaceSymbol(let symbol) in response.results { + if symbol.name == name, + case .uri(let uriOnly) = symbol.location, + let path = uriOnly.uri.fileURL?.path, + path.hasSuffix(".swiftinterface") || path.hasSuffix(".swiftmodule") + { + return symbol + } + } + return nil + } + + func testWorkspaceSymbolNamesContainsSourceSymbols() async throws { + let project = try await IndexedSingleSwiftFileTestProject( + """ + public struct MyStruct {} + public func myFunction() {} + """, + indexSystemModules: true + ) + + let response = try await project.testClient.send(WorkspaceSymbolNamesRequest()) + + assertContains(response.names, "MyStruct") + assertContains(response.names, "myFunction()") + // Stdlib types should be included as it's implicitly imported. + assertContains(response.names, "String") + } + + func testWorkspaceSymbolInfoAndResolveForStdlibSymbol() async throws { + let project = try await IndexedSingleSwiftFileTestProject( + """ + let x: String = "" + """, + capabilities: ClientCapabilities( + workspace: .init(symbol: .init(resolveSupport: .init(properties: ["location"]))), + experimental: [GetReferenceDocumentRequest.method: .dictionary(["supported": .bool(true)])] + ), + indexSystemModules: true + ) + + let response = try await project.testClient.send(WorkspaceSymbolInfoRequest(names: ["String"])) + + // workspace/symbolInfo returns a deferred WorkspaceSymbol for SDK symbols: + // location is a file://?module= URI (no range), USR in data["usr"]. + let symbol = try XCTUnwrap( + generatedInterfaceSymbol(for: "String", in: response), + "Expected a 'String' WorkspaceSymbol with a module file URI location" + ) + guard case .uri(let uriOnly) = symbol.location else { + XCTFail("Expected .uri location, got \(symbol.location)") + return + } + let path = try XCTUnwrap(uriOnly.uri.fileURL?.path) + XCTAssert( + path.hasSuffix(".swiftinterface") || path.hasSuffix(".swiftmodule"), + "Expected a .swiftinterface or .swiftmodule path, got: \(path)" + ) + let urlComponents = try XCTUnwrap(URLComponents(string: uriOnly.uri.arbitrarySchemeURL.absoluteString)) + let queryItems = urlComponents.queryItems + let moduleParam = try XCTUnwrap( + queryItems?.first(where: { $0.name == "module" })?.value, + "URI should contain a ?module= query parameter" + ) + XCTAssertFalse(moduleParam.isEmpty, "?module= query parameter should be non-empty") + XCTAssertNil(urlComponents.fragment, "URI should not contain a fragment") + + // workspaceSymbol/resolve turns the deferred URI into a sourcekit-lsp:// location with a range. + let resolved = try await project.testClient.send( + WorkspaceSymbolResolveRequest(workspaceSymbol: symbol) + ) + guard case .location(let location) = resolved.location else { + XCTFail("Expected .location after resolve, got \(resolved.location)") + return + } + XCTAssertEqual(location.uri.scheme, "sourcekit-lsp") + + // getReferenceDocument delivers the interface text; the resolved range points at the declaration. + let refDoc = try await project.testClient.send(GetReferenceDocumentRequest(uri: location.uri)) + XCTAssert( + refDoc.content.contains("struct String"), + "Generated interface should contain 'struct String'" + ) + let lineTable = LineTable(refDoc.content) + let line = try XCTUnwrap(lineTable.line(at: location.range.lowerBound.line)) + .trimmingCharacters(in: .whitespaces) + XCTAssert( + line.contains("struct String"), + "Line at resolved position should contain 'struct String', got: '\(line)'" + ) + } + + /// Confirms that symbols from a binary-only `.swiftmodule` (compiled without `-index-store-path`) + /// do not appear in the workspace index, unlike symbols from source-compiled targets. + func testBinarySwiftModuleSymbolsNotIndexed() async throws { + guard let swiftc = await ToolchainRegistry.forTesting.default?.swiftc else { + throw XCTSkip("swiftc not found") + } + + // Compile a Swift module to binary .swiftmodule only — no -index-store-path, + // so its symbols are never written to any index store. + try await withTestScratchDir { binaryModuleDir in + let sourceFile = binaryModuleDir.appendingPathComponent("BinaryLib.swift") + try await "public struct BinaryOnlyStruct {}".writeWithRetry(to: sourceFile) + + var args = [ + swiftc.path, + "-emit-module", + "-module-name", "BinaryLib", + "-emit-module-path", binaryModuleDir.appendingPathComponent("BinaryLib.swiftmodule").path, + ] + if let sdk = defaultSDKPath { + args += ["-sdk", sdk] + } + // Pin the deployment target to macOS 10.13 to match SwiftPMTestProject's default, + // so the binary module is importable in the consumer project regardless of SDK version. + #if os(macOS) + #if arch(arm64) + args += ["-target", "arm64-apple-macosx10.13"] + #elseif arch(x86_64) + args += ["-target", "x86_64-apple-macosx10.13"] + #endif + #endif + args += [sourceFile.path] + try await Process.checkNonZeroExit(arguments: args) + + // Create a project that imports BinaryLib via its binary .swiftmodule only. + let project = try await SwiftPMTestProject( + files: [ + "Sources/App/main.swift": """ + import BinaryLib + public struct SourceStruct { + var binary: BinaryOnlyStruct + } + """ + ], + manifest: """ + let package = Package( + name: "App", + targets: [ + .executableTarget( + name: "App", + swiftSettings: [.unsafeFlags(["-I", "\(binaryModuleDir.path)"])] + ) + ] + ) + """, + enableBackgroundIndexing: true, + pollIndex: true + ) + XCTAssert(FileManager.default.fileExists(at: binaryModuleDir.appendingPathComponent("BinaryLib.swiftmodule"))) + + // Confirm the file has no error diagnostics — the binary .swiftmodule is importable. + let (mainUri, _) = try project.openDocument("main.swift") + let diagnostics = try await project.testClient.send( + DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(mainUri)) + ) + let errorDiagnostics = diagnostics.fullReport?.items.filter { $0.severity == .error } ?? [] + XCTAssert(errorDiagnostics.isEmpty, "Expected no errors in main.swift, got: \(errorDiagnostics)") + + let response = try await project.testClient.send(WorkspaceSymbolNamesRequest()) + + // SourceStruct is defined in source and compiled with -index-store-path → it IS indexed. + XCTAssert(response.names.contains("SourceStruct"), "Source-compiled symbol should appear in the index") + + // BinaryOnlyStruct lives only in the .swiftmodule binary → no index record was ever written for it. + XCTAssertFalse( + response.names.contains("BinaryOnlyStruct"), + "Symbol from binary-only .swiftmodule should not appear in the index" + ) + } + } +}