From 8d73731bcbf1b236a155f9ec5b22578ab457beff Mon Sep 17 00:00:00 2001 From: Alex Hoppen Date: Fri, 13 Dec 2024 07:19:41 -0800 Subject: [PATCH] Support semantic functionality in generated interfaces if the client supports `getReferenceDocument` This allows us to provide semantic functionality inside the generated interfaces, such as hover or jump-to-definition. rdar://125663597 --- .../IndexedSingleSwiftFileTestProject.swift | 2 + Sources/SourceKitLSP/CMakeLists.txt | 5 +- .../MessageHandlingDependencyTracker.swift | 4 +- Sources/SourceKitLSP/SourceKitLSPServer.swift | 12 +- .../GeneratedInterfaceDocumentURLData.swift | 87 +++++++ .../Swift/GeneratedInterfaceManager.swift | 233 ++++++++++++++++++ .../SourceKitLSP/Swift/MacroExpansion.swift | 8 +- ...croExpansionReferenceDocumentURLData.swift | 6 +- .../SourceKitLSP/Swift/OpenInterface.swift | 136 +++------- .../Swift/ReferenceDocumentURL.swift | 74 ++++-- .../Swift/SwiftLanguageService.swift | 57 ++++- Sources/SourceKitLSP/Workspace.swift | 2 +- .../SwiftInterfaceTests.swift | 119 ++++++++- 13 files changed, 589 insertions(+), 156 deletions(-) create mode 100644 Sources/SourceKitLSP/Swift/GeneratedInterfaceDocumentURLData.swift create mode 100644 Sources/SourceKitLSP/Swift/GeneratedInterfaceManager.swift diff --git a/Sources/SKTestSupport/IndexedSingleSwiftFileTestProject.swift b/Sources/SKTestSupport/IndexedSingleSwiftFileTestProject.swift index 150a34f6e..92fd1d738 100644 --- a/Sources/SKTestSupport/IndexedSingleSwiftFileTestProject.swift +++ b/Sources/SKTestSupport/IndexedSingleSwiftFileTestProject.swift @@ -51,6 +51,7 @@ package struct IndexedSingleSwiftFileTestProject { /// - cleanUp: Whether to remove the temporary directory when the SourceKit-LSP server shuts down. package init( _ markedText: String, + capabilities: ClientCapabilities = ClientCapabilities(), indexSystemModules: Bool = false, allowBuildFailure: Bool = false, workspaceDirectory: URL? = nil, @@ -153,6 +154,7 @@ package struct IndexedSingleSwiftFileTestProject { ) self.testClient = try await TestSourceKitLSPClient( options: options, + capabilities: capabilities, workspaceFolders: [ WorkspaceFolder(uri: DocumentURI(testWorkspaceDirectory)) ], diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index 51702cce5..8629d1edc 100644 --- a/Sources/SourceKitLSP/CMakeLists.txt +++ b/Sources/SourceKitLSP/CMakeLists.txt @@ -48,11 +48,14 @@ target_sources(SourceKitLSP PRIVATE Swift/DocumentSymbols.swift Swift/ExpandMacroCommand.swift Swift/FoldingRange.swift + Swift/GeneratedInterfaceDocumentURLData.swift + Swift/GeneratedInterfaceManager.swift + Swift/GeneratedInterfaceManager.swift Swift/MacroExpansion.swift Swift/MacroExpansionReferenceDocumentURLData.swift Swift/OpenInterface.swift - Swift/RefactoringResponse.swift Swift/RefactoringEdit.swift + Swift/RefactoringResponse.swift Swift/ReferenceDocumentURL.swift Swift/RelatedIdentifiers.swift Swift/RewriteSourceKitPlaceholders.swift diff --git a/Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift b/Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift index 4680c7e73..b033b9538 100644 --- a/Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift +++ b/Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift @@ -74,9 +74,9 @@ package enum MessageHandlingDependencyTracker: QueueBasedMessageHandlerDependenc case (.documentUpdate(let selfUri), .documentUpdate(let otherUri)): return selfUri == otherUri case (.documentUpdate(let selfUri), .documentRequest(let otherUri)): - return selfUri == otherUri + return selfUri.buildSettingsFile == otherUri.buildSettingsFile case (.documentRequest(let selfUri), .documentUpdate(let otherUri)): - return selfUri == otherUri + return selfUri.buildSettingsFile == otherUri.buildSettingsFile // documentRequest case (.documentRequest, .documentRequest): diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index 323d6fb12..2891e484a 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -242,7 +242,7 @@ package actor SourceKitLSPServer { } package func workspaceForDocument(uri: DocumentURI) async -> Workspace? { - let uri = uri.primaryFile ?? uri + let uri = uri.buildSettingsFile if let cachedWorkspace = self.workspaceForUri[uri]?.value { return cachedWorkspace } @@ -1590,14 +1590,14 @@ extension SourceKitLSPServer { } func getReferenceDocument(_ req: GetReferenceDocumentRequest) async throws -> GetReferenceDocumentResponse { - let primaryFileURI = try ReferenceDocumentURL(from: req.uri).primaryFile + let buildSettingsUri = try ReferenceDocumentURL(from: req.uri).buildSettingsFile - guard let workspace = await workspaceForDocument(uri: primaryFileURI) else { - throw ResponseError.workspaceNotOpen(primaryFileURI) + guard let workspace = await workspaceForDocument(uri: buildSettingsUri) else { + throw ResponseError.workspaceNotOpen(buildSettingsUri) } - guard let languageService = workspace.documentService(for: primaryFileURI) else { - throw ResponseError.unknown("No Language Service for URI: \(primaryFileURI)") + guard let languageService = workspace.documentService(for: buildSettingsUri) else { + throw ResponseError.unknown("No Language Service for URI: \(buildSettingsUri)") } return try await languageService.getReferenceDocument(req) diff --git a/Sources/SourceKitLSP/Swift/GeneratedInterfaceDocumentURLData.swift b/Sources/SourceKitLSP/Swift/GeneratedInterfaceDocumentURLData.swift new file mode 100644 index 000000000..f0d5031d7 --- /dev/null +++ b/Sources/SourceKitLSP/Swift/GeneratedInterfaceDocumentURLData.swift @@ -0,0 +1,87 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 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 +import LanguageServerProtocol + +/// Represents url of generated interface reference document. + +package struct GeneratedInterfaceDocumentURLData: Hashable, ReferenceURLData { + package static let documentType = "generated-swift-interface" + + private struct Parameters { + static let moduleName = "moduleName" + static let groupName = "groupName" + static let sourcekitdDocumentName = "sourcekitdDocument" + static let buildSettingsFrom = "buildSettingsFrom" + } + + /// The module that should be shown in this generated interface. + let moduleName: String + + /// The group that should be shown in this generated interface, if applicable. + let groupName: String? + + /// The name by which this document is referred to in sourcekitd. + let sourcekitdDocumentName: String + + /// The document from which the build settings for the generated interface should be inferred. + let buildSettingsFrom: DocumentURI + + var displayName: String { + if let groupName { + return "\(moduleName).\(groupName.replacing("/", with: ".")).swiftinterface" + } + return "\(moduleName).swiftinterface" + } + + var queryItems: [URLQueryItem] { + var result = [ + URLQueryItem(name: Parameters.moduleName, value: moduleName) + ] + if let groupName { + result.append(URLQueryItem(name: Parameters.groupName, value: groupName)) + } + result += [ + URLQueryItem(name: Parameters.sourcekitdDocumentName, value: sourcekitdDocumentName), + URLQueryItem(name: Parameters.buildSettingsFrom, value: buildSettingsFrom.stringValue), + ] + return result + } + + var uri: DocumentURI { + get throws { + try ReferenceDocumentURL.generatedInterface(self).uri + } + } + + init(moduleName: String, groupName: String?, sourcekitdDocumentName: String, primaryFile: DocumentURI) { + self.moduleName = moduleName + self.groupName = groupName + self.sourcekitdDocumentName = sourcekitdDocumentName + self.buildSettingsFrom = primaryFile + } + + init(queryItems: [URLQueryItem]) throws { + guard let moduleName = queryItems.last(where: { $0.name == Parameters.moduleName })?.value, + let sourcekitdDocumentName = queryItems.last(where: { $0.name == Parameters.sourcekitdDocumentName })?.value, + let primaryFile = queryItems.last(where: { $0.name == Parameters.buildSettingsFrom })?.value + else { + throw ReferenceDocumentURLError(description: "Invalid queryItems for generated interface reference document url") + } + + self.moduleName = moduleName + self.groupName = queryItems.last(where: { $0.name == Parameters.groupName })?.value + self.sourcekitdDocumentName = sourcekitdDocumentName + self.buildSettingsFrom = try DocumentURI(string: primaryFile) + } +} diff --git a/Sources/SourceKitLSP/Swift/GeneratedInterfaceManager.swift b/Sources/SourceKitLSP/Swift/GeneratedInterfaceManager.swift new file mode 100644 index 000000000..42a51a937 --- /dev/null +++ b/Sources/SourceKitLSP/Swift/GeneratedInterfaceManager.swift @@ -0,0 +1,233 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2022 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 LanguageServerProtocol +import SKLogging +import SKUtilities +import SourceKitD +import SwiftExtensions + +/// When information about a generated interface is requested, this opens the generated interface in sourcekitd and +/// caches the generated interface contents. +/// +/// It keeps the generated interface open in sourcekitd until the corresponding reference document is closed in the +/// editor. Additionally, it also keeps a few recently requested interfaces cached. This way we don't need to recompute +/// the generated interface contents between the initial generated interface request to find a USR's position in the +/// interface until the editor actually opens the reference document. +actor GeneratedInterfaceManager { + private struct OpenGeneratedInterfaceDocumentDetails { + let url: GeneratedInterfaceDocumentURLData + + /// The contents of the generated interface. + let snapshot: DocumentSnapshot + + /// The number of `GeneratedInterfaceManager` that are actively working with the sourcekitd document. If this value + /// is 0, the generated interface may be closed in sourcekitd. + /// + /// Usually, this value is 1, while the reference document for this generated interface is open in the editor. + var refCount: Int + } + + private weak var swiftLanguageService: SwiftLanguageService? + + /// The number of generated interface documents that are not in editor but should still be cached. + private let cacheSize = 2 + + /// Details about the generated interfaces that are currently open in sourcekitd. + /// + /// Conceptually, this is a dictionary with `url` being the key. To prevent excessive memory usage we only keep + /// `cacheSize` entries with a ref count of 0 in the array. Older entries are at the end of the list, newer entries + /// at the front. + private var openInterfaces: [OpenGeneratedInterfaceDocumentDetails] = [] + + init(swiftLanguageService: SwiftLanguageService) { + self.swiftLanguageService = swiftLanguageService + } + + /// If there are more than `cacheSize` entries in `openInterfaces` that have a ref count of 0, close the oldest ones. + private func purgeCache() { + var documentsToClose: [String] = [] + while openInterfaces.count(where: { $0.refCount == 0 }) > cacheSize, + let indexToPurge = openInterfaces.lastIndex(where: { $0.refCount == 0 }) + { + documentsToClose.append(openInterfaces[indexToPurge].url.sourcekitdDocumentName) + openInterfaces.remove(at: indexToPurge) + } + if !documentsToClose.isEmpty, let swiftLanguageService { + Task { + let sourcekitd = swiftLanguageService.sourcekitd + for documentToClose in documentsToClose { + await orLog("Closing generated interface") { + _ = try await swiftLanguageService.sendSourcekitdRequest( + sourcekitd.dictionary([ + sourcekitd.keys.request: sourcekitd.requests.editorClose, + sourcekitd.keys.name: documentToClose, + sourcekitd.keys.cancelBuilds: 0, + ]), + fileContents: nil + ) + } + } + } + } + } + + /// If we don't have the generated interface for the given `document` open in sourcekitd, open it, otherwise return + /// its details from the cache. + /// + /// If `incrementingRefCount` is `true`, then the document manager will keep the generated interface open in + /// sourcekitd, independent of the cache size. If `incrementingRefCount` is `true`, then `decrementRefCount` must be + /// called to allow the document to be closed again. + private func details( + for document: GeneratedInterfaceDocumentURLData, + incrementingRefCount: Bool + ) async throws -> OpenGeneratedInterfaceDocumentDetails { + func loadFromCache() -> OpenGeneratedInterfaceDocumentDetails? { + guard let cachedIndex = openInterfaces.firstIndex(where: { $0.url == document }) else { + return nil + } + if incrementingRefCount { + openInterfaces[cachedIndex].refCount += 1 + } + return openInterfaces[cachedIndex] + + } + if let cached = loadFromCache() { + return cached + } + + guard let swiftLanguageService else { + // `SwiftLanguageService` has been destructed. We are tearing down the language server. Nothing left to do. + throw ResponseError.unknown("Connection to the editor closed") + } + + let sourcekitd = swiftLanguageService.sourcekitd + + let keys = sourcekitd.keys + let skreq = sourcekitd.dictionary([ + keys.request: sourcekitd.requests.editorOpenInterface, + keys.moduleName: document.moduleName, + keys.groupName: document.groupName, + keys.name: document.sourcekitdDocumentName, + keys.synthesizedExtension: 1, + keys.compilerArgs: await swiftLanguageService.buildSettings(for: try document.uri, fallbackAfterTimeout: false)? + .compilerArgs as [SKDRequestValue]?, + ]) + + let dict = try await swiftLanguageService.sendSourcekitdRequest(skreq, fileContents: nil) + + guard let contents: String = dict[keys.sourceText] else { + throw ResponseError.unknown("sourcekitd response is missing sourceText") + } + + if let cached = loadFromCache() { + // Another request raced us to create the generated interface. Discard what we computed here and return the cached + // value. + await orLog("Closing generated interface created during race") { + _ = try await swiftLanguageService.sendSourcekitdRequest( + sourcekitd.dictionary([ + keys.request: sourcekitd.requests.editorClose, + keys.name: document.sourcekitdDocumentName, + keys.cancelBuilds: 0, + ]), + fileContents: nil + ) + } + return cached + } + + let details = OpenGeneratedInterfaceDocumentDetails( + url: document, + snapshot: DocumentSnapshot( + uri: try document.uri, + language: .swift, + version: 0, + lineTable: LineTable(contents) + ), + refCount: incrementingRefCount ? 1 : 0 + ) + openInterfaces.insert(details, at: 0) + purgeCache() + return details + } + + private func decrementRefCount(for document: GeneratedInterfaceDocumentURLData) { + guard let cachedIndex = openInterfaces.firstIndex(where: { $0.url == document }) else { + logger.fault( + "Generated interface document for \(document.moduleName) is not open anymore. Unbalanced retain and releases?" + ) + return + } + if openInterfaces[cachedIndex].refCount == 0 { + logger.fault( + "Generated interface document for \(document.moduleName) is already 0. Unbalanced retain and releases?" + ) + return + } + openInterfaces[cachedIndex].refCount -= 1 + purgeCache() + } + + func position(ofUsr usr: String, in document: GeneratedInterfaceDocumentURLData) async throws -> Position { + guard let swiftLanguageService else { + // `SwiftLanguageService` has been destructed. We are tearing down the language server. Nothing left to do. + throw ResponseError.unknown("Connection to the editor closed") + } + + let details = try await details(for: document, incrementingRefCount: true) + defer { + decrementRefCount(for: document) + } + + let sourcekitd = swiftLanguageService.sourcekitd + let keys = sourcekitd.keys + let skreq = sourcekitd.dictionary([ + keys.request: sourcekitd.requests.editorFindUSR, + keys.sourceFile: document.sourcekitdDocumentName, + keys.usr: usr, + ]) + + let dict = try await swiftLanguageService.sendSourcekitdRequest(skreq, fileContents: details.snapshot.text) + guard let offset: Int = dict[keys.offset] else { + throw ResponseError.unknown("Missing key 'offset'") + } + return details.snapshot.positionOf(utf8Offset: offset) + } + + func snapshot(of document: GeneratedInterfaceDocumentURLData) async throws -> DocumentSnapshot { + return try await details(for: document, incrementingRefCount: false).snapshot + } + + func open(document: GeneratedInterfaceDocumentURLData) async throws { + _ = try await details(for: document, incrementingRefCount: true) + } + + func close(document: GeneratedInterfaceDocumentURLData) async { + decrementRefCount(for: document) + } + + func reopen(interfacesWithBuildSettingsFrom buildSettingsFile: DocumentURI) async { + for openInterface in openInterfaces { + guard openInterface.url.buildSettingsFrom == buildSettingsFile else { + continue + } + await orLog("Reopening generated interface") { + // `MessageHandlingDependencyTracker` ensures that we don't handle a request for the generated interface while + // it is being re-opened because `documentUpdate` and `documentRequest` use the `buildSettingsFile` to determine + // their dependencies. + await close(document: openInterface.url) + openInterfaces.removeAll(where: { $0.url == openInterface.url }) + try await open(document: openInterface.url) + } + } + } +} diff --git a/Sources/SourceKitLSP/Swift/MacroExpansion.swift b/Sources/SourceKitLSP/Swift/MacroExpansion.swift index e6ae0dc94..73d2bd6ac 100644 --- a/Sources/SourceKitLSP/Swift/MacroExpansion.swift +++ b/Sources/SourceKitLSP/Swift/MacroExpansion.swift @@ -177,6 +177,8 @@ extension SwiftLanguageService { switch try? ReferenceDocumentURL(from: expandMacroCommand.textDocument.uri) { case .macroExpansion(let data): data.bufferName + case .generatedInterface(let data): + data.displayName case nil: expandMacroCommand.textDocument.uri.fileURL?.lastPathComponent ?? expandMacroCommand.textDocument.uri.pseudoPath } @@ -223,9 +225,7 @@ extension SwiftLanguageService { case .bool(true) = experimentalCapabilities["workspace/peekDocuments"], case .bool(true) = experimentalCapabilities["workspace/getReferenceDocument"] { - let expansionURIs = try macroExpansionReferenceDocumentURLs.map { - return DocumentURI(try $0.url) - } + let expansionURIs = try macroExpansionReferenceDocumentURLs.map { try $0.uri } let uri = expandMacroCommand.textDocument.uri.primaryFile ?? expandMacroCommand.textDocument.uri @@ -233,7 +233,7 @@ extension SwiftLanguageService { switch try? ReferenceDocumentURL(from: expandMacroCommand.textDocument.uri) { case .macroExpansion(let data): data.primaryFileSelectionRange.lowerBound - case nil: + case .generatedInterface, nil: expandMacroCommand.positionRange.lowerBound } diff --git a/Sources/SourceKitLSP/Swift/MacroExpansionReferenceDocumentURLData.swift b/Sources/SourceKitLSP/Swift/MacroExpansionReferenceDocumentURLData.swift index 2b6bb4277..8a0d0c6f1 100644 --- a/Sources/SourceKitLSP/Swift/MacroExpansionReferenceDocumentURLData.swift +++ b/Sources/SourceKitLSP/Swift/MacroExpansionReferenceDocumentURLData.swift @@ -31,7 +31,7 @@ import RegexBuilder /// - `bufferName` denotes the buffer name of the specific macro expansion edit /// - `parent` denoting the URI of the document from which the macro was expanded. For a first-level macro expansion, /// this is a file URI. For nested macro expansions, this is a `sourcekit-lsp://swift-macro-expansion` URL. -package struct MacroExpansionReferenceDocumentURLData { +package struct MacroExpansionReferenceDocumentURLData: ReferenceURLData { package static let documentType = "swift-macro-expansion" /// The document from which this macro was expanded. For first-level macro expansions, this is a file URL. For @@ -146,7 +146,7 @@ package struct MacroExpansionReferenceDocumentURLData { switch try? ReferenceDocumentURL(from: parent) { case .macroExpansion(let data): data.primaryFile - case nil: + case .generatedInterface, nil: parent } } @@ -155,7 +155,7 @@ package struct MacroExpansionReferenceDocumentURLData { switch try? ReferenceDocumentURL(from: parent) { case .macroExpansion(let data): data.primaryFileSelectionRange - case nil: + case .generatedInterface, nil: self.parentSelectionRange } } diff --git a/Sources/SourceKitLSP/Swift/OpenInterface.swift b/Sources/SourceKitLSP/Swift/OpenInterface.swift index 614b6ec85..35b5565ab 100644 --- a/Sources/SourceKitLSP/Swift/OpenInterface.swift +++ b/Sources/SourceKitLSP/Swift/OpenInterface.swift @@ -10,24 +10,15 @@ // //===----------------------------------------------------------------------===// -#if compiler(>=6) import Foundation -package import LanguageServerProtocol import SKLogging -import SKUtilities -import SourceKitD + +#if compiler(>=6) +package import LanguageServerProtocol #else -import Foundation import LanguageServerProtocol -import SKLogging -import SKUtilities -import SourceKitD #endif -struct GeneratedInterfaceInfo { - var contents: String -} - extension SwiftLanguageService { package func openGeneratedInterface( document: DocumentURI, @@ -35,104 +26,35 @@ extension SwiftLanguageService { groupName: String?, symbolUSR symbol: String? ) async throws -> GeneratedInterfaceDetails? { - // Name of interface module name with group names appended - let name = - if let groupName { - "\(moduleName).\(groupName.replacing("/", with: "."))" + let urlData = GeneratedInterfaceDocumentURLData( + moduleName: moduleName, + groupName: groupName, + sourcekitdDocumentName: "\(moduleName)-\(UUID())", + primaryFile: document + ) + let position: Position? = + if let symbol { + await orLog("Getting position of USR") { + try await generatedInterfaceManager.position(ofUsr: symbol, in: urlData) + } } else { - moduleName - } - let interfaceFilePath = self.generatedInterfacesPath.appendingPathComponent("\(name).swiftinterface") - let interfaceDocURI = DocumentURI(interfaceFilePath) - // has interface already been generated - if let snapshot = try? await self.latestSnapshot(for: interfaceDocURI) { - return await self.generatedInterfaceDetails( - uri: interfaceDocURI, - snapshot: snapshot, - symbol: symbol - ) - } else { - let interfaceInfo = try await self.generatedInterfaceInfo( - document: document, - moduleName: moduleName, - groupName: groupName, - interfaceURI: interfaceDocURI - ) - try interfaceInfo.contents.write(to: interfaceFilePath, atomically: true, encoding: String.Encoding.utf8) - let snapshot = DocumentSnapshot( - uri: interfaceDocURI, - language: .swift, - version: 0, - lineTable: LineTable(interfaceInfo.contents) - ) - let result = await self.generatedInterfaceDetails( - uri: interfaceDocURI, - snapshot: snapshot, - symbol: symbol - ) - _ = await orLog("Closing generated interface") { - try await sendSourcekitdRequest(closeDocumentSourcekitdRequest(uri: interfaceDocURI), fileContents: nil) - } - return result - } - } - - /// Open the Swift interface for a module. - /// - /// - Parameters: - /// - document: The document whose compiler arguments should be used to generate the interface. - /// - moduleName: The module to generate an index for. - /// - groupName: The module group name. - /// - interfaceURI: The file where the generated interface should be written. - /// - /// - Important: This opens a document with name `interfaceURI.pseudoPath` in sourcekitd. The caller is responsible - /// for ensuring that the document will eventually get closed in sourcekitd again. - private func generatedInterfaceInfo( - document: DocumentURI, - moduleName: String, - groupName: String?, - interfaceURI: DocumentURI - ) async throws -> GeneratedInterfaceInfo { - let keys = self.keys - let skreq = sourcekitd.dictionary([ - keys.request: requests.editorOpenInterface, - keys.moduleName: moduleName, - keys.groupName: groupName, - keys.name: interfaceURI.pseudoPath, - keys.synthesizedExtension: 1, - keys.compilerArgs: await self.buildSettings(for: document, fallbackAfterTimeout: false)?.compilerArgs - as [SKDRequestValue]?, - ]) - - let dict = try await sendSourcekitdRequest(skreq, fileContents: nil) - return GeneratedInterfaceInfo(contents: dict[keys.sourceText] ?? "") - } - - private func generatedInterfaceDetails( - uri: DocumentURI, - snapshot: DocumentSnapshot, - symbol: String? - ) async -> GeneratedInterfaceDetails { - do { - guard let symbol = symbol else { - return GeneratedInterfaceDetails(uri: uri, position: nil) + nil } - let keys = self.keys - let skreq = sourcekitd.dictionary([ - keys.request: requests.editorFindUSR, - keys.sourceFile: uri.sourcekitdSourceFile, - keys.primaryFile: uri.primaryFile?.pseudoPath, - keys.usr: symbol, - ]) - let dict = try await sendSourcekitdRequest(skreq, fileContents: snapshot.text) - if let offset: Int = dict[keys.offset] { - return GeneratedInterfaceDetails(uri: uri, position: snapshot.positionOf(utf8Offset: offset)) - } else { - return GeneratedInterfaceDetails(uri: uri, position: nil) - } - } catch { - return GeneratedInterfaceDetails(uri: uri, position: nil) + if case .dictionary(let experimentalCapabilities) = self.capabilityRegistry.clientCapabilities.experimental, + case .bool(true) = experimentalCapabilities["workspace/getReferenceDocument"] + { + return GeneratedInterfaceDetails(uri: try urlData.uri, position: position) } + let interfaceFilePath = self.generatedInterfacesPath.appendingPathComponent(urlData.displayName) + try await generatedInterfaceManager.snapshot(of: urlData).text.write( + to: interfaceFilePath, + atomically: true, + encoding: String.Encoding.utf8 + ) + return GeneratedInterfaceDetails( + uri: DocumentURI(interfaceFilePath), + position: position + ) } } diff --git a/Sources/SourceKitLSP/Swift/ReferenceDocumentURL.swift b/Sources/SourceKitLSP/Swift/ReferenceDocumentURL.swift index 04d332322..8b5d86320 100644 --- a/Sources/SourceKitLSP/Swift/ReferenceDocumentURL.swift +++ b/Sources/SourceKitLSP/Swift/ReferenceDocumentURL.swift @@ -13,6 +13,12 @@ import Foundation import LanguageServerProtocol +protocol ReferenceURLData { + static var documentType: String { get } + var displayName: String { get } + var queryItems: [URLQueryItem] { get } +} + /// A Reference Document is a document whose url scheme is `sourcekit-lsp:` and whose content can only be retrieved /// using `GetReferenceDocumentRequest`. The enum represents a specific type of reference document and its /// associated value represents the data necessary to generate the document's contents and its url @@ -28,25 +34,33 @@ package enum ReferenceDocumentURL { package static let scheme = "sourcekit-lsp" case macroExpansion(MacroExpansionReferenceDocumentURLData) + case generatedInterface(GeneratedInterfaceDocumentURLData) var url: URL { get throws { - switch self { - case let .macroExpansion(data): - var components = URLComponents() - components.scheme = Self.scheme - components.host = MacroExpansionReferenceDocumentURLData.documentType - components.path = "/\(data.displayName)" - components.queryItems = data.queryItems - - guard let url = components.url else { - throw ReferenceDocumentURLError( - description: "Unable to create URL for macro expansion reference document" - ) + let data: ReferenceURLData = + switch self { + case .macroExpansion(let data): data + case .generatedInterface(let data): data } - return url + var components = URLComponents() + components.scheme = Self.scheme + components.host = type(of: data).documentType + components.path = "/\(data.displayName)" + components.queryItems = data.queryItems + + guard let url = components.url else { + throw ReferenceDocumentURLError(description: "Unable to create URL for reference document") } + + return url + } + } + + var uri: DocumentURI { + get throws { + DocumentURI(try url) } } @@ -74,6 +88,15 @@ package enum ReferenceDocumentURL { queryItems: queryItems ) self = .macroExpansion(macroExpansionURLData) + case GeneratedInterfaceDocumentURLData.documentType: + guard let queryItems = URLComponents(string: url.absoluteString)?.queryItems else { + throw ReferenceDocumentURLError( + description: "No queryItems passed for generated interface reference document: \(url)" + ) + } + + let macroExpansionURLData = try GeneratedInterfaceDocumentURLData(queryItems: queryItems) + self = .generatedInterface(macroExpansionURLData) case nil: throw ReferenceDocumentURLError( description: "Bad URL for reference document: \(url)" @@ -90,14 +113,23 @@ package enum ReferenceDocumentURL { /// For macro expansions, this is the buffer name that the URI references. var sourcekitdSourceFile: String { switch self { - case let .macroExpansion(data): data.bufferName + case .macroExpansion(let data): return data.bufferName + case .generatedInterface(let data): return data.sourcekitdDocumentName } } - var primaryFile: DocumentURI { + /// The file that should be used to retrieve build settings for this reference document. + var buildSettingsFile: DocumentURI { switch self { - case let .macroExpansion(data): - return data.primaryFile + case .macroExpansion(let data): return data.primaryFile + case .generatedInterface(let data): return data.buildSettingsFrom + } + } + + var primaryFile: DocumentURI? { + switch self { + case .macroExpansion(let data): return data.primaryFile + case .generatedInterface(let data): return data.buildSettingsFrom.primaryFile } } } @@ -126,6 +158,14 @@ extension DocumentURI { } return nil } + + /// The file that should be used to retrieve build settings for this reference document. + var buildSettingsFile: DocumentURI { + if let referenceDocument = try? ReferenceDocumentURL(from: self) { + return referenceDocument.buildSettingsFile + } + return self + } } package struct ReferenceDocumentURLError: Error, CustomStringConvertible { diff --git a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift index fa86f15c9..d6d972f3a 100644 --- a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift +++ b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift @@ -168,7 +168,22 @@ package actor SwiftLanguageService: LanguageService, Sendable { private let diagnosticReportManager: DiagnosticReportManager /// - Note: Implicitly unwrapped optional so we can pass a reference of `self` to `MacroExpansionManager`. - private(set) var macroExpansionManager: MacroExpansionManager! + private(set) var macroExpansionManager: MacroExpansionManager! { + willSet { + // Must only be set once. + precondition(macroExpansionManager == nil) + precondition(newValue != nil) + } + } + + /// - Note: Implicitly unwrapped optional so we can pass a reference of `self` to `MacroExpansionManager`. + private(set) var generatedInterfaceManager: GeneratedInterfaceManager! { + willSet { + // Must only be set once. + precondition(generatedInterfaceManager == nil) + precondition(newValue != nil) + } + } var documentManager: DocumentManager { get throws { @@ -235,6 +250,7 @@ package actor SwiftLanguageService: LanguageService, Sendable { ) self.macroExpansionManager = MacroExpansionManager(swiftLanguageService: self) + self.generatedInterfaceManager = GeneratedInterfaceManager(swiftLanguageService: self) // Create sub-directories for each type of generated file try FileManager.default.createDirectory(at: generatedInterfacesPath, withIntermediateDirectories: true) @@ -252,23 +268,25 @@ package actor SwiftLanguageService: LanguageService, Sendable { case .macroExpansion(let data): let content = try await self.macroExpansionManager.macroExpansion(for: data) return DocumentSnapshot(uri: uri, language: .swift, version: 0, lineTable: LineTable(content)) + case .generatedInterface(let data): + return try await self.generatedInterfaceManager.snapshot(of: data) case nil: return try documentManager.latestSnapshot(uri) } } func buildSettings(for document: DocumentURI, fallbackAfterTimeout: Bool) async -> SwiftCompileCommand? { - let primaryDocument = document.primaryFile ?? document + let buildSettingsFile = document.buildSettingsFile guard let sourceKitLSPServer else { logger.fault("Cannot retrieve build settings because SourceKitLSPServer is no longer alive") return nil } - guard let workspace = await sourceKitLSPServer.workspaceForDocument(uri: primaryDocument) else { + guard let workspace = await sourceKitLSPServer.workspaceForDocument(uri: buildSettingsFile) else { return nil } if let settings = await workspace.buildSystemManager.buildSettingsInferredFromMainFile( - for: primaryDocument, + for: buildSettingsFile, language: .swift, fallbackAfterTimeout: fallbackAfterTimeout ) { @@ -410,8 +428,10 @@ extension SwiftLanguageService { package func reopenDocument(_ notification: ReopenTextDocumentNotification) async { switch try? ReferenceDocumentURL(from: notification.textDocument.uri) { - case .macroExpansion: - break + case .macroExpansion, .generatedInterface: + // Macro expansions and generated interfaces don't have document dependencies or build settings associated with + // their URI. We should thus not not receive any `ReopenDocument` notifications for them. + logger.fault("Unexpectedly received reopen document notification for reference document") case nil: let snapshot = orLog("Getting snapshot to re-open document") { try documentManager.latestSnapshot(notification.textDocument.uri) @@ -511,6 +531,10 @@ extension SwiftLanguageService { switch try? ReferenceDocumentURL(from: notification.textDocument.uri) { case .macroExpansion: break + case .generatedInterface(let data): + await orLog("Opening generated interface") { + try await generatedInterfaceManager.open(document: data) + } case nil: cancelInFlightPublishDiagnosticsTask(for: notification.textDocument.uri) await diagnosticReportManager.removeItemsFromCache(with: notification.textDocument.uri) @@ -525,15 +549,16 @@ extension SwiftLanguageService { } package func closeDocument(_ notification: DidCloseTextDocumentNotification) async { + cancelInFlightPublishDiagnosticsTask(for: notification.textDocument.uri) + inFlightPublishDiagnosticsTasks[notification.textDocument.uri] = nil + await diagnosticReportManager.removeItemsFromCache(with: notification.textDocument.uri) + buildSettingsForOpenFiles[notification.textDocument.uri] = nil switch try? ReferenceDocumentURL(from: notification.textDocument.uri) { case .macroExpansion: break + case .generatedInterface(let data): + await generatedInterfaceManager.close(document: data) case nil: - cancelInFlightPublishDiagnosticsTask(for: notification.textDocument.uri) - inFlightPublishDiagnosticsTasks[notification.textDocument.uri] = nil - await diagnosticReportManager.removeItemsFromCache(with: notification.textDocument.uri) - buildSettingsForOpenFiles[notification.textDocument.uri] = nil - let req = closeDocumentSourcekitdRequest(uri: notification.textDocument.uri) _ = try? await self.sendSourcekitdRequest(req, fileContents: nil) } @@ -830,6 +855,10 @@ extension SwiftLanguageService { } package func codeAction(_ req: CodeActionRequest) async throws -> CodeActionRequestResponse? { + if (try? ReferenceDocumentURL(from: req.textDocument.uri)) != nil { + // Do not show code actions in reference documents + return nil + } let providersAndKinds: [(provider: CodeActionProvider, kind: CodeActionKind?)] = [ (retrieveSyntaxCodeActions, nil), (retrieveRefactorCodeActions, .refactor), @@ -1008,7 +1037,7 @@ extension SwiftLanguageService { package func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport { do { await semanticIndexManager?.prepareFileForEditorFunctionality( - req.textDocument.uri.primaryFile ?? req.textDocument.uri + req.textDocument.uri.buildSettingsFile ) let snapshot = try await self.latestSnapshot(for: req.textDocument.uri) let buildSettings = await self.buildSettings(for: req.textDocument.uri, fallbackAfterTimeout: false) @@ -1061,6 +1090,10 @@ extension SwiftLanguageService { return GetReferenceDocumentResponse( content: try await macroExpansionManager.macroExpansion(for: data) ) + case .generatedInterface(let data): + return GetReferenceDocumentResponse( + content: try await generatedInterfaceManager.snapshot(of: data).text + ) } } } diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index d31fe91c7..dba6ec9e5 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -383,7 +383,7 @@ package final class Workspace: Sendable, BuildSystemManagerDelegate { } func documentService(for uri: DocumentURI) -> LanguageService? { - return documentService.value[uri.primaryFile ?? uri] + return documentService.value[uri.buildSettingsFile] } /// Set a language service for a document uri and returns if none exists already. diff --git a/Tests/SourceKitLSPTests/SwiftInterfaceTests.swift b/Tests/SourceKitLSPTests/SwiftInterfaceTests.swift index b1422f059..ba20e32c3 100644 --- a/Tests/SourceKitLSPTests/SwiftInterfaceTests.swift +++ b/Tests/SourceKitLSPTests/SwiftInterfaceTests.swift @@ -22,14 +22,13 @@ import XCTest final class SwiftInterfaceTests: XCTestCase { func testSystemModuleInterface() async throws { let testClient = try await TestSourceKitLSPClient() - let url = URL(fileURLWithPath: "/\(UUID())/a.swift") - let uri = DocumentURI(url) + let uri = DocumentURI(for: .swift) testClient.openDocument("import Foundation", uri: uri) let resp = try await testClient.send( DefinitionRequest( - textDocument: TextDocumentIdentifier(url), + textDocument: TextDocumentIdentifier(uri), position: Position(line: 0, utf16index: 10) ) ) @@ -43,6 +42,30 @@ final class SwiftInterfaceTests: XCTestCase { ) } + func testSystemModuleInterfaceReferenceDocument() async throws { + let testClient = try await TestSourceKitLSPClient( + capabilities: ClientCapabilities(experimental: [ + "workspace/getReferenceDocument": .bool(true) + ]) + ) + let uri = DocumentURI(for: .swift) + + testClient.openDocument("import Foundation", uri: uri) + + let response = try await testClient.send( + DefinitionRequest( + textDocument: TextDocumentIdentifier(uri), + position: Position(line: 0, utf16index: 10) + ) + ) + let location = try XCTUnwrap(response?.locations?.only) + let referenceDocument = try await testClient.send(GetReferenceDocumentRequest(uri: location.uri)) + XCTAssert( + referenceDocument.content.hasPrefix("import "), + "Expected that the foundation swift interface starts with 'import ' but got '\(referenceDocument.content.prefix(100))'" + ) + } + func testDefinitionInSystemModuleInterface() async throws { let project = try await IndexedSingleSwiftFileTestProject( """ @@ -86,6 +109,37 @@ final class SwiftInterfaceTests: XCTestCase { ) } + func testDefinitionInSystemModuleInterfaceWithReferenceDocument() async throws { + let project = try await IndexedSingleSwiftFileTestProject( + """ + public func libFunc() async { + let a: 1️⃣String = "test" + } + """, + capabilities: ClientCapabilities(experimental: [ + "workspace/getReferenceDocument": .bool(true) + ]), + indexSystemModules: true + ) + + let definition = try await project.testClient.send( + DefinitionRequest( + textDocument: TextDocumentIdentifier(project.fileURI), + position: project.positions["1️⃣"] + ) + ) + let location = try XCTUnwrap(definition?.locations?.only) + let referenceDocument = try await project.testClient.send(GetReferenceDocumentRequest(uri: location.uri)) + let contents = referenceDocument.content + let lineTable = LineTable(contents) + let destinationLine = try XCTUnwrap(lineTable.line(at: location.range.lowerBound.line)) + .trimmingCharacters(in: .whitespaces) + XCTAssert( + destinationLine.hasPrefix("@frozen public struct String"), + "Full line was: '\(destinationLine)'" + ) + } + func testSwiftInterfaceAcrossModules() async throws { let project = try await SwiftPMTestProject( files: [ @@ -135,6 +189,65 @@ final class SwiftInterfaceTests: XCTestCase { ) } + func testSemanticFunctionalityInGeneratedInterface() async throws { + let project = try await SwiftPMTestProject( + files: [ + "MyLibrary/MyLibrary.swift": """ + public struct Lib { + public func foo() -> String {} + public init() {} + } + """, + "Exec/main.swift": "import 1️⃣MyLibrary", + ], + manifest: """ + let package = Package( + name: "MyLibrary", + targets: [ + .target(name: "MyLibrary"), + .executableTarget(name: "Exec", dependencies: ["MyLibrary"]) + ] + ) + """, + capabilities: ClientCapabilities(experimental: [ + "workspace/getReferenceDocument": .bool(true) + ]), + enableBackgroundIndexing: true + ) + + let (mainUri, mainPositions) = try project.openDocument("main.swift") + let response = + try await project.testClient.send( + DefinitionRequest( + textDocument: TextDocumentIdentifier(mainUri), + position: mainPositions["1️⃣"] + ) + ) + let referenceDocumentUri = try XCTUnwrap(response?.locations?.only).uri + let referenceDocument = try await project.testClient.send(GetReferenceDocumentRequest(uri: referenceDocumentUri)) + let stringIndex = try XCTUnwrap(referenceDocument.content.firstRange(of: "-> String")) + let (stringLine, stringColumn) = LineTable(referenceDocument.content) + .lineAndUTF16ColumnOf(referenceDocument.content.index(stringIndex.lowerBound, offsetBy: 3)) + + project.testClient.send( + DidOpenTextDocumentNotification( + textDocument: TextDocumentItem( + uri: referenceDocumentUri, + language: .swift, + version: 0, + text: referenceDocument.content + ) + ) + ) + let hover = try await project.testClient.send( + HoverRequest( + textDocument: TextDocumentIdentifier(referenceDocumentUri), + position: Position(line: stringLine, utf16index: stringColumn) + ) + ) + XCTAssertNotNil(hover) + } + func testJumpToSynthesizedExtensionMethodInSystemModuleWithoutIndex() async throws { let testClient = try await TestSourceKitLSPClient() let uri = DocumentURI(for: .swift)