diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..2a9df2dac --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true +insert_final_newline = true +xcode_trim_whitespace_on_empty_lines = true diff --git a/.vscode/launch.json b/.vscode/launch.json index ae3b8840a..01a73b222 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,31 +1,28 @@ { "configurations": [ { - "type": "lldb", + "type": "swift", "request": "launch", "name": "Debug sourcekit-lsp", "program": "${workspaceFolder:sourcekit-lsp}/.build/debug/sourcekit-lsp", "args": [], "cwd": "${workspaceFolder:sourcekit-lsp}", "preLaunchTask": "swift: Build Debug sourcekit-lsp", - "sourceLanguages": ["swift"] }, { - "type": "lldb", + "type": "swift", "request": "launch", "name": "Release sourcekit-lsp", "program": "${workspaceFolder:sourcekit-lsp}/.build/release/sourcekit-lsp", "args": [], "cwd": "${workspaceFolder:sourcekit-lsp}", "preLaunchTask": "swift: Build Release sourcekit-lsp", - "sourceLanguages": ["swift"] }, { - "type": "lldb", + "type": "swift", "request": "attach", "name": "Attach sourcekit-lsp (debug)", "program": "${workspaceFolder:sourcekit-lsp}/.build/debug/sourcekit-lsp", - "sourceLanguages": ["swift"], "waitFor": true } ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b08a2da7..efa8144e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,7 +90,7 @@ To adjust the toolchain that should be used by SourceKit-LSP (eg. because you wa ## Logging -SourceKit-LSP has extensive logging to the system log on macOS and to `/var/logs/sourcekit-lsp` or stderr on other platforms. +SourceKit-LSP has extensive logging to the system log on macOS and to `~/.sourcekit-lsp/logs/` or stderr on other platforms. To show the logs on macOS, run ```sh @@ -100,6 +100,10 @@ Or to stream the logs as they are produced: ``` log stream --predicate 'subsystem CONTAINS "org.swift.sourcekit-lsp"' --level debug ``` +On non-Apple platforms, you can use common commands like `tail` to read the logs or stream them as they are produced: +``` +tail -F ~/.sourcekit-lsp/logs/* +``` SourceKit-LSP masks data that may contain private information such as source file names and contents by default. To enable logging of this information, follow the instructions in [Diagnose Bundle.md](Documentation/Diagnose%20Bundle.md). diff --git a/Contributor Documentation/BSP Extensions.md b/Contributor Documentation/BSP Extensions.md index 00f0e9da6..19e5371dd 100644 --- a/Contributor Documentation/BSP Extensions.md +++ b/Contributor Documentation/BSP Extensions.md @@ -76,18 +76,13 @@ export interface PrepareParams { ```ts export interface SourceKitSourceItemData { /** The language of the source file. If `nil`, the language is inferred from the file extension. */ - language? LanguageId; + language?: LanguageId; - /** Whether the file is a header file that is clearly associated with one target. - * - * For example header files in SwiftPM projects are always associated to one target and SwiftPM can provide build - * settings for that header file. - * - * In general, build systems don't need to list all header files in the `buildTarget/sources` request: Semantic - * functionality for header files is usually provided by finding a main file that includes the header file and - * inferring build settings from it. Listing header files in `buildTarget/sources` allows SourceKit-LSP to provide - * semantic functionality for header files if they haven't been included by any main file. **/ - isHeader?: bool; + /** + * The kind of source file that this source item represents. If omitted, the item is assumed to be a normal source + * file, ie. omitting this key is equivalent to specifying it as `source`. + */ + kind?: "source" | "header" | "doccCatalog"; /** * The output path that is during indexing for this file, ie. the `-index-unit-output-path`, if it is specified diff --git a/Contributor Documentation/Overview.md b/Contributor Documentation/Overview.md index 830e0ba14..801ab8f81 100644 --- a/Contributor Documentation/Overview.md +++ b/Contributor Documentation/Overview.md @@ -31,4 +31,4 @@ SourceKit-LSP has fairly extensive logging to help diagnose issues. The way logg On macOS, SourceKit-LSP logs to the system log. [CONTRIBUTING.md](../CONTRIBUTING.md#logging) contains some information about how to read the system logs. Since [OSLog](https://developer.apple.com/documentation/os/logging) cannot be wrapped, the decision to log to macOS’s system log is done at build time and cannot be modified at runtime. -On other platforms, the `NonDarwinLogger` types are used to log messages. These types are API-compatible with OSLog. Log messages are written to stderr by default. When possible, `SourceKitLSP.run` will redirect the log messages to log files in `/var/log/sourcekit-lsp` on launch. +On other platforms, the `NonDarwinLogger` types are used to log messages. These types are API-compatible with OSLog. Log messages are written to stderr by default. When possible, `SourceKitLSP.run` will redirect the log messages to log files in `~/.sourcekit-lsp` on launch. diff --git a/Package.swift b/Package.swift index ca00da0d9..24c433d6d 100644 --- a/Package.swift +++ b/Package.swift @@ -214,6 +214,25 @@ var targets: [Target] = [ swiftSettings: globalSwiftSettings ), + // MARK: DocCDocumentation + + .target( + name: "DocCDocumentation", + dependencies: [ + "BuildServerProtocol", + "BuildSystemIntegration", + "LanguageServerProtocol", + "SemanticIndex", + "SKLogging", + "SwiftExtensions", + .product(name: "IndexStoreDB", package: "indexstore-db"), + .product(name: "SwiftDocC", package: "swift-docc"), + .product(name: "SymbolKit", package: "swift-docc-symbolkit"), + ], + exclude: ["CMakeLists.txt"], + swiftSettings: globalSwiftSettings + ), + // MARK: InProcessClient .target( @@ -292,8 +311,10 @@ var targets: [Target] = [ .target( name: "SemanticIndex", dependencies: [ + "BuildServerProtocol", "BuildSystemIntegration", "LanguageServerProtocol", + "LanguageServerProtocolExtensions", "SKLogging", "SwiftExtensions", "ToolchainRegistry", @@ -474,6 +495,7 @@ var targets: [Target] = [ dependencies: [ "BuildServerProtocol", "BuildSystemIntegration", + "DocCDocumentation", "LanguageServerProtocol", "LanguageServerProtocolExtensions", "LanguageServerProtocolJSONRPC", @@ -485,9 +507,9 @@ var targets: [Target] = [ "SwiftExtensions", "ToolchainRegistry", "TSCExtensions", - .product(name: "SwiftDocC", package: "swift-docc"), .product(name: "IndexStoreDB", package: "indexstore-db"), .product(name: "Crypto", package: "swift-crypto"), + .product(name: "Markdown", package: "swift-markdown"), .product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"), ] + swiftPMDependency([ @@ -770,6 +792,8 @@ var dependencies: [Package.Dependency] { return [ .package(path: "../indexstore-db"), .package(path: "../swift-docc"), + .package(path: "../swift-docc-symbolkit"), + .package(path: "../swift-markdown"), .package(path: "../swift-tools-support-core"), .package(path: "../swift-argument-parser"), .package(path: "../swift-syntax"), @@ -781,6 +805,8 @@ var dependencies: [Package.Dependency] { return [ .package(url: "https://github.com/swiftlang/indexstore-db.git", branch: relatedDependenciesBranch), .package(url: "https://github.com/swiftlang/swift-docc.git", branch: relatedDependenciesBranch), + .package(url: "https://github.com/swiftlang/swift-docc-symbolkit.git", branch: relatedDependenciesBranch), + .package(url: "https://github.com/swiftlang/swift-markdown.git", branch: relatedDependenciesBranch), .package(url: "https://github.com/apple/swift-tools-support-core.git", branch: relatedDependenciesBranch), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.4.0"), .package(url: "https://github.com/swiftlang/swift-syntax.git", branch: relatedDependenciesBranch), diff --git a/Sources/BuildServerProtocol/Messages/BuildTargetSourcesRequest.swift b/Sources/BuildServerProtocol/Messages/BuildTargetSourcesRequest.swift index e3784346d..e18a3d3d7 100644 --- a/Sources/BuildServerProtocol/Messages/BuildTargetSourcesRequest.swift +++ b/Sources/BuildServerProtocol/Messages/BuildTargetSourcesRequest.swift @@ -119,11 +119,12 @@ public struct SourceItemDataKind: RawRepresentable, Codable, Hashable, Sendable } /// **(BSP Extension)** -public struct SourceKitSourceItemData: LSPAnyCodable, Codable { - /// The language of the source file. If `nil`, the language is inferred from the file extension. - public var language: Language? - /// Whether the file is a header file that is clearly associated with one target. +public enum SourceKitSourceItemKind: String, Codable { + /// A source file that belongs to the target + case source = "source" + + /// A header file that is clearly associated with one target. /// /// For example header files in SwiftPM projects are always associated to one target and SwiftPM can provide build /// settings for that header file. @@ -132,7 +133,19 @@ public struct SourceKitSourceItemData: LSPAnyCodable, Codable { /// functionality for header files is usually provided by finding a main file that includes the header file and /// inferring build settings from it. Listing header files in `buildTarget/sources` allows SourceKit-LSP to provide /// semantic functionality for header files if they haven't been included by any main file. - public var isHeader: Bool? + case header = "header" + + /// A SwiftDocC documentation catalog usually ending in the ".docc" extension. + case doccCatalog = "doccCatalog" +} + +public struct SourceKitSourceItemData: LSPAnyCodable, Codable { + /// The language of the source file. If `nil`, the language is inferred from the file extension. + public var language: Language? + + /// The kind of source file that this source item represents. If omitted, the item is assumed to be a normal source file, + /// ie. omitting this key is equivalent to specifying it as `source`. + public var kind: SourceKitSourceItemKind? /// The output path that is used during indexing for this file, ie. the `-index-unit-output-path`, if it is specified /// in the compiler arguments or the file that is passed as `-o`, if `-index-unit-output-path` is not specified. @@ -144,9 +157,9 @@ public struct SourceKitSourceItemData: LSPAnyCodable, Codable { /// `outputPathsProvider: true` in `SourceKitInitializeBuildResponseData`. public var outputPath: String? - public init(language: Language? = nil, isHeader: Bool? = nil, outputPath: String? = nil) { + public init(language: Language? = nil, kind: SourceKitSourceItemKind? = nil, outputPath: String? = nil) { self.language = language - self.isHeader = isHeader + self.kind = kind self.outputPath = outputPath } @@ -154,8 +167,12 @@ public struct SourceKitSourceItemData: LSPAnyCodable, Codable { if case .string(let language) = dictionary[CodingKeys.language.stringValue] { self.language = Language(rawValue: language) } - if case .bool(let isHeader) = dictionary[CodingKeys.isHeader.stringValue] { - self.isHeader = isHeader + if case .string(let rawKind) = dictionary[CodingKeys.kind.stringValue] { + self.kind = SourceKitSourceItemKind(rawValue: rawKind) + } + // Backwards compatibility for isHeader + if case .bool(let isHeader) = dictionary["isHeader"], isHeader { + self.kind = .header } if case .string(let outputFilePath) = dictionary[CodingKeys.outputPath.stringValue] { self.outputPath = outputFilePath @@ -167,8 +184,8 @@ public struct SourceKitSourceItemData: LSPAnyCodable, Codable { if let language { result[CodingKeys.language.stringValue] = .string(language.rawValue) } - if let isHeader { - result[CodingKeys.isHeader.stringValue] = .bool(isHeader) + if let kind { + result[CodingKeys.kind.stringValue] = .string(kind.rawValue) } if let outputPath { result[CodingKeys.outputPath.stringValue] = .string(outputPath) diff --git a/Sources/BuildSystemIntegration/BuildSystemManager.swift b/Sources/BuildSystemIntegration/BuildSystemManager.swift index 8fbe531e5..ca127874a 100644 --- a/Sources/BuildSystemIntegration/BuildSystemManager.swift +++ b/Sources/BuildSystemIntegration/BuildSystemManager.swift @@ -1266,7 +1266,7 @@ package actor BuildSystemManager: QueueBasedMessageHandler { isPartOfRootProject: isPartOfRootProject, mayContainTests: mayContainTests, isBuildable: !(target?.tags.contains(.notBuildable) ?? false) - && !(sourceKitData?.isHeader ?? false) + && (sourceKitData?.kind ?? .source) == .source ) switch sourceItem.kind { case .file: diff --git a/Sources/BuildSystemIntegration/FileBuildSettings.swift b/Sources/BuildSystemIntegration/FileBuildSettings.swift index d7f1edcfe..8e147f6e7 100644 --- a/Sources/BuildSystemIntegration/FileBuildSettings.swift +++ b/Sources/BuildSystemIntegration/FileBuildSettings.swift @@ -55,7 +55,7 @@ package struct FileBuildSettings: Equatable, Sendable { /// /// This patches the arguments by searching for the argument corresponding to /// `originalFile` and replacing it. - func patching(newFile: DocumentURI, originalFile: DocumentURI) -> FileBuildSettings { + package func patching(newFile: DocumentURI, originalFile: DocumentURI) -> FileBuildSettings { var arguments = self.compilerArguments // URL.lastPathComponent is only set for file URLs but we want to also infer a file extension for non-file URLs like // untitled:file.cpp diff --git a/Sources/BuildSystemIntegration/SwiftPMBuildSystem.swift b/Sources/BuildSystemIntegration/SwiftPMBuildSystem.swift index b879b7b76..7273707a2 100644 --- a/Sources/BuildSystemIntegration/SwiftPMBuildSystem.swift +++ b/Sources/BuildSystemIntegration/SwiftPMBuildSystem.swift @@ -593,16 +593,23 @@ package actor SwiftPMBuildSystem: BuiltInBuildSystem { kind: .file, generated: false, dataKind: .sourceKit, - data: SourceKitSourceItemData(isHeader: true).encodeToLSPAny() - ) - } - sources += (swiftPMTarget.resources + swiftPMTarget.ignored + swiftPMTarget.others).map { - SourceItem( - uri: DocumentURI($0), - kind: $0.isDirectory ? .directory : .file, - generated: false + data: SourceKitSourceItemData(kind: .header).encodeToLSPAny() ) } + sources += (swiftPMTarget.resources + swiftPMTarget.ignored + swiftPMTarget.others) + .map { (url: URL) -> SourceItem in + var data: SourceKitSourceItemData? = nil + if url.isDirectory, url.pathExtension == "docc" { + data = SourceKitSourceItemData(kind: .doccCatalog) + } + return SourceItem( + uri: DocumentURI(url), + kind: url.isDirectory ? .directory : .file, + generated: false, + dataKind: data != nil ? .sourceKit : nil, + data: data?.encodeToLSPAny() + ) + } result.append(SourcesItem(target: target, sources: sources)) } return BuildTargetSourcesResponse(items: result) diff --git a/Sources/Diagnose/IndexCommand.swift b/Sources/Diagnose/IndexCommand.swift index 20edf8b69..5b782014d 100644 --- a/Sources/Diagnose/IndexCommand.swift +++ b/Sources/Diagnose/IndexCommand.swift @@ -69,10 +69,10 @@ package struct IndexCommand: AsyncParsableCommand { var toolchainOverride: String? @Option( - name: .customLong("experimental-index-feature"), + name: .customLong("experimental-feature"), help: """ Enable an experimental sourcekit-lsp feature. - Available features are: \(ExperimentalFeature.allCases.map(\.rawValue).joined(separator: ", ")) + Available features are: \(ExperimentalFeature.allNonInternalCases.map(\.rawValue).joined(separator: ", ")) """ ) var experimentalFeatures: [ExperimentalFeature] = [] diff --git a/Sources/DocCDocumentation/BuildSystemIntegrationExtensions.swift b/Sources/DocCDocumentation/BuildSystemIntegrationExtensions.swift new file mode 100644 index 000000000..5052267e8 --- /dev/null +++ b/Sources/DocCDocumentation/BuildSystemIntegrationExtensions.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 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 +// +//===----------------------------------------------------------------------===// + +package import BuildServerProtocol +package import BuildSystemIntegration +package import Foundation +import LanguageServerProtocol +import SKLogging + +package extension BuildSystemManager { + /// Retrieves the name of the Swift module for a given target. + /// + /// **Note:** prefer using ``module(for:in:)`` over ths function. This function + /// only exists for cases where you want the Swift module name of a target where + /// you don't know one of its Swift document URIs in advance. E.g. when handling + /// requests for Markdown/Tutorial files in DocC since they don't have compile + /// commands that could be used to find the module name. + /// + /// - Parameter target: The build target identifier + /// - Returns: The name of the Swift module or nil if it could not be determined + func moduleName(for target: BuildTargetIdentifier) async -> String? { + let sourceFiles = + await orLog( + "Failed to retreive source files from target \(target.uri)", + { try await self.sourceFiles(in: [target]).flatMap(\.sources) } + ) ?? [] + for sourceFile in sourceFiles { + let language = await defaultLanguage(for: sourceFile.uri, in: target) + guard language == .swift else { + continue + } + if let moduleName = await moduleName(for: sourceFile.uri, in: target) { + return moduleName + } + } + return nil + } + + /// Finds the SwiftDocC documentation catalog associated with a target, if any. + /// + /// - Parameter target: The build target identifier + /// - Returns: The URL of the documentation catalog or nil if one could not be found + func doccCatalog(for target: BuildTargetIdentifier) async -> URL? { + let sourceFiles = + await orLog( + "Failed to retrieve source files from target \(target.uri)", + { try await self.sourceFiles(in: [target]).flatMap(\.sources) } + ) ?? [] + let catalogURLs = sourceFiles.compactMap { sourceItem -> URL? in + guard sourceItem.dataKind == .sourceKit, + let data = SourceKitSourceItemData(fromLSPAny: sourceItem.data), + data.kind == .doccCatalog + else { + return nil + } + return sourceItem.uri.fileURL + }.sorted(by: { $0.absoluteString < $1.absoluteString }) + if catalogURLs.count > 1 { + logger.error("Multiple SwiftDocC catalogs found in build target \(target.uri)") + } + return catalogURLs.first + } +} diff --git a/Sources/DocCDocumentation/DocCCatalogIndexManager.swift b/Sources/DocCDocumentation/DocCCatalogIndexManager.swift new file mode 100644 index 000000000..174d5a96c --- /dev/null +++ b/Sources/DocCDocumentation/DocCCatalogIndexManager.swift @@ -0,0 +1,190 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 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 +// +//===----------------------------------------------------------------------===// + +package import Foundation +import SKLogging +import SKUtilities +@_spi(LinkCompletion) @preconcurrency import SwiftDocC + +final actor DocCCatalogIndexManager { + private let server: DocCServer + + /// The cache of DocCCatalogIndex for a given SwiftDocC catalog URL + /// + /// - Note: The capacity has been chosen without scientific measurements. The + /// feeling is that switching between SwiftDocC catalogs is rare and 5 catalog + /// indexes won't take up much memory. + private var indexCache = LRUCache>(capacity: 5) + + init(server: DocCServer) { + self.server = server + } + + func invalidate(_ url: URL) { + indexCache.removeValue(forKey: url) + } + + func index(for catalogURL: URL) async throws(DocCIndexError) -> DocCCatalogIndex { + if let existingCatalog = indexCache[catalogURL] { + return try existingCatalog.get() + } + do { + let convertResponse = try await server.convert( + externalIDsToConvert: [], + documentPathsToConvert: [], + includeRenderReferenceStore: true, + documentationBundleLocation: catalogURL, + documentationBundleDisplayName: "unknown", + documentationBundleIdentifier: "unknown", + symbolGraphs: [], + emitSymbolSourceFileURIs: true, + markupFiles: [], + tutorialFiles: [], + convertRequestIdentifier: UUID().uuidString + ) + guard let renderReferenceStoreData = convertResponse.renderReferenceStore else { + throw DocCIndexError.unexpectedlyNilRenderReferenceStore + } + let renderReferenceStore = try JSONDecoder().decode(RenderReferenceStore.self, from: renderReferenceStoreData) + let catalogIndex = DocCCatalogIndex(from: renderReferenceStore) + indexCache[catalogURL] = .success(catalogIndex) + return catalogIndex + } catch { + if error is CancellationError { + // Don't cache cancellation errors + throw .cancelled + } + let internalError = error as? DocCIndexError ?? DocCIndexError.internalError(error) + indexCache[catalogURL] = .failure(internalError) + throw internalError + } + } +} + +/// Represents a potential error that the ``DocCCatalogIndexManager`` could encounter while indexing +package enum DocCIndexError: LocalizedError { + case internalError(any Error) + case unexpectedlyNilRenderReferenceStore + case cancelled + + package var errorDescription: String? { + switch self { + case .internalError(let internalError): + return "An internal error occurred: \(internalError.localizedDescription)" + case .unexpectedlyNilRenderReferenceStore: + return "Did not receive a RenderReferenceStore from the DocC server" + case .cancelled: + return "The request was cancelled" + } + } +} + +package struct DocCCatalogIndex: Sendable { + /// A map from an asset name to its DataAsset contents. + let assets: [String: DataAsset] + + /// An array of DocCSymbolLink and their associated document URLs. + let documentationExtensions: [(link: DocCSymbolLink, documentURL: URL?)] + + /// A map from article name to its TopicRenderReference. + let articles: [String: TopicRenderReference] + + /// A map from tutorial name to its TopicRenderReference. + let tutorials: [String: TopicRenderReference] + + // A map from tutorial overview name to its TopicRenderReference. + let tutorialOverviews: [String: TopicRenderReference] + + /// Retrieves the documentation extension URL for the given symbol if one exists. + /// + /// - Parameter symbolInformation: The `DocCSymbolInformation` representing the symbol to search for. + package func documentationExtension(for symbolInformation: DocCSymbolInformation) -> URL? { + documentationExtensions.filter { symbolInformation.matches($0.link) }.first?.documentURL + } + + init(from renderReferenceStore: RenderReferenceStore) { + // Assets + var assets: [String: DataAsset] = [:] + for (reference, asset) in renderReferenceStore.assets { + var asset = asset + asset.variants = asset.variants.compactMapValues { url in + orLog("Failed to convert asset from RenderReferenceStore") { try url.withScheme("doc-asset") } + } + assets[reference.assetName] = asset + } + self.assets = assets + // Markdown and Tutorial content + var documentationExtensionToSourceURL: [(link: DocCSymbolLink, documentURL: URL?)] = [] + var articles: [String: TopicRenderReference] = [:] + var tutorials: [String: TopicRenderReference] = [:] + var tutorialOverviews: [String: TopicRenderReference] = [:] + for (renderReferenceKey, topicContentValue) in renderReferenceStore.topics { + guard let topicRenderReference = topicContentValue.renderReference as? TopicRenderReference else { + continue + } + // Article and Tutorial URLs in SwiftDocC are always of the form `doc://///`. + // Therefore, we only really need to store the filename in these cases which will always be the last path component. + let lastPathComponent = renderReferenceKey.url.lastPathComponent + + switch topicRenderReference.kind { + case .article: + articles[lastPathComponent] = topicRenderReference + case .tutorial: + tutorials[lastPathComponent] = topicRenderReference + case .overview: + tutorialOverviews[lastPathComponent] = topicRenderReference + default: + guard topicContentValue.isDocumentationExtensionContent, renderReferenceKey.url.pathComponents.count > 2 else { + continue + } + // Documentation extensions are always of the form `doc:///documentation/`. + // We want to parse the `SymbolPath` in this case and store it in the index for lookups later. + let linkString = renderReferenceKey.url.pathComponents[2...].joined(separator: "/") + guard let doccSymbolLink = DocCSymbolLink(linkString: linkString) else { + continue + } + documentationExtensionToSourceURL.append((link: doccSymbolLink, documentURL: topicContentValue.source)) + } + } + self.documentationExtensions = documentationExtensionToSourceURL + self.articles = articles + self.tutorials = tutorials + self.tutorialOverviews = tutorialOverviews + } +} + +fileprivate enum WithSchemeError: LocalizedError { + case failedToRetrieveComponents(URL) + case failedToEncode(URLComponents) + + var errorDescription: String? { + switch self { + case .failedToRetrieveComponents(let url): + "Failed to retrieve components for URL \(url.absoluteString)" + case .failedToEncode(let components): + "Failed to encode URL components \(String(reflecting: components))" + } + } +} + +fileprivate extension URL { + func withScheme(_ scheme: String) throws(WithSchemeError) -> URL { + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: true) else { + throw WithSchemeError.failedToRetrieveComponents(self) + } + components.scheme = scheme + guard let result = components.url else { + throw WithSchemeError.failedToEncode(components) + } + return result + } +} diff --git a/Sources/DocCDocumentation/DocCDocumentationManager.swift b/Sources/DocCDocumentation/DocCDocumentationManager.swift new file mode 100644 index 000000000..f41be2fe9 --- /dev/null +++ b/Sources/DocCDocumentation/DocCDocumentationManager.swift @@ -0,0 +1,132 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 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 BuildServerProtocol +package import BuildSystemIntegration +package import Foundation +package import LanguageServerProtocol +import SKLogging +import SwiftDocC + +package struct DocCDocumentationManager: Sendable { + private let doccServer: DocCServer + private let referenceResolutionService: DocCReferenceResolutionService + private let catalogIndexManager: DocCCatalogIndexManager + + private let buildSystemManager: BuildSystemManager + + package init(buildSystemManager: BuildSystemManager) { + let symbolResolutionServer = DocumentationServer(qualityOfService: .unspecified) + doccServer = DocCServer( + peer: symbolResolutionServer, + qualityOfService: .default + ) + catalogIndexManager = DocCCatalogIndexManager(server: doccServer) + referenceResolutionService = DocCReferenceResolutionService() + symbolResolutionServer.register(service: referenceResolutionService) + self.buildSystemManager = buildSystemManager + } + + package func filesDidChange(_ events: [FileEvent]) async { + for event in events { + for target in await buildSystemManager.targets(for: event.uri) { + guard let catalogURL = await buildSystemManager.doccCatalog(for: target) else { + continue + } + await catalogIndexManager.invalidate(catalogURL) + } + } + } + + package func catalogIndex(for catalogURL: URL) async throws(DocCIndexError) -> DocCCatalogIndex { + try await catalogIndexManager.index(for: catalogURL) + } + + /// Generates the SwiftDocC RenderNode for a given symbol, tutorial, or markdown file. + /// + /// - Parameters: + /// - symbolUSR: The USR of the symbol to render + /// - symbolGraph: The symbol graph that includes the given symbol USR + /// - overrideDocComments: An array of documentation comment lines that will override the comments in the symbol graph + /// - markupFile: The markdown article or symbol extension to render + /// - tutorialFile: The tutorial file to render + /// - moduleName: The name of the Swift module that will be rendered + /// - catalogURL: The URL pointing to the docc catalog that this symbol, tutorial, or markdown file is a part of + /// - Throws: A ResponseError if something went wrong + /// - Returns: The DoccDocumentationResponse containing the RenderNode if successful + package func renderDocCDocumentation( + symbolUSR: String? = nil, + symbolGraph: String? = nil, + overrideDocComments: [String]? = nil, + markupFile: String? = nil, + tutorialFile: String? = nil, + moduleName: String?, + catalogURL: URL? + ) async throws -> DoccDocumentationResponse { + // Make inputs consumable by DocC + var externalIDsToConvert: [String]? = nil + var overridingDocumentationComments: [String: [String]] = [:] + if let symbolUSR { + externalIDsToConvert = [symbolUSR] + if let overrideDocComments { + overridingDocumentationComments[symbolUSR] = overrideDocComments + } + } + var symbolGraphs: [Data] = [] + if let symbolGraphData = symbolGraph?.data(using: .utf8) { + symbolGraphs.append(symbolGraphData) + } + var markupFiles: [Data] = [] + if let markupFile = markupFile?.data(using: .utf8) { + markupFiles.append(markupFile) + } + var tutorialFiles: [Data] = [] + if let tutorialFile = tutorialFile?.data(using: .utf8) { + tutorialFiles.append(tutorialFile) + } + // Store the convert request identifier in order to fulfill index requests from SwiftDocC + let convertRequestIdentifier = UUID().uuidString + var catalogIndex: DocCCatalogIndex? = nil + if let catalogURL { + catalogIndex = try await catalogIndexManager.index(for: catalogURL) + } + referenceResolutionService.addContext( + DocCReferenceResolutionContext( + catalogURL: catalogURL, + catalogIndex: catalogIndex + ), + withKey: convertRequestIdentifier + ) + // Send the convert request to SwiftDocC and wait for the response + let convertResponse = try await doccServer.convert( + externalIDsToConvert: externalIDsToConvert, + documentPathsToConvert: nil, + includeRenderReferenceStore: false, + documentationBundleLocation: nil, + documentationBundleDisplayName: moduleName ?? "Unknown", + documentationBundleIdentifier: "unknown", + symbolGraphs: symbolGraphs, + overridingDocumentationComments: overridingDocumentationComments, + emitSymbolSourceFileURIs: false, + markupFiles: markupFiles, + tutorialFiles: tutorialFiles, + convertRequestIdentifier: convertRequestIdentifier + ) + guard let renderNodeData = convertResponse.renderNodes.first else { + throw ResponseError.internalError("SwiftDocC did not return any render nodes") + } + guard let renderNode = String(data: renderNodeData, encoding: .utf8) else { + throw ResponseError.internalError("Failed to encode render node from SwiftDocC") + } + return DoccDocumentationResponse(renderNode: renderNode) + } +} diff --git a/Sources/DocCDocumentation/DocCReferenceResolutionService.swift b/Sources/DocCDocumentation/DocCReferenceResolutionService.swift new file mode 100644 index 000000000..dd1f1468e --- /dev/null +++ b/Sources/DocCDocumentation/DocCReferenceResolutionService.swift @@ -0,0 +1,233 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 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 IndexStoreDB +import LanguageServerProtocol +import SKLogging +import SemanticIndex +@_spi(Linkcompletion) @preconcurrency import SwiftDocC +import SwiftExtensions + +final class DocCReferenceResolutionService: DocumentationService, Sendable { + /// The message type that this service accepts. + static let symbolResolutionMessageType: DocumentationServer.MessageType = "resolve-reference" + + /// The message type that this service responds with when the requested symbol resolution was successful. + static let symbolResolutionResponseMessageType: DocumentationServer.MessageType = "resolve-reference-response" + + static let handlingTypes = [symbolResolutionMessageType] + + private let contextMap = ThreadSafeBox<[String: DocCReferenceResolutionContext]>(initialValue: [:]) + + init() {} + + func addContext(_ context: DocCReferenceResolutionContext, withKey key: String) { + contextMap.value[key] = context + } + + @discardableResult func removeContext(forKey key: String) -> DocCReferenceResolutionContext? { + contextMap.value.removeValue(forKey: key) + } + + func context(forKey key: String) -> DocCReferenceResolutionContext? { + contextMap.value[key] + } + + func process( + _ message: DocumentationServer.Message, + completion: @escaping (DocumentationServer.Message) -> () + ) { + do { + let response = try process(message) + completion(response) + } catch { + completion(createResponseWithErrorMessage(error.localizedDescription)) + } + } + + private func process( + _ message: DocumentationServer.Message + ) throws(ReferenceResolutionError) -> DocumentationServer.Message { + // Decode the message payload + guard let payload = message.payload else { + throw ReferenceResolutionError.nilMessagePayload + } + let request = try decode(ConvertRequestContextWrapper.self, from: payload) + // Attempt to resolve the reference in the request + let resolvedReference = try resolveReference(request: request); + // Encode the response payload + let encodedResolvedReference = try encode(resolvedReference) + return createResponse(payload: encodedResolvedReference) + } + + private func resolveReference( + request: ConvertRequestContextWrapper + ) throws(ReferenceResolutionError) -> OutOfProcessReferenceResolver.Response { + guard let convertRequestIdentifier = request.convertRequestIdentifier else { + throw .missingConvertRequestIdentifier + } + guard let context = context(forKey: convertRequestIdentifier) else { + throw .missingContext + } + switch request.payload { + case .symbol(let symbolUSR): + throw .symbolNotFound(symbolUSR) + case .asset(let assetReference): + guard let catalog = context.catalogIndex else { + throw .indexNotAvailable + } + guard let dataAsset = catalog.assets[assetReference.assetName] else { + throw .assetNotFound + } + return .asset(dataAsset) + case .topic(let topicURL): + // Check if this is a link to another documentation article + let relevantPathComponents = topicURL.pathComponents.filter { $0 != "/" } + let resolvedReference: TopicRenderReference? = + switch relevantPathComponents.first { + case NodeURLGenerator.Path.documentationFolderName: + context.catalogIndex?.articles[topicURL.lastPathComponent] + case NodeURLGenerator.Path.tutorialsFolderName: + context.catalogIndex?.tutorials[topicURL.lastPathComponent] + default: + nil + } + if let resolvedReference { + return .resolvedInformation(OutOfProcessReferenceResolver.ResolvedInformation(resolvedReference, url: topicURL)) + } + // Otherwise this must be a link to a symbol + let urlString = topicURL.absoluteString + guard let doccSymbolLink = DocCSymbolLink(linkString: urlString) else { + throw .invalidURLInRequest + } + // Don't bother checking to see if the symbol actually exists in the index. This can be time consuming and + // it would be better to report errors/warnings for unresolved symbols directly within the document, anyway. + return .resolvedInformation( + OutOfProcessReferenceResolver.ResolvedInformation( + symbolURL: topicURL, + symbolName: doccSymbolLink.symbolName + ) + ) + } + } + + private func decode(_ type: T.Type, from data: Data) throws(ReferenceResolutionError) -> T { + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw .decodingFailure(error.localizedDescription) + } + } + + private func encode(_ value: T) throws(ReferenceResolutionError) -> Data { + do { + return try JSONEncoder().encode(value) + } catch { + throw .decodingFailure(error.localizedDescription) + } + } + + private func createResponseWithErrorMessage(_ message: String) -> DocumentationServer.Message { + let errorMessage = OutOfProcessReferenceResolver.Response.errorMessage(message) + let encodedErrorMessage = orLog("Encoding error message for OutOfProcessReferenceResolver.Response") { + try JSONEncoder().encode(errorMessage) + } + return createResponse(payload: encodedErrorMessage) + } + + private func createResponse(payload: Data?) -> DocumentationServer.Message { + DocumentationServer.Message( + type: DocCReferenceResolutionService.symbolResolutionResponseMessageType, + payload: payload + ) + } +} + +struct DocCReferenceResolutionContext { + let catalogURL: URL? + let catalogIndex: DocCCatalogIndex? +} + +fileprivate extension OutOfProcessReferenceResolver.ResolvedInformation { + init(symbolURL: URL, symbolName: String) { + self = OutOfProcessReferenceResolver.ResolvedInformation( + kind: .unknownSymbol, + url: symbolURL, + title: symbolName, + abstract: "", + language: .swift, + availableLanguages: [.swift], + platforms: [], + declarationFragments: nil + ) + } + + init(_ renderReference: TopicRenderReference, url: URL) { + let kind: DocumentationNode.Kind + switch renderReference.kind { + case .article: + kind = .article + case .tutorial, .overview: + kind = .tutorial + case .symbol: + kind = .unknownSymbol + case .section: + kind = .unknown + } + + self.init( + kind: kind, + url: url, + title: renderReference.title, + abstract: renderReference.abstract.map(\.plainText).joined(), + language: .swift, + availableLanguages: [.swift, .objectiveC], + topicImages: renderReference.images + ) + } +} + +enum ReferenceResolutionError: LocalizedError { + case nilMessagePayload + case invalidURLInRequest + case decodingFailure(String) + case encodingFailure(String) + case missingConvertRequestIdentifier + case missingContext + case indexNotAvailable + case symbolNotFound(String) + case assetNotFound + + var errorDescription: String? { + switch self { + case .nilMessagePayload: + return "Nil message payload provided." + case .decodingFailure(let error): + return "The service was unable to decode the given symbol resolution request: '\(error)'." + case .encodingFailure(let error): + return "The service failed to encode the result after resolving the symbol: \(error)" + case .invalidURLInRequest: + return "Failed to initialize an 'AbsoluteSymbolLink' from the given URL." + case .missingConvertRequestIdentifier: + return "The given request was missing a convert request identifier." + case .missingContext: + return "The given convert request identifier is not associated with any symbol resolution context." + case .indexNotAvailable: + return "An index was not available to complete this request." + case .symbolNotFound(let symbol): + return "Unable to find symbol '\(symbol)' in the index." + case .assetNotFound: + return "The requested asset could not be found." + } + } +} diff --git a/Sources/SourceKitLSP/Documentation/DocCServer.swift b/Sources/DocCDocumentation/DocCServer.swift similarity index 99% rename from Sources/SourceKitLSP/Documentation/DocCServer.swift rename to Sources/DocCDocumentation/DocCServer.swift index d7da9e0b2..3e54ca7f3 100644 --- a/Sources/SourceKitLSP/Documentation/DocCServer.swift +++ b/Sources/DocCDocumentation/DocCServer.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -#if canImport(SwiftDocC) import Foundation @preconcurrency import SwiftDocC @@ -180,7 +179,7 @@ enum DocCServerError: LocalizedError { case messagePayloadDecodingFailure(messageType: String, decodingError: Error) case unknownMessageType(_ messageType: String) case unexpectedlyNilPayload(_ messageType: String) - case internalError(_ underlyingError: DescribedError) + case internalError(_ underlyingError: LocalizedError) var errorDescription: String? { switch self { @@ -200,4 +199,3 @@ enum DocCServerError: LocalizedError { } } } -#endif diff --git a/Sources/DocCDocumentation/DocCSymbolInformation.swift b/Sources/DocCDocumentation/DocCSymbolInformation.swift new file mode 100644 index 000000000..cf0e6ffd0 --- /dev/null +++ b/Sources/DocCDocumentation/DocCSymbolInformation.swift @@ -0,0 +1,127 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 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 IndexStoreDB +package import SemanticIndex +@_spi(LinkCompletion) @preconcurrency import SwiftDocC +import SymbolKit + +package struct DocCSymbolInformation { + let components: [(name: String, information: LinkCompletionTools.SymbolInformation)] + + /// Find the DocCSymbolLink for a given symbol USR. + /// + /// - Parameters: + /// - usr: The symbol USR to find in the index. + /// - index: The CheckedIndex to search within. + package init?(fromUSR usr: String, in index: CheckedIndex) { + guard let topLevelSymbolOccurrence = index.primaryDefinitionOrDeclarationOccurrence(ofUSR: usr) else { + return nil + } + let moduleName = topLevelSymbolOccurrence.location.moduleName + var components = [topLevelSymbolOccurrence] + // Find any parent symbols + var symbolOccurrence: SymbolOccurrence = topLevelSymbolOccurrence + while let parentSymbolOccurrence = symbolOccurrence.parent(index) { + components.insert(parentSymbolOccurrence, at: 0) + symbolOccurrence = parentSymbolOccurrence + } + self.components = + [(name: moduleName, LinkCompletionTools.SymbolInformation(fromModuleName: moduleName))] + + components.map { + (name: $0.symbol.name, information: LinkCompletionTools.SymbolInformation(fromSymbolOccurrence: $0)) + } + } + + package func matches(_ link: DocCSymbolLink) -> Bool { + guard link.components.count == components.count else { + return false + } + return zip(link.components, components).allSatisfy { linkComponent, symbolComponent in + linkComponent.name == symbolComponent.name && symbolComponent.information.matches(linkComponent.disambiguation) + } + } +} + +fileprivate typealias KindIdentifier = SymbolGraph.Symbol.KindIdentifier + +extension SymbolOccurrence { + var doccSymbolKind: String { + switch symbol.kind { + case .module: + KindIdentifier.module.identifier + case .namespace, .namespaceAlias: + KindIdentifier.namespace.identifier + case .macro: + KindIdentifier.macro.identifier + case .enum: + KindIdentifier.enum.identifier + case .struct: + KindIdentifier.struct.identifier + case .class: + KindIdentifier.class.identifier + case .protocol: + KindIdentifier.protocol.identifier + case .extension: + KindIdentifier.extension.identifier + case .union: + KindIdentifier.union.identifier + case .typealias: + KindIdentifier.typealias.identifier + case .function: + KindIdentifier.func.identifier + case .variable: + KindIdentifier.var.identifier + case .field: + KindIdentifier.property.identifier + case .enumConstant: + KindIdentifier.case.identifier + case .instanceMethod: + KindIdentifier.func.identifier + case .classMethod: + KindIdentifier.func.identifier + case .staticMethod: + KindIdentifier.func.identifier + case .instanceProperty: + KindIdentifier.property.identifier + case .classProperty, .staticProperty: + KindIdentifier.typeProperty.identifier + case .constructor: + KindIdentifier.`init`.identifier + case .destructor: + KindIdentifier.deinit.identifier + case .conversionFunction: + KindIdentifier.func.identifier + case .unknown, .using, .concept, .commentTag, .parameter: + "unknown" + } + } +} + +extension LinkCompletionTools.SymbolInformation { + init(fromModuleName moduleName: String) { + self.init( + kind: KindIdentifier.module.identifier, + symbolIDHash: Self.hash(uniqueSymbolID: moduleName) + ) + } + + init(fromSymbolOccurrence occurrence: SymbolOccurrence) { + self.init( + kind: occurrence.doccSymbolKind, + symbolIDHash: Self.hash(uniqueSymbolID: occurrence.symbol.usr), + parameterTypes: nil, + returnTypes: nil + ) + } +} diff --git a/Sources/DocCDocumentation/DocCSymbolLink.swift b/Sources/DocCDocumentation/DocCSymbolLink.swift new file mode 100644 index 000000000..6d8e37c1a --- /dev/null +++ b/Sources/DocCDocumentation/DocCSymbolLink.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 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 IndexStoreDB +import SemanticIndex +@_spi(LinkCompletion) @preconcurrency import SwiftDocC +import SymbolKit + +package struct DocCSymbolLink: Sendable { + let linkString: String + let components: [(name: String, disambiguation: LinkCompletionTools.ParsedDisambiguation)] + + var symbolName: String { + components.last!.name + } + + package init?(linkString: String) { + let components = LinkCompletionTools.parse(linkString: linkString) + guard !components.isEmpty else { + return nil + } + self.linkString = linkString + self.components = components + } +} diff --git a/Sources/DocCDocumentation/DoccDocumentationError.swift b/Sources/DocCDocumentation/DoccDocumentationError.swift new file mode 100644 index 000000000..35c00b9a4 --- /dev/null +++ b/Sources/DocCDocumentation/DoccDocumentationError.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 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 +package import LanguageServerProtocol + +package enum DocCDocumentationError: LocalizedError { + case noDocumentation + case indexNotAvailable + case symbolNotFound(String) + + var errorDescription: String? { + switch self { + case .noDocumentation: + return "No documentation could be rendered for the position in this document" + case .indexNotAvailable: + return "The index is not availble to complete the request" + case .symbolNotFound(let symbolName): + return "Could not find symbol \(symbolName) in the project" + } + } +} + +package extension ResponseError { + static func requestFailed(doccDocumentationError: DocCDocumentationError) -> ResponseError { + return ResponseError.requestFailed(doccDocumentationError.localizedDescription) + } +} diff --git a/Sources/DocCDocumentation/EmptySymbolGraph.swift b/Sources/DocCDocumentation/EmptySymbolGraph.swift new file mode 100644 index 000000000..e041ca995 --- /dev/null +++ b/Sources/DocCDocumentation/EmptySymbolGraph.swift @@ -0,0 +1,17 @@ +import Foundation +import SymbolKit + +/// Generates a JSON string that represents an empty symbol graph for the given module name. +package func emptySymbolGraph(forModule moduleName: String) throws -> String? { + let symbolGraph = SymbolGraph( + metadata: SymbolGraph.Metadata( + formatVersion: SymbolGraph.SemanticVersion(major: 0, minor: 0, patch: 0), + generator: "SourceKit-LSP" + ), + module: SymbolGraph.Module(name: moduleName, platform: SymbolGraph.Platform()), + symbols: [], + relationships: [] + ) + let data = try JSONEncoder().encode(symbolGraph) + return String(data: data, encoding: .utf8) +} diff --git a/Sources/DocCDocumentation/IndexStoreDB+Extensions.swift b/Sources/DocCDocumentation/IndexStoreDB+Extensions.swift new file mode 100644 index 000000000..64042eb3b --- /dev/null +++ b/Sources/DocCDocumentation/IndexStoreDB+Extensions.swift @@ -0,0 +1,97 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 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 +// +//===----------------------------------------------------------------------===// + +package import IndexStoreDB +import SKLogging +import SemanticIndex +@_spi(LinkCompletion) import SwiftDocC + +extension CheckedIndex { + /// Find a `SymbolOccurrence` that is considered the primary definition of the symbol with the given `DocCSymbolLink`. + /// + /// If the `DocCSymbolLink` has an ambiguous definition, the most important role of this function is to deterministically return + /// the same result every time. + package func primaryDefinitionOrDeclarationOccurrence( + ofDocCSymbolLink symbolLink: DocCSymbolLink + ) -> SymbolOccurrence? { + var components = symbolLink.components + guard components.count > 0 else { + return nil + } + // Do a lookup to find the top level symbol + let topLevelSymbol = components.removeLast() + var topLevelSymbolOccurrences: [SymbolOccurrence] = [] + forEachCanonicalSymbolOccurrence(byName: topLevelSymbol.name) { symbolOccurrence in + topLevelSymbolOccurrences.append(symbolOccurrence) + return true // continue + } + topLevelSymbolOccurrences = topLevelSymbolOccurrences.filter { + let symbolInformation = LinkCompletionTools.SymbolInformation(fromSymbolOccurrence: $0) + return symbolInformation.matches(topLevelSymbol.disambiguation) + } + // Search each potential symbol's parents to find an exact match + let symbolOccurences = topLevelSymbolOccurrences.filter { topLevelSymbolOccurrence in + var components = components + var symbolOccurrence = topLevelSymbolOccurrence + while let nextComponent = components.popLast(), let parentSymbolOccurrence = symbolOccurrence.parent(self) { + let parentSymbolInformation = LinkCompletionTools.SymbolInformation( + fromSymbolOccurrence: parentSymbolOccurrence + ) + guard parentSymbolOccurrence.symbol.name == nextComponent.name, + parentSymbolInformation.matches(nextComponent.disambiguation) + else { + return false + } + symbolOccurrence = parentSymbolOccurrence + } + // If we have exactly one component left, check to see if it's the module name + if components.count == 1 { + let lastComponent = components.removeLast() + guard lastComponent.name == topLevelSymbolOccurrence.location.moduleName else { + return false + } + } + guard components.isEmpty else { + return false + } + return true + }.sorted() + if symbolOccurences.count > 1 { + logger.debug("Multiple symbols found for DocC symbol link '\(symbolLink.linkString)'") + } + return symbolOccurences.first + } +} + +extension SymbolOccurrence { + func parent(_ index: CheckedIndex) -> SymbolOccurrence? { + let allParentRelations = + relations + .filter { $0.roles.contains(.childOf) } + .sorted() + if allParentRelations.count > 1 { + logger.debug("Symbol \(symbol.usr) has multiple parent symbols") + } + guard let parentRelation = allParentRelations.first else { + return nil + } + if parentRelation.symbol.kind == .extension { + let allSymbolOccurrences = index.occurrences(relatedToUSR: parentRelation.symbol.usr, roles: .extendedBy) + .sorted() + if allSymbolOccurrences.count > 1 { + logger.debug("Extension \(parentRelation.symbol.usr) extends multiple symbols") + } + return allSymbolOccurrences.first + } + return index.primaryDefinitionOrDeclarationOccurrence(ofUSR: parentRelation.symbol.usr) + } +} diff --git a/Sources/SKOptions/ExperimentalFeatures.swift b/Sources/SKOptions/ExperimentalFeatures.swift index 41744f648..8eae864bc 100644 --- a/Sources/SKOptions/ExperimentalFeatures.swift +++ b/Sources/SKOptions/ExperimentalFeatures.swift @@ -44,4 +44,29 @@ public enum ExperimentalFeature: String, Codable, Sendable, CaseIterable { /// /// - Note: Internal option, for testing only case synchronizeForBuildSystemUpdates = "synchronize-for-build-system-updates" + + /// All non-internal experimental features. + public static var allNonInternalCases: [ExperimentalFeature] { + allCases.filter { !$0.isInternal } + } + + /// Whether the feature is internal. + var isInternal: Bool { + switch self { + case .onTypeFormatting: + return false + case .setOptionsRequest: + return true + case .sourceKitOptionsRequest: + return true + case .isIndexingRequest: + return true + case .structuredLogs: + return false + case .outputPathsRequest: + return true + case .synchronizeForBuildSystemUpdates: + return true + } + } } diff --git a/Sources/SKUtilities/CMakeLists.txt b/Sources/SKUtilities/CMakeLists.txt index 9d29ca202..70eec825b 100644 --- a/Sources/SKUtilities/CMakeLists.txt +++ b/Sources/SKUtilities/CMakeLists.txt @@ -2,6 +2,7 @@ set(sources Debouncer.swift Dictionary+InitWithElementsKeyedBy.swift LineTable.swift + LRUCache.swift ) add_library(SKUtilities STATIC ${sources}) diff --git a/Sources/SKUtilities/LRUCache.swift b/Sources/SKUtilities/LRUCache.swift new file mode 100644 index 000000000..f20e809ac --- /dev/null +++ b/Sources/SKUtilities/LRUCache.swift @@ -0,0 +1,140 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 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 +// +//===----------------------------------------------------------------------===// + +/// A cache that stores key-value pairs up to a given capacity. +/// +/// The least recently used key-value pair is removed when the cache exceeds its capacity. +package struct LRUCache { + private struct Priority { + var next: Key? + var previous: Key? + + init(next: Key? = nil, previous: Key? = nil) { + self.next = next + self.previous = previous + } + } + + // The hash map for accessing cached key-value pairs. + private var cache: [Key: Value] + + // Doubly linked list of priorities keeping track of the first and last entries. + private var priorities: [Key: Priority] + private var firstPriority: Key? = nil + private var lastPriority: Key? = nil + + /// The maximum number of key-value pairs that can be stored in the cache. + package let capacity: Int + + /// The number of key-value pairs within the cache. + package var count: Int { cache.count } + + /// A collection containing just the keys of the cache. + /// + /// - Note: Keys will **not** be in the same order that they were added to the cache. + package var keys: some Collection { cache.keys } + + /// A collection containing just the values of the cache. + /// + /// - Note: Values will **not** be in the same order that they were added to the cache. + package var values: some Collection { cache.values } + + package init(capacity: Int) { + precondition(capacity > 0, "LRUCache capacity must be greater than 0") + self.capacity = capacity + self.cache = Dictionary(minimumCapacity: capacity) + self.priorities = Dictionary(minimumCapacity: capacity) + } + + /// Adds the given key as the first priority in the doubly linked list of priorities. + private mutating func addPriority(forKey key: Key) { + // Make sure the key doesn't already exist in the list + removePriority(forKey: key) + + guard let currentFirstPriority = firstPriority else { + firstPriority = key + lastPriority = key + priorities[key] = Priority() + return + } + priorities[key] = Priority(next: currentFirstPriority) + priorities[currentFirstPriority]?.previous = key + firstPriority = key + } + + /// Removes the given key from the doubly linked list of priorities. + private mutating func removePriority(forKey key: Key) { + guard let priority = priorities.removeValue(forKey: key) else { + return + } + // Update the first and last priorities + if firstPriority == key { + firstPriority = priority.next + } + if lastPriority == key { + lastPriority = priority.previous + } + // Update the previous and next keys in the priority list + if let previousPriority = priority.previous { + priorities[previousPriority]?.next = priority.next + } + if let nextPriority = priority.next { + priorities[nextPriority]?.previous = priority.previous + } + } + + /// Removes all key-value pairs from the cache. + package mutating func removeAll() { + cache.removeAll() + priorities.removeAll() + firstPriority = nil + lastPriority = nil + } + + /// Removes all the elements that satisfy the given predicate. + package mutating func removeAll(where shouldBeRemoved: (_ key: Key) throws -> Bool) rethrows { + cache = try cache.filter { entry in + guard try shouldBeRemoved(entry.key) else { + return true + } + removePriority(forKey: entry.key) + return false + } + } + + /// Removes the given key and its associated value from the cache. + /// + /// Returns the value that was associated with the key. + @discardableResult + package mutating func removeValue(forKey key: Key) -> Value? { + removePriority(forKey: key) + return cache.removeValue(forKey: key) + } + + package subscript(key: Key) -> Value? { + mutating _read { + addPriority(forKey: key) + yield cache[key] + } + set { + guard let newValue else { + removeValue(forKey: key) + return + } + cache[key] = newValue + addPriority(forKey: key) + if cache.count > capacity, let lastPriority { + removeValue(forKey: lastPriority) + } + } + } +} diff --git a/Sources/SemanticIndex/CheckedIndex.swift b/Sources/SemanticIndex/CheckedIndex.swift index 2afee5837..846e54a47 100644 --- a/Sources/SemanticIndex/CheckedIndex.swift +++ b/Sources/SemanticIndex/CheckedIndex.swift @@ -129,6 +129,18 @@ package final class CheckedIndex { } } + @discardableResult package func forEachCanonicalSymbolOccurrence( + byName name: String, + body: (SymbolOccurrence) -> Bool + ) -> Bool { + index.forEachCanonicalSymbolOccurrence(byName: name) { occurrence in + guard self.checker.isUpToDate(occurrence.location) else { + return true // continue + } + return body(occurrence) + } + } + package func symbols(inFilePath path: String) -> [Symbol] { guard self.hasAnyUpToDateUnit(for: DocumentURI(filePath: path, isDirectory: false)) else { return [] diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index cbe4241dc..c19ae3313 100644 --- a/Sources/SourceKitLSP/CMakeLists.txt +++ b/Sources/SourceKitLSP/CMakeLists.txt @@ -26,6 +26,7 @@ target_sources(SourceKitLSP PRIVATE Clang/SemanticTokenTranslator.swift ) target_sources(SourceKitLSP PRIVATE + Documentation/DocCDocumentationHandler.swift Documentation/DocumentationLanguageService.swift ) target_sources(SourceKitLSP PRIVATE @@ -75,6 +76,7 @@ target_sources(SourceKitLSP PRIVATE Swift/SyntaxHighlightingTokens.swift Swift/SyntaxTreeManager.swift Swift/VariableTypeInfo.swift + Swift/WithSnapshotFromDiskOpenedInSourcekitd.swift ) set_target_properties(SourceKitLSP PROPERTIES INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY}) diff --git a/Sources/SourceKitLSP/Clang/ClangLanguageService.swift b/Sources/SourceKitLSP/Clang/ClangLanguageService.swift index 47ebb09fd..c4bd6c3b5 100644 --- a/Sources/SourceKitLSP/Clang/ClangLanguageService.swift +++ b/Sources/SourceKitLSP/Clang/ClangLanguageService.swift @@ -22,6 +22,10 @@ import SwiftSyntax import TSCExtensions import ToolchainRegistry +#if canImport(DocCDocumentation) +import DocCDocumentation +#endif + #if os(Windows) import WinSDK #endif @@ -482,6 +486,12 @@ extension ClangLanguageService { return try await forwardRequestToClangd(req) } + #if canImport(DocCDocumentation) + func doccDocumentation(_ req: DoccDocumentationRequest) async throws -> DoccDocumentationResponse { + throw ResponseError.requestFailed(doccDocumentationError: .noDocumentation) + } + #endif + func symbolInfo(_ req: SymbolInfoRequest) async throws -> [SymbolDetails] { return try await forwardRequestToClangd(req) } diff --git a/Sources/SourceKitLSP/Documentation/DoccDocumentationHandler.swift b/Sources/SourceKitLSP/Documentation/DoccDocumentationHandler.swift new file mode 100644 index 000000000..332a335fe --- /dev/null +++ b/Sources/SourceKitLSP/Documentation/DoccDocumentationHandler.swift @@ -0,0 +1,154 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 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 +// +//===----------------------------------------------------------------------===// + +#if canImport(DocCDocumentation) +import BuildSystemIntegration +import DocCDocumentation +import Foundation +@preconcurrency import IndexStoreDB +package import LanguageServerProtocol +import Markdown +import SKUtilities +import SemanticIndex + +extension DocumentationLanguageService { + package func doccDocumentation(_ req: DoccDocumentationRequest) async throws -> DoccDocumentationResponse { + guard let sourceKitLSPServer else { + throw ResponseError.internalError("SourceKit-LSP is shutting down") + } + guard let workspace = await sourceKitLSPServer.workspaceForDocument(uri: req.textDocument.uri) else { + throw ResponseError.workspaceNotOpen(req.textDocument.uri) + } + let documentationManager = workspace.doccDocumentationManager + let snapshot = try documentManager.latestSnapshot(req.textDocument.uri) + var moduleName: String? = nil + var catalogURL: URL? = nil + if let target = await workspace.buildSystemManager.canonicalTarget(for: req.textDocument.uri) { + moduleName = await workspace.buildSystemManager.moduleName(for: target) + catalogURL = await workspace.buildSystemManager.doccCatalog(for: target) + } + + switch snapshot.language { + case .tutorial: + return try await documentationManager.renderDocCDocumentation( + tutorialFile: snapshot.text, + moduleName: moduleName, + catalogURL: catalogURL + ) + case .markdown: + guard case .symbol(let symbolName) = MarkdownTitleFinder.find(parsing: snapshot.text) else { + // This is an article that can be rendered on its own + return try await documentationManager.renderDocCDocumentation( + markupFile: snapshot.text, + moduleName: moduleName, + catalogURL: catalogURL + ) + } + guard let moduleName, symbolName == moduleName else { + // This is a symbol extension page. Find the symbol so that we can include it in the request. + guard let index = workspace.index(checkedFor: .deletedFiles) else { + throw ResponseError.requestFailed(doccDocumentationError: .indexNotAvailable) + } + guard let symbolLink = DocCSymbolLink(linkString: symbolName), + let symbolOccurrence = index.primaryDefinitionOrDeclarationOccurrence(ofDocCSymbolLink: symbolLink) + else { + throw ResponseError.requestFailed(doccDocumentationError: .symbolNotFound(symbolName)) + } + let symbolDocumentUri = symbolOccurrence.location.documentUri + guard + let symbolWorkspace = try await workspaceForDocument(uri: symbolDocumentUri), + let languageService = try await languageService(for: symbolDocumentUri, .swift, in: symbolWorkspace) + as? SwiftLanguageService + else { + throw ResponseError.internalError("Unable to find Swift language service for \(symbolDocumentUri)") + } + let symbolGraph = try await languageService.withSnapshotFromDiskOpenedInSourcekitd( + uri: symbolDocumentUri, + fallbackSettingsAfterTimeout: false + ) { snapshot, compileCommand in + try await languageService.cursorInfo( + snapshot, + compileCommand: compileCommand, + Range(snapshot.position(of: symbolOccurrence.location)), + includeSymbolGraph: true + ).symbolGraph + } + guard let symbolGraph else { + throw ResponseError.internalError("Unable to retrieve symbol graph for \(symbolOccurrence.symbol.name)") + } + return try await documentationManager.renderDocCDocumentation( + symbolUSR: symbolOccurrence.symbol.usr, + symbolGraph: symbolGraph, + markupFile: snapshot.text, + moduleName: moduleName, + catalogURL: catalogURL + ) + } + // This is a page representing the module itself. + // Create a dummy symbol graph and tell SwiftDocC to convert the module name. + // The version information isn't really all that important since we're creating + // what is essentially an empty symbol graph. + return try await documentationManager.renderDocCDocumentation( + symbolUSR: moduleName, + symbolGraph: emptySymbolGraph(forModule: moduleName), + markupFile: snapshot.text, + moduleName: moduleName, + catalogURL: catalogURL + ) + default: + throw ResponseError.requestFailed(doccDocumentationError: .noDocumentation) + } + } +} + +struct MarkdownTitleFinder: MarkupVisitor { + enum Title { + case plainText(String) + case symbol(String) + } + + static func find(parsing text: String) -> Title? { + let document = Markdown.Document(parsing: text, options: [.parseSymbolLinks]) + var visitor = MarkdownTitleFinder() + return visitor.visit(document) + } + + mutating func defaultVisit(_ markup: any Markup) -> Title? { + for child in markup.children { + if let value = visit(child) { + return value + } + } + return nil + } + + mutating func visitHeading(_ heading: Heading) -> Title? { + guard heading.level == 1 else { + return nil + } + if let symbolLink = heading.child(at: 0) as? SymbolLink { + // Remove the surrounding backticks to find the symbol name + let plainText = symbolLink.plainText + var startIndex = plainText.startIndex + if plainText.hasPrefix("``") { + startIndex = plainText.index(plainText.startIndex, offsetBy: 2) + } + var endIndex = plainText.endIndex + if plainText.hasSuffix("``") { + endIndex = plainText.index(plainText.endIndex, offsetBy: -2) + } + return .symbol(String(plainText[startIndex.. Workspace? { + guard let sourceKitLSPServer else { + throw ResponseError.unknown("Connection to the editor closed") + } + return await sourceKitLSPServer.workspaceForDocument(uri: uri) + } + + func languageService( + for uri: DocumentURI, + _ language: Language, + in workspace: Workspace + ) async throws -> LanguageService? { + guard let sourceKitLSPServer else { + throw ResponseError.unknown("Connection to the editor closed") + } + return await sourceKitLSPServer.languageService(for: uri, language, in: workspace) + } package nonisolated func canHandle(workspace: Workspace, toolchain: Toolchain) -> Bool { return true diff --git a/Sources/SourceKitLSP/Documentation/DocumentationManager.swift b/Sources/SourceKitLSP/Documentation/DocumentationManager.swift deleted file mode 100644 index 9837b1532..000000000 --- a/Sources/SourceKitLSP/Documentation/DocumentationManager.swift +++ /dev/null @@ -1,192 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 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 -// -//===----------------------------------------------------------------------===// - -#if canImport(SwiftDocC) -import BuildSystemIntegration -import BuildServerProtocol -import Foundation -import IndexStoreDB -import LanguageServerProtocol -import SemanticIndex -import SwiftDocC -import SwiftExtensions -import SwiftSyntax - -package final actor DocumentationManager { - private weak var sourceKitLSPServer: SourceKitLSPServer? - - private let doccServer: DocCServer - - init(sourceKitLSPServer: SourceKitLSPServer) { - self.sourceKitLSPServer = sourceKitLSPServer - self.doccServer = DocCServer(peer: nil, qualityOfService: .default) - } - - func convertDocumentation( - _ documentURI: DocumentURI, - at position: Position? = nil - ) async throws -> DoccDocumentationResponse { - guard let sourceKitLSPServer = sourceKitLSPServer else { - throw ResponseError.internalError("SourceKit-LSP is shutting down") - } - guard let workspace = await sourceKitLSPServer.workspaceForDocument(uri: documentURI) else { - throw ResponseError.workspaceNotOpen(documentURI) - } - - let snapshot = try sourceKitLSPServer.documentManager.latestSnapshot(documentURI) - let targetId = await workspace.buildSystemManager.canonicalTarget(for: documentURI) - var moduleName: String? = nil - if let targetId { - moduleName = await workspace.buildSystemManager.moduleName(for: documentURI, in: targetId) - } - - var externalIDsToConvert: [String]? - var symbolGraphs = [Data]() - var overridingDocumentationComments = [String: [String]]() - switch snapshot.language { - case .swift: - guard let position else { - throw ResponseError.invalidParams("A position must be provided for Swift files") - } - guard let languageService = await sourceKitLSPServer.languageService(for: documentURI, .swift, in: workspace), - let swiftLanguageService = languageService as? SwiftLanguageService - else { - throw ResponseError.internalError("Unable to find Swift language service for \(documentURI)") - } - // Search for the nearest documentable symbol at this location - let syntaxTree = await swiftLanguageService.syntaxTreeManager.syntaxTree(for: snapshot) - guard - let nearestDocumentableSymbol = DocumentableSymbol.findNearestSymbol( - syntaxTree: syntaxTree, - position: snapshot.absolutePosition(of: position) - ) - else { - throw ResponseError.requestFailed(convertError: .noDocumentation) - } - // Retrieve the symbol graph as well as information about the symbol - let symbolPosition = await swiftLanguageService.adjustPositionToStartOfIdentifier( - snapshot.position(of: nearestDocumentableSymbol.position), - in: snapshot - ) - let (cursorInfo, _, symbolGraph) = try await swiftLanguageService.cursorInfo( - documentURI, - Range(symbolPosition), - includeSymbolGraph: true, - fallbackSettingsAfterTimeout: false - ) - guard let symbolGraph, - let cursorInfo = cursorInfo.first, - let symbolUSR = cursorInfo.symbolInfo.usr - else { - throw ResponseError.internalError("Unable to retrieve symbol graph for the document") - } - guard let rawSymbolGraph = symbolGraph.data(using: .utf8) else { - throw ResponseError.internalError("Unable to encode symbol graph") - } - externalIDsToConvert = [symbolUSR] - symbolGraphs.append(rawSymbolGraph) - overridingDocumentationComments[symbolUSR] = nearestDocumentableSymbol.documentationComments - default: - throw ResponseError.requestFailed(convertError: .noDocumentation) - } - // Send the convert request to SwiftDocC and wait for the response - let convertResponse = try await doccServer.convert( - externalIDsToConvert: externalIDsToConvert, - documentPathsToConvert: nil, - includeRenderReferenceStore: false, - documentationBundleLocation: nil, - documentationBundleDisplayName: moduleName ?? "Unknown", - documentationBundleIdentifier: "unknown", - symbolGraphs: symbolGraphs, - overridingDocumentationComments: overridingDocumentationComments, - emitSymbolSourceFileURIs: false, - markupFiles: [], - tutorialFiles: [], - convertRequestIdentifier: UUID().uuidString - ) - guard let renderNodeData = convertResponse.renderNodes.first else { - throw ResponseError.internalError("SwiftDocC did not return any render nodes") - } - guard let renderNode = String(data: renderNodeData, encoding: .utf8) else { - throw ResponseError.internalError("Failed to encode render node from SwiftDocC") - } - return DoccDocumentationResponse(renderNode: renderNode) - } -} - -package enum ConvertDocumentationError { - case noDocumentation - - public var message: String { - switch self { - case .noDocumentation: - return "No documentation could be rendered for the position in this document" - } - } -} - -fileprivate extension ResponseError { - static func requestFailed(convertError: ConvertDocumentationError) -> ResponseError { - return ResponseError.requestFailed(convertError.message) - } -} - -fileprivate struct DocumentableSymbol { - let position: AbsolutePosition - let documentationComments: [String] - - init(node: any SyntaxProtocol, position: AbsolutePosition) { - self.position = position - self.documentationComments = node.leadingTrivia.flatMap { trivia -> [String] in - switch trivia { - case .docLineComment(let comment): - return [String(comment.dropFirst(3).trimmingCharacters(in: .whitespaces))] - case .docBlockComment(let comment): - return comment.dropFirst(3) - .dropLast(2) - .split(separator: "\n") - .map { String($0).trimmingCharacters(in: .whitespaces) } - default: - return [] - } - } - } -} - -fileprivate extension DocumentableSymbol { - static func findNearestSymbol(syntaxTree: SourceFileSyntax, position: AbsolutePosition) -> DocumentableSymbol? { - guard let token = syntaxTree.token(at: position) else { - return nil - } - return token.ancestorOrSelf { node in - if let namedDecl = node.asProtocol(NamedDeclSyntax.self) { - return DocumentableSymbol(node: namedDecl, position: namedDecl.name.positionAfterSkippingLeadingTrivia) - } else if let initDecl = node.as(InitializerDeclSyntax.self) { - return DocumentableSymbol(node: initDecl, position: initDecl.initKeyword.positionAfterSkippingLeadingTrivia) - } else if let functionDecl = node.as(FunctionDeclSyntax.self) { - return DocumentableSymbol(node: functionDecl, position: functionDecl.name.positionAfterSkippingLeadingTrivia) - } else if let variableDecl = node.as(VariableDeclSyntax.self) { - guard let identifier = variableDecl.bindings.only?.pattern.as(IdentifierPatternSyntax.self) else { - return nil - } - return DocumentableSymbol(node: variableDecl, position: identifier.positionAfterSkippingLeadingTrivia) - } else if let enumCaseDecl = node.as(EnumCaseDeclSyntax.self) { - guard let name = enumCaseDecl.elements.only?.name else { - return nil - } - return DocumentableSymbol(node: enumCaseDecl, position: name.positionAfterSkippingLeadingTrivia) - } - return nil - } - } -} -#endif diff --git a/Sources/SourceKitLSP/LanguageService.swift b/Sources/SourceKitLSP/LanguageService.swift index 3f94982b4..c4dc56cf6 100644 --- a/Sources/SourceKitLSP/LanguageService.swift +++ b/Sources/SourceKitLSP/LanguageService.swift @@ -166,6 +166,9 @@ package protocol LanguageService: AnyObject, Sendable { func completion(_ req: CompletionRequest) async throws -> CompletionList func completionItemResolve(_ req: CompletionItemResolveRequest) async throws -> CompletionItem func hover(_ req: HoverRequest) async throws -> HoverResponse? + #if canImport(DocCDocumentation) + func doccDocumentation(_ req: DoccDocumentationRequest) async throws -> DoccDocumentationResponse + #endif func symbolInfo(_ request: SymbolInfoRequest) async throws -> [SymbolDetails] /// Request a generated interface of a module to display in the IDE. diff --git a/Sources/SourceKitLSP/Rename.swift b/Sources/SourceKitLSP/Rename.swift index b2384294a..b08fe91b6 100644 --- a/Sources/SourceKitLSP/Rename.swift +++ b/Sources/SourceKitLSP/Rename.swift @@ -355,7 +355,7 @@ extension SwiftLanguageService { let req = sourcekitd.dictionary([ keys.request: sourcekitd.requests.nameTranslation, keys.sourceFile: snapshot.uri.pseudoPath, - keys.compilerArgs: await self.buildSettings(for: snapshot.uri, fallbackAfterTimeout: false)?.compilerArgs + keys.compilerArgs: await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: false)?.compilerArgs as [SKDRequestValue]?, keys.offset: snapshot.utf8Offset(of: snapshot.position(of: symbolLocation)), keys.nameKind: sourcekitd.values.nameSwift, @@ -407,7 +407,7 @@ extension SwiftLanguageService { let req = sourcekitd.dictionary([ keys.request: sourcekitd.requests.nameTranslation, keys.sourceFile: snapshot.uri.pseudoPath, - keys.compilerArgs: await self.buildSettings(for: snapshot.uri, fallbackAfterTimeout: false)?.compilerArgs + keys.compilerArgs: await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: false)?.compilerArgs as [SKDRequestValue]?, keys.offset: snapshot.utf8Offset(of: snapshot.position(of: symbolLocation)), keys.nameKind: sourcekitd.values.nameObjc, diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index 0aa32d31f..e21bffc95 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -28,6 +28,10 @@ package import ToolchainRegistry import struct TSCBasic.AbsolutePath import protocol TSCBasic.FileSystem +#if canImport(DocCDocumentation) +import DocCDocumentation +#endif + /// Disambiguate LanguageServerProtocol.Language and IndexstoreDB.Language package typealias Language = LanguageServerProtocol.Language @@ -75,16 +79,6 @@ package actor SourceKitLSPServer { package let documentManager = DocumentManager() - #if canImport(SwiftDocC) - /// The `DocumentationManager` that handles all documentation related requests - /// - /// Implicitly unwrapped optional so we can create an `DocumentationManager` that has a weak reference to - /// `SourceKitLSPServer`. - /// `nonisolated(unsafe)` because `documentationManager` will not be modified after it is assigned from the - /// initializer. - private(set) nonisolated(unsafe) var documentationManager: DocumentationManager! - #endif - /// The `TaskScheduler` that schedules all background indexing tasks. /// /// Shared process-wide to ensure the scheduled index operations across multiple workspaces don't exceed the maximum @@ -180,10 +174,6 @@ package actor SourceKitLSPServer { maxConcurrentTasksByPriority: Self.maxConcurrentIndexingTasksByPriority(isIndexingPaused: false, options: options) ) self.indexProgressManager = nil - #if canImport(SwiftDocC) - self.documentationManager = nil - self.documentationManager = DocumentationManager(sourceKitLSPServer: self) - #endif self.indexProgressManager = IndexProgressManager(sourceKitLSPServer: self) self.sourcekitdCrashedWorkDoneProgress = SharedWorkDoneProgressManager( sourceKitLSPServer: self, @@ -762,9 +752,9 @@ extension SourceKitLSPServer: QueueBasedMessageHandler { await self.handleRequest(for: request, requestHandler: self.declaration) case let request as RequestAndReply: await self.handleRequest(for: request, requestHandler: self.definition) - #if canImport(SwiftDocC) + #if canImport(DocCDocumentation) case let request as RequestAndReply: - await request.reply { try await doccDocumentation(request.params) } + await self.handleRequest(for: request, requestHandler: self.doccDocumentation) #endif case let request as RequestAndReply: await self.handleRequest(for: request, requestHandler: self.documentColor) @@ -1089,7 +1079,7 @@ extension SourceKitLSPServer { GetReferenceDocumentRequest.method: .dictionary(["version": .int(1)]), DidChangeActiveDocumentNotification.method: .dictionary(["version": .int(1)]), ] - #if canImport(SwiftDocC) + #if canImport(DocCDocumentation) experimentalCapabilities["textDocument/doccDocumentation"] = .dictionary(["version": .int(1)]) #endif @@ -1573,12 +1563,13 @@ extension SourceKitLSPServer { return try await documentService(for: completionItemData.uri).completionItemResolve(request) } - #if canImport(SwiftDocC) - func doccDocumentation(_ req: DoccDocumentationRequest) async throws -> DoccDocumentationResponse { - return try await documentationManager.convertDocumentation( - req.textDocument.uri, - at: req.position - ) + #if canImport(DocCDocumentation) + func doccDocumentation( + _ req: DoccDocumentationRequest, + workspace: Workspace, + languageService: LanguageService + ) async throws -> DoccDocumentationResponse { + return try await languageService.doccDocumentation(req) } #endif diff --git a/Sources/SourceKitLSP/Swift/CodeCompletion.swift b/Sources/SourceKitLSP/Swift/CodeCompletion.swift index e70489014..cd3471c40 100644 --- a/Sources/SourceKitLSP/Swift/CodeCompletion.swift +++ b/Sources/SourceKitLSP/Swift/CodeCompletion.swift @@ -23,7 +23,7 @@ extension SwiftLanguageService { let completionPos = await adjustPositionToStartOfIdentifier(req.position, in: snapshot) let filterText = String(snapshot.text[snapshot.index(of: completionPos).., includeSymbolGraph: Bool = false, - fallbackSettingsAfterTimeout: Bool, additionalParameters appendAdditionalParameters: ((SKDRequestDictionary) -> Void)? = nil ) async throws -> (cursorInfo: [CursorInfo], refactorActions: [SemanticRefactorCommand], symbolGraph: String?) { let documentManager = try self.documentManager - let snapshot = try await self.latestSnapshot(for: uri) let offsetRange = snapshot.utf8OffsetRange(of: range) @@ -154,8 +165,7 @@ extension SwiftLanguageService { keys.sourceFile: snapshot.uri.sourcekitdSourceFile, keys.primaryFile: snapshot.uri.primaryFile?.pseudoPath, keys.retrieveSymbolGraph: includeSymbolGraph ? 1 : 0, - keys.compilerArgs: await self.buildSettings(for: uri, fallbackAfterTimeout: fallbackSettingsAfterTimeout)? - .compilerArgs as [SKDRequestValue]?, + keys.compilerArgs: compileCommand?.compilerArgs as [SKDRequestValue]?, ]) appendAdditionalParameters?(skreq) @@ -163,17 +173,17 @@ extension SwiftLanguageService { let dict = try await sendSourcekitdRequest(skreq, fileContents: snapshot.text) var cursorInfoResults: [CursorInfo] = [] - if let cursorInfo = CursorInfo(dict, documentManager: documentManager, sourcekitd: sourcekitd) { + if let cursorInfo = CursorInfo(dict, snapshot: snapshot, documentManager: documentManager, sourcekitd: sourcekitd) { cursorInfoResults.append(cursorInfo) } cursorInfoResults += dict[keys.secondarySymbols]? - .compactMap { CursorInfo($0, documentManager: documentManager, sourcekitd: sourcekitd) } ?? [] + .compactMap { CursorInfo($0, snapshot: snapshot, documentManager: documentManager, sourcekitd: sourcekitd) } ?? [] let refactorActions = [SemanticRefactorCommand]( array: dict[keys.refactorActions], range: range, - textDocument: TextDocumentIdentifier(uri), + textDocument: TextDocumentIdentifier(snapshot.uri), keys, self.sourcekitd.api ) ?? [] @@ -181,4 +191,20 @@ extension SwiftLanguageService { return (cursorInfoResults, refactorActions, symbolGraph) } + + func cursorInfo( + _ uri: DocumentURI, + _ range: Range, + includeSymbolGraph: Bool = false, + fallbackSettingsAfterTimeout: Bool, + additionalParameters appendAdditionalParameters: ((SKDRequestDictionary) -> Void)? = nil + ) async throws -> (cursorInfo: [CursorInfo], refactorActions: [SemanticRefactorCommand], symbolGraph: String?) { + return try await self.cursorInfo( + self.latestSnapshot(for: uri), + compileCommand: await self.compileCommand(for: uri, fallbackAfterTimeout: fallbackSettingsAfterTimeout), + range, + includeSymbolGraph: includeSymbolGraph, + additionalParameters: appendAdditionalParameters + ) + } } diff --git a/Sources/SourceKitLSP/Swift/DiagnosticReportManager.swift b/Sources/SourceKitLSP/Swift/DiagnosticReportManager.swift index 6156bcc41..ccaa6ed68 100644 --- a/Sources/SourceKitLSP/Swift/DiagnosticReportManager.swift +++ b/Sources/SourceKitLSP/Swift/DiagnosticReportManager.swift @@ -14,6 +14,7 @@ import LanguageServerProtocol import LanguageServerProtocolExtensions import SKLogging import SKOptions +import SKUtilities import SourceKitD import SwiftDiagnostics import SwiftExtensions @@ -25,6 +26,11 @@ actor DiagnosticReportManager { (report: RelatedFullDocumentDiagnosticReport, cachable: Bool) > + private struct CacheKey: Hashable { + let snapshotID: DocumentSnapshot.ID + let buildSettings: SwiftCompileCommand? + } + private let sourcekitd: SourceKitD private let options: SourceKitLSPOptions private let syntaxTreeManager: SyntaxTreeManager @@ -36,20 +42,8 @@ actor DiagnosticReportManager { /// The cache that stores reportTasks for snapshot id and buildSettings /// - /// Conceptually, this is a dictionary. To prevent excessive memory usage we - /// only keep `cacheSize` entries within the array. Older entries are at the - /// end of the list, newer entries at the front. - private var reportTaskCache: - [( - snapshotID: DocumentSnapshot.ID, - buildSettings: SwiftCompileCommand?, - reportTask: ReportTask - )] = [] - - /// The number of reportTasks to keep - /// - /// - Note: This has been chosen without scientific measurements. - private let cacheSize = 5 + /// - Note: The capacity has been chosen without scientific measurements. + private var reportTaskCache = LRUCache(capacity: 5) init( sourcekitd: SourceKitD, @@ -188,14 +182,10 @@ actor DiagnosticReportManager { for snapshotID: DocumentSnapshot.ID, buildSettings: SwiftCompileCommand? ) -> ReportTask? { - return reportTaskCache.first(where: { $0.snapshotID == snapshotID && $0.buildSettings == buildSettings })? - .reportTask + return reportTaskCache[CacheKey(snapshotID: snapshotID, buildSettings: buildSettings)] } /// Set the reportTask for the given document snapshot and buildSettings. - /// - /// If we are already storing `cacheSize` many reports, the oldest one - /// will get discarded. private func setReportTask( for snapshotID: DocumentSnapshot.ID, buildSettings: SwiftCompileCommand?, @@ -203,13 +193,6 @@ actor DiagnosticReportManager { ) { // Remove any reportTasks for old versions of this document. reportTaskCache.removeAll(where: { $0.snapshotID <= snapshotID }) - - reportTaskCache.insert((snapshotID, buildSettings, reportTask), at: 0) - - // If we still have more than `cacheSize` reportTasks, delete the ones that - // were produced last. We can always re-request them on-demand. - while reportTaskCache.count > cacheSize { - reportTaskCache.removeLast() - } + reportTaskCache[CacheKey(snapshotID: snapshotID, buildSettings: buildSettings)] = reportTask } } diff --git a/Sources/SourceKitLSP/Swift/DoccDocumentation.swift b/Sources/SourceKitLSP/Swift/DoccDocumentation.swift new file mode 100644 index 000000000..d57fde7cc --- /dev/null +++ b/Sources/SourceKitLSP/Swift/DoccDocumentation.swift @@ -0,0 +1,159 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 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 +// +//===----------------------------------------------------------------------===// + +#if canImport(DocCDocumentation) +import BuildSystemIntegration +import DocCDocumentation +import Foundation +package import LanguageServerProtocol +import SemanticIndex +import SKLogging +import SwiftExtensions +import SwiftSyntax + +extension SwiftLanguageService { + package func doccDocumentation(_ req: DoccDocumentationRequest) async throws -> DoccDocumentationResponse { + guard let sourceKitLSPServer else { + throw ResponseError.internalError("SourceKit-LSP is shutting down") + } + guard let workspace = await sourceKitLSPServer.workspaceForDocument(uri: req.textDocument.uri) else { + throw ResponseError.workspaceNotOpen(req.textDocument.uri) + } + let documentationManager = workspace.doccDocumentationManager + guard let position = req.position else { + throw ResponseError.invalidParams("A position must be provided for Swift files") + } + let snapshot = try documentManager.latestSnapshot(req.textDocument.uri) + var moduleName: String? = nil + var catalogURL: URL? = nil + if let target = await workspace.buildSystemManager.canonicalTarget(for: req.textDocument.uri) { + moduleName = await workspace.buildSystemManager.moduleName(for: target) + catalogURL = await workspace.buildSystemManager.doccCatalog(for: target) + } + + // Search for the nearest documentable symbol at this location + let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) + guard + let nearestDocumentableSymbol = DocumentableSymbol.findNearestSymbol( + syntaxTree: syntaxTree, + position: snapshot.absolutePosition(of: position) + ) + else { + throw ResponseError.requestFailed(doccDocumentationError: .noDocumentation) + } + // Retrieve the symbol graph as well as information about the symbol + let symbolPosition = await adjustPositionToStartOfIdentifier( + snapshot.position(of: nearestDocumentableSymbol.position), + in: snapshot + ) + let (cursorInfo, _, symbolGraph) = try await cursorInfo( + req.textDocument.uri, + Range(symbolPosition), + includeSymbolGraph: true, + fallbackSettingsAfterTimeout: false + ) + guard let symbolGraph, + let cursorInfo = cursorInfo.first, + let symbolUSR = cursorInfo.symbolInfo.usr + else { + throw ResponseError.internalError("Unable to retrieve symbol graph for the document") + } + // Locate the documentation extension and include it in the request if one exists + let markupExtensionFile = await orLog("Finding markup extension file for symbol \(symbolUSR)") { + try await findMarkupExtensionFile( + workspace: workspace, + documentationManager: documentationManager, + catalogURL: catalogURL, + for: symbolUSR + ) + } + return try await documentationManager.renderDocCDocumentation( + symbolUSR: symbolUSR, + symbolGraph: symbolGraph, + overrideDocComments: nearestDocumentableSymbol.documentationComments, + markupFile: markupExtensionFile, + moduleName: moduleName, + catalogURL: catalogURL + ) + } + + private func findMarkupExtensionFile( + workspace: Workspace, + documentationManager: DocCDocumentationManager, + catalogURL: URL?, + for symbolUSR: String + ) async throws -> String? { + guard let catalogURL else { + return nil + } + let catalogIndex = try await documentationManager.catalogIndex(for: catalogURL) + guard let index = workspace.index(checkedFor: .deletedFiles), + let symbolInformation = DocCSymbolInformation(fromUSR: symbolUSR, in: index), + let markupExtensionFileURL = catalogIndex.documentationExtension(for: symbolInformation) + else { + return nil + } + return try? documentManager.latestSnapshotOrDisk( + DocumentURI(markupExtensionFileURL), + language: .markdown + )?.text + } +} + +fileprivate struct DocumentableSymbol { + let position: AbsolutePosition + let documentationComments: [String] + + init(node: any SyntaxProtocol, position: AbsolutePosition) { + self.position = position + self.documentationComments = node.leadingTrivia.flatMap { trivia -> [String] in + switch trivia { + case .docLineComment(let comment): + return [String(comment.dropFirst(3).trimmingCharacters(in: .whitespaces))] + case .docBlockComment(let comment): + return comment.dropFirst(3) + .dropLast(2) + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespaces) } + default: + return [] + } + } + } + + static func findNearestSymbol(syntaxTree: SourceFileSyntax, position: AbsolutePosition) -> DocumentableSymbol? { + guard let token = syntaxTree.token(at: position) else { + return nil + } + return token.ancestorOrSelf { node in + if let namedDecl = node.asProtocol(NamedDeclSyntax.self) { + return DocumentableSymbol(node: namedDecl, position: namedDecl.name.positionAfterSkippingLeadingTrivia) + } else if let initDecl = node.as(InitializerDeclSyntax.self) { + return DocumentableSymbol(node: initDecl, position: initDecl.initKeyword.positionAfterSkippingLeadingTrivia) + } else if let functionDecl = node.as(FunctionDeclSyntax.self) { + return DocumentableSymbol(node: functionDecl, position: functionDecl.name.positionAfterSkippingLeadingTrivia) + } else if let variableDecl = node.as(VariableDeclSyntax.self) { + guard let identifier = variableDecl.bindings.only?.pattern.as(IdentifierPatternSyntax.self) else { + return nil + } + return DocumentableSymbol(node: variableDecl, position: identifier.positionAfterSkippingLeadingTrivia) + } else if let enumCaseDecl = node.as(EnumCaseDeclSyntax.self) { + guard let name = enumCaseDecl.elements.only?.name else { + return nil + } + return DocumentableSymbol(node: enumCaseDecl, position: name.positionAfterSkippingLeadingTrivia) + } + return nil + } + } +} +#endif diff --git a/Sources/SourceKitLSP/Swift/GeneratedInterfaceDocumentURLData.swift b/Sources/SourceKitLSP/Swift/GeneratedInterfaceDocumentURLData.swift index f0d5031d7..bfa6522df 100644 --- a/Sources/SourceKitLSP/Swift/GeneratedInterfaceDocumentURLData.swift +++ b/Sources/SourceKitLSP/Swift/GeneratedInterfaceDocumentURLData.swift @@ -14,7 +14,6 @@ import Foundation import LanguageServerProtocol /// Represents url of generated interface reference document. - package struct GeneratedInterfaceDocumentURLData: Hashable, ReferenceURLData { package static let documentType = "generated-swift-interface" diff --git a/Sources/SourceKitLSP/Swift/GeneratedInterfaceManager.swift b/Sources/SourceKitLSP/Swift/GeneratedInterfaceManager.swift index 42a51a937..6cc7bd015 100644 --- a/Sources/SourceKitLSP/Swift/GeneratedInterfaceManager.swift +++ b/Sources/SourceKitLSP/Swift/GeneratedInterfaceManager.swift @@ -119,7 +119,7 @@ actor GeneratedInterfaceManager { keys.groupName: document.groupName, keys.name: document.sourcekitdDocumentName, keys.synthesizedExtension: 1, - keys.compilerArgs: await swiftLanguageService.buildSettings(for: try document.uri, fallbackAfterTimeout: false)? + keys.compilerArgs: await swiftLanguageService.compileCommand(for: try document.uri, fallbackAfterTimeout: false)? .compilerArgs as [SKDRequestValue]?, ]) diff --git a/Sources/SourceKitLSP/Swift/MacroExpansion.swift b/Sources/SourceKitLSP/Swift/MacroExpansion.swift index d7044287f..8cd170fa5 100644 --- a/Sources/SourceKitLSP/Swift/MacroExpansion.swift +++ b/Sources/SourceKitLSP/Swift/MacroExpansion.swift @@ -22,26 +22,10 @@ import SwiftExtensions /// Caches the contents of macro expansions that were recently requested by the user. actor MacroExpansionManager { - private struct CacheEntry { - // Key + private struct CacheKey: Hashable { let snapshotID: DocumentSnapshot.ID let range: Range let buildSettings: SwiftCompileCommand? - - // Value - let value: [RefactoringEdit] - - fileprivate init( - snapshot: DocumentSnapshot, - range: Range, - buildSettings: SwiftCompileCommand?, - value: [RefactoringEdit] - ) { - self.snapshotID = snapshot.id - self.range = range - self.buildSettings = buildSettings - self.value = value - } } init(swiftLanguageService: SwiftLanguageService?) { @@ -50,19 +34,12 @@ actor MacroExpansionManager { private weak var swiftLanguageService: SwiftLanguageService? - /// The number of macro expansions to cache. - /// - /// - Note: This should be bigger than the maximum expansion depth of macros a user might do to avoid re-generating - /// all parent macros to a nested macro expansion's buffer. 10 seems to be big enough for that because it's - /// unlikely that a macro will expand to more than 10 levels. - private let cacheSize = 10 - /// The cache that stores reportTasks for a combination of uri, range and build settings. /// - /// Conceptually, this is a dictionary. To prevent excessive memory usage we - /// only keep `cacheSize` entries within the array. Older entries are at the - /// end of the list, newer entries at the front. - private var cache: [CacheEntry] = [] + /// - Note: The capacity of this cache should be bigger than the maximum expansion depth of macros a user might + /// do to avoid re-generating all parent macros to a nested macro expansion's buffer. 10 seems to be big enough + /// for that because it's unlikely that a macro will expand to more than 10 levels. + private var cache = LRUCache(capacity: 10) /// Return the text of the macro expansion referenced by `macroExpansionURLData`. func macroExpansion( @@ -88,22 +65,14 @@ actor MacroExpansionManager { } let snapshot = try await swiftLanguageService.latestSnapshot(for: uri) - let buildSettings = await swiftLanguageService.buildSettings(for: uri, fallbackAfterTimeout: false) + let compileCommand = await swiftLanguageService.compileCommand(for: uri, fallbackAfterTimeout: false) - if let cacheEntry = cache.first(where: { - $0.snapshotID == snapshot.id && $0.range == range && $0.buildSettings == buildSettings - }) { - return cacheEntry.value - } - let macroExpansions = try await macroExpansionsImpl(in: snapshot, at: range, buildSettings: buildSettings) - cache.insert( - CacheEntry(snapshot: snapshot, range: range, buildSettings: buildSettings, value: macroExpansions), - at: 0 - ) - - while cache.count > cacheSize { - cache.removeLast() + let cacheKey = CacheKey(snapshotID: snapshot.id, range: range, buildSettings: compileCommand) + if let valueFromCache = cache[cacheKey] { + return valueFromCache } + let macroExpansions = try await macroExpansionsImpl(in: snapshot, at: range, buildSettings: compileCommand) + cache[cacheKey] = macroExpansions return macroExpansions } @@ -151,7 +120,9 @@ actor MacroExpansionManager { /// Remove all cached macro expansions for the given primary file, eg. because the macro's plugin might have changed. func purge(primaryFile: DocumentURI) { - cache.removeAll { $0.snapshotID.uri.primaryFile ?? $0.snapshotID.uri == primaryFile } + cache.removeAll { + $0.snapshotID.uri.primaryFile ?? $0.snapshotID.uri == primaryFile + } } } diff --git a/Sources/SourceKitLSP/Swift/RefactoringResponse.swift b/Sources/SourceKitLSP/Swift/RefactoringResponse.swift index 070a3dded..1b3f67b74 100644 --- a/Sources/SourceKitLSP/Swift/RefactoringResponse.swift +++ b/Sources/SourceKitLSP/Swift/RefactoringResponse.swift @@ -127,7 +127,7 @@ extension SwiftLanguageService { keys.column: utf8Column + 1, keys.length: snapshot.utf8OffsetRange(of: refactorCommand.positionRange).count, keys.actionUID: self.sourcekitd.api.uid_get_from_cstr(refactorCommand.actionString)!, - keys.compilerArgs: await self.buildSettings(for: snapshot.uri, fallbackAfterTimeout: true)?.compilerArgs + keys.compilerArgs: await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: true)?.compilerArgs as [SKDRequestValue]?, ]) diff --git a/Sources/SourceKitLSP/Swift/RelatedIdentifiers.swift b/Sources/SourceKitLSP/Swift/RelatedIdentifiers.swift index 8e3ec78b0..71662df52 100644 --- a/Sources/SourceKitLSP/Swift/RelatedIdentifiers.swift +++ b/Sources/SourceKitLSP/Swift/RelatedIdentifiers.swift @@ -73,7 +73,7 @@ extension SwiftLanguageService { keys.sourceFile: snapshot.uri.sourcekitdSourceFile, keys.primaryFile: snapshot.uri.primaryFile?.pseudoPath, keys.includeNonEditableBaseNames: includeNonEditableBaseNames ? 1 : 0, - keys.compilerArgs: await self.buildSettings(for: snapshot.uri, fallbackAfterTimeout: true)?.compilerArgs + keys.compilerArgs: await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: true)?.compilerArgs as [SKDRequestValue]?, ]) diff --git a/Sources/SourceKitLSP/Swift/SemanticTokens.swift b/Sources/SourceKitLSP/Swift/SemanticTokens.swift index f754d1727..4679e5828 100644 --- a/Sources/SourceKitLSP/Swift/SemanticTokens.swift +++ b/Sources/SourceKitLSP/Swift/SemanticTokens.swift @@ -20,8 +20,8 @@ import SwiftSyntax extension SwiftLanguageService { /// Requests the semantic highlighting tokens for the given snapshot from sourcekitd. private func semanticHighlightingTokens(for snapshot: DocumentSnapshot) async throws -> SyntaxHighlightingTokens? { - guard let buildSettings = await self.buildSettings(for: snapshot.uri, fallbackAfterTimeout: false), - !buildSettings.isFallback + guard let compileCommand = await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: false), + !compileCommand.isFallback else { return nil } @@ -30,7 +30,7 @@ extension SwiftLanguageService { keys.request: requests.semanticTokens, keys.sourceFile: snapshot.uri.sourcekitdSourceFile, keys.primaryFile: snapshot.uri.primaryFile?.pseudoPath, - keys.compilerArgs: buildSettings.compilerArgs as [SKDRequestValue], + keys.compilerArgs: compileCommand.compilerArgs as [SKDRequestValue], ]) let dict = try await sendSourcekitdRequest(skreq, fileContents: snapshot.text) diff --git a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift index 9fcb132f2..d98bb6b13 100644 --- a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift +++ b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift @@ -73,7 +73,7 @@ fileprivate func diagnosticsEnabled(for document: DocumentURI) -> Bool { } /// A swift compiler command derived from a `FileBuildSettingsChange`. -package struct SwiftCompileCommand: Sendable, Equatable { +package struct SwiftCompileCommand: Sendable, Equatable, Hashable { /// The compiler arguments, including working directory. This is required since sourcekitd only /// accepts the working directory via the compiler arguments. @@ -279,7 +279,7 @@ package actor SwiftLanguageService: LanguageService, Sendable { } } - func buildSettings(for document: DocumentURI, fallbackAfterTimeout: Bool) async -> SwiftCompileCommand? { + func buildSettings(for document: DocumentURI, fallbackAfterTimeout: Bool) async -> FileBuildSettings? { let buildSettingsFile = document.buildSettingsFile guard let sourceKitLSPServer else { @@ -289,11 +289,15 @@ package actor SwiftLanguageService: LanguageService, Sendable { guard let workspace = await sourceKitLSPServer.workspaceForDocument(uri: buildSettingsFile) else { return nil } - if let settings = await workspace.buildSystemManager.buildSettingsInferredFromMainFile( + return await workspace.buildSystemManager.buildSettingsInferredFromMainFile( for: buildSettingsFile, language: .swift, fallbackAfterTimeout: fallbackAfterTimeout - ) { + ) + } + + func compileCommand(for document: DocumentURI, fallbackAfterTimeout: Bool) async -> SwiftCompileCommand? { + if let settings = await self.buildSettings(for: document, fallbackAfterTimeout: fallbackAfterTimeout) { return SwiftCompileCommand(settings) } else { return nil @@ -453,7 +457,7 @@ extension SwiftLanguageService { try await self.sendSourcekitdRequest(closeReq, fileContents: nil) } - let buildSettings = await buildSettings(for: snapshot.uri, fallbackAfterTimeout: true) + let buildSettings = await compileCommand(for: snapshot.uri, fallbackAfterTimeout: true) let openReq = openDocumentSourcekitdRequest( snapshot: snapshot, compileCommand: buildSettings @@ -475,7 +479,7 @@ extension SwiftLanguageService { guard (try? documentManager.openDocuments.contains(uri)) ?? false else { return } - let newBuildSettings = await self.buildSettings(for: uri, fallbackAfterTimeout: false) + let newBuildSettings = await self.compileCommand(for: uri, fallbackAfterTimeout: false) if newBuildSettings != buildSettingsForOpenFiles[uri] { // Close and re-open the document internally to inform sourcekitd to update the compile command. At the moment // there's no better way to do this. @@ -509,7 +513,7 @@ extension SwiftLanguageService { // MARK: - Text synchronization - private func openDocumentSourcekitdRequest( + func openDocumentSourcekitdRequest( snapshot: DocumentSnapshot, compileCommand: SwiftCompileCommand? ) -> SKDRequestDictionary { @@ -545,11 +549,13 @@ extension SwiftLanguageService { cancelInFlightPublishDiagnosticsTask(for: notification.textDocument.uri) await diagnosticReportManager.removeItemsFromCache(with: notification.textDocument.uri) - let buildSettings = await self.buildSettings(for: snapshot.uri, fallbackAfterTimeout: true) + let buildSettings = await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: true) buildSettingsForOpenFiles[snapshot.uri] = buildSettings let req = openDocumentSourcekitdRequest(snapshot: snapshot, compileCommand: buildSettings) - _ = try? await self.sendSourcekitdRequest(req, fileContents: snapshot.text) + await orLog("Opening sourcekitd document") { + _ = try await self.sendSourcekitdRequest(req, fileContents: snapshot.text) + } await publishDiagnosticsIfNeeded(for: notification.textDocument.uri) } } @@ -566,7 +572,9 @@ extension SwiftLanguageService { await generatedInterfaceManager.close(document: data) case nil: let req = closeDocumentSourcekitdRequest(uri: notification.textDocument.uri) - _ = try? await self.sendSourcekitdRequest(req, fileContents: nil) + await orLog("Closing sourcekitd document") { + _ = try await self.sendSourcekitdRequest(req, fileContents: nil) + } } } @@ -608,7 +616,7 @@ extension SwiftLanguageService { } do { let snapshot = try await self.latestSnapshot(for: document) - let buildSettings = await self.buildSettings(for: document, fallbackAfterTimeout: false) + let buildSettings = await self.compileCommand(for: document, fallbackAfterTimeout: false) let diagnosticReport = try await self.diagnosticReportManager.diagnosticReport( for: snapshot, buildSettings: buildSettings @@ -958,7 +966,7 @@ extension SwiftLanguageService { func retrieveQuickFixCodeActions(_ params: CodeActionRequest) async throws -> [CodeAction] { let snapshot = try await self.latestSnapshot(for: params.textDocument.uri) - let buildSettings = await self.buildSettings(for: params.textDocument.uri, fallbackAfterTimeout: true) + let buildSettings = await self.compileCommand(for: params.textDocument.uri, fallbackAfterTimeout: true) let diagnosticReport = try await self.diagnosticReportManager.diagnosticReport( for: snapshot, buildSettings: buildSettings @@ -1066,7 +1074,7 @@ extension SwiftLanguageService { 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) + let buildSettings = await self.compileCommand(for: req.textDocument.uri, fallbackAfterTimeout: false) try Task.checkCancellation() let diagnosticReport = try await self.diagnosticReportManager.diagnosticReport( for: snapshot, diff --git a/Sources/SourceKitLSP/Swift/SyntaxTreeManager.swift b/Sources/SourceKitLSP/Swift/SyntaxTreeManager.swift index 998462fdc..567bdff0b 100644 --- a/Sources/SourceKitLSP/Swift/SyntaxTreeManager.swift +++ b/Sources/SourceKitLSP/Swift/SyntaxTreeManager.swift @@ -10,6 +10,7 @@ // //===----------------------------------------------------------------------===// +import SKUtilities import SwiftParser import SwiftSyntax @@ -23,21 +24,10 @@ actor SyntaxTreeManager { /// The tasks that compute syntax trees. /// - /// Conceptually, this is a dictionary. To prevent excessive memory usage we - /// only keep `cacheSize` entries within the array. Older entries are at the - /// end of the list, newer entries at the front. - private var syntaxTreeComputations: - [( - snapshotID: DocumentSnapshot.ID, - computation: SyntaxTreeComputation - )] = [] - - /// The number of syntax trees to keep. - /// - /// - Note: This has been chosen without scientific measurements. The feeling - /// is that you rarely work on more than 5 files at once and 5 syntax trees - /// don't take up too much memory. - private let cacheSize = 5 + /// - Note: The capacity has been chosen without scientific measurements. The + /// feeling is that you rarely work on more than 5 files at once and 5 syntax + /// trees don't take up too much memory. + private var syntaxTreeComputations = LRUCache(capacity: 5) /// - Important: For testing only private var reusedNodeCallback: ReusedNodeCallback? @@ -49,24 +39,14 @@ actor SyntaxTreeManager { /// The task that computes the syntax tree for the given document snapshot. private func computation(for snapshotID: DocumentSnapshot.ID) -> SyntaxTreeComputation? { - return syntaxTreeComputations.first(where: { $0.snapshotID == snapshotID })?.computation + return syntaxTreeComputations[snapshotID] } /// Set the task that computes the syntax tree for the given document snapshot. - /// - /// If we are already storing `cacheSize` many syntax trees, the oldest one - /// will get discarded. private func setComputation(for snapshotID: DocumentSnapshot.ID, computation: SyntaxTreeComputation) { - syntaxTreeComputations.insert((snapshotID, computation), at: 0) - // Remove any syntax trees for old versions of this document. - syntaxTreeComputations.removeAll(where: { $0.snapshotID < snapshotID }) - - // If we still have more than `cacheSize` syntax trees, delete the ones that - // were produced last. We can always re-compute them on-demand. - while syntaxTreeComputations.count > cacheSize { - syntaxTreeComputations.removeLast() - } + syntaxTreeComputations.removeAll(where: { $0 < snapshotID }) + syntaxTreeComputations[snapshotID] = computation } /// Get the SwiftSyntax tree for the given document snapshot. diff --git a/Sources/SourceKitLSP/Swift/VariableTypeInfo.swift b/Sources/SourceKitLSP/Swift/VariableTypeInfo.swift index 1b48f2d77..7420b0f42 100644 --- a/Sources/SourceKitLSP/Swift/VariableTypeInfo.swift +++ b/Sources/SourceKitLSP/Swift/VariableTypeInfo.swift @@ -89,7 +89,7 @@ extension SwiftLanguageService { keys.request: requests.collectVariableType, keys.sourceFile: snapshot.uri.sourcekitdSourceFile, keys.primaryFile: snapshot.uri.primaryFile?.pseudoPath, - keys.compilerArgs: await self.buildSettings(for: uri, fallbackAfterTimeout: false)?.compilerArgs + keys.compilerArgs: await self.compileCommand(for: uri, fallbackAfterTimeout: false)?.compilerArgs as [SKDRequestValue]?, ]) diff --git a/Sources/SourceKitLSP/Swift/WithSnapshotFromDiskOpenedInSourcekitd.swift b/Sources/SourceKitLSP/Swift/WithSnapshotFromDiskOpenedInSourcekitd.swift new file mode 100644 index 000000000..6348b8015 --- /dev/null +++ b/Sources/SourceKitLSP/Swift/WithSnapshotFromDiskOpenedInSourcekitd.swift @@ -0,0 +1,67 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2018 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 BuildSystemIntegration +import Foundation +import LanguageServerProtocol +import SKLogging +import SKUtilities +import SwiftExtensions + +extension SwiftLanguageService { + /// Open a unique dummy document in sourcekitd that has the contents of the file on disk for `uri` but an arbitrary + /// URI which doesn't exist on disk. Invoke `body` with a snapshot that contains the on-disk document contents and has + /// that dummy URI as well as build settings that were inferred from `uri` but have that URI replaced with the dummy + /// URI. Close the document in sourcekit after `body` has finished. + func withSnapshotFromDiskOpenedInSourcekitd( + uri: DocumentURI, + fallbackSettingsAfterTimeout: Bool, + body: (_ snapshot: DocumentSnapshot, _ patchedCompileCommand: SwiftCompileCommand?) async throws -> Result + ) async throws -> Result { + guard let fileURL = uri.fileURL else { + throw ResponseError.unknown("Cannot create snapshot with on-disk contents for non-file URI \(uri.forLogging)") + } + let snapshot = DocumentSnapshot( + uri: try DocumentURI(filePath: "\(UUID().uuidString)/\(fileURL.filePath)", isDirectory: false), + language: .swift, + version: 0, + lineTable: LineTable(try String(contentsOf: fileURL, encoding: .utf8)) + ) + let patchedCompileCommand: SwiftCompileCommand? = + if let buildSettings = await self.buildSettings( + for: uri, + fallbackAfterTimeout: fallbackSettingsAfterTimeout + ) { + SwiftCompileCommand(buildSettings.patching(newFile: snapshot.uri, originalFile: uri)) + } else { + nil + } + + _ = try await sendSourcekitdRequest( + self.openDocumentSourcekitdRequest(snapshot: snapshot, compileCommand: patchedCompileCommand), + fileContents: snapshot.text + ) + let result: Swift.Result + do { + result = .success(try await body(snapshot, patchedCompileCommand)) + } catch { + result = .failure(error) + } + await orLog("Close helper document '\(snapshot.uri)' for cursorInfoFromDisk") { + _ = try await sendSourcekitdRequest( + self.closeDocumentSourcekitdRequest(uri: snapshot.uri), + fileContents: snapshot.text + ) + } + return try result.get() + } +} diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index 30a74c5e3..ba4becc84 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -26,6 +26,10 @@ import ToolchainRegistry import struct TSCBasic.AbsolutePath import struct TSCBasic.RelativePath +#if canImport(DocCDocumentation) +package import DocCDocumentation +#endif + /// Actor that caches realpaths for `sourceFilesWithSameRealpath`. fileprivate actor SourceFilesWithSameRealpathInferrer { private let buildSystemManager: BuildSystemManager @@ -97,6 +101,10 @@ package final class Workspace: Sendable, BuildSystemManagerDelegate { /// The build system manager to use for documents in this workspace. package let buildSystemManager: BuildSystemManager + #if canImport(DocCDocumentation) + package let doccDocumentationManager: DocCDocumentationManager + #endif + private let sourceFilesWithSameRealpathInferrer: SourceFilesWithSameRealpathInferrer let options: SourceKitLSPOptions @@ -145,6 +153,9 @@ package final class Workspace: Sendable, BuildSystemManagerDelegate { self.options = options self._uncheckedIndex = ThreadSafeBox(initialValue: uncheckedIndex) self.buildSystemManager = buildSystemManager + #if canImport(DocCDocumentation) + self.doccDocumentationManager = DocCDocumentationManager(buildSystemManager: buildSystemManager) + #endif self.sourceFilesWithSameRealpathInferrer = SourceFilesWithSameRealpathInferrer( buildSystemManager: buildSystemManager ) @@ -392,6 +403,9 @@ package final class Workspace: Sendable, BuildSystemManagerDelegate { // Notify all clients about the reported and inferred edits. await buildSystemManager.filesDidChange(events) + #if canImport(DocCDocumentation) + await doccDocumentationManager.filesDidChange(events) + #endif async let updateSyntacticIndex: Void = await syntacticTestIndex.filesDidChange(events) async let updateSemanticIndex: Void? = await semanticIndexManager?.filesDidChange(events) diff --git a/Sources/sourcekit-lsp/SourceKitLSP.swift b/Sources/sourcekit-lsp/SourceKitLSP.swift index ec7d4ca34..b6f4db35d 100644 --- a/Sources/sourcekit-lsp/SourceKitLSP.swift +++ b/Sources/sourcekit-lsp/SourceKitLSP.swift @@ -136,7 +136,7 @@ struct SourceKitLSP: AsyncParsableCommand { name: .customLong("experimental-feature"), help: """ Enable an experimental sourcekit-lsp feature. - Available features are: \(ExperimentalFeature.allCases.map(\.rawValue).joined(separator: ", ")) + Available features are: \(ExperimentalFeature.allNonInternalCases.map(\.rawValue).joined(separator: ", ")) """ ) var experimentalFeatures: [String] = [] diff --git a/Tests/SKUtilitiesTests/LRUCacheTests.swift b/Tests/SKUtilitiesTests/LRUCacheTests.swift new file mode 100644 index 000000000..7ce515be7 --- /dev/null +++ b/Tests/SKUtilitiesTests/LRUCacheTests.swift @@ -0,0 +1,152 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 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 SKTestSupport +import SKUtilities +import XCTest + +final class LRUCacheTests: XCTestCase { + func testGetValue() { + var lruCache = LRUCache(capacity: 5) + + // Add key-value pairs up to the cache's capacity + for i in 1...lruCache.capacity { + lruCache[i] = i + XCTAssertEqual(lruCache[i], i) + } + + // Getting the key-value pair with key 3 should make it MRU + XCTAssertEqual(lruCache[3], 3) + + // Inserting 4 key-value pairs should keep the MRU key 3 + for i in 6...9 { + lruCache[i] = i + } + assertLRUCacheKeys(lruCache, expectedKeys: [3, 6, 7, 8, 9]) + } + + func testModifyValue() { + struct ComplexValue: Equatable { + var real: Int + var imaginary: Int + } + + // Add key-value pairs up to the cache's capacity + var lruCache = LRUCache(capacity: 5) + for i in 1...lruCache.capacity { + lruCache[i] = ComplexValue(real: i, imaginary: i) + } + + // Modifying the key-value pair with key 3 should make it MRU + lruCache[3]?.real = 7 + + // Inserting 4 key-value pairs should keep the MRU key 3 + for i in 6...9 { + lruCache[i] = ComplexValue(real: i, imaginary: i) + } + assertLRUCacheKeys(lruCache, expectedKeys: [3, 6, 7, 8, 9]) + + // Make sure that the associated value for key 3 has actually been modified + XCTAssertEqual(lruCache[3], ComplexValue(real: 7, imaginary: 3)) + } + + func testRemoveValue() { + var lruCache = LRUCache(capacity: 20) + for i in 1...10 { + lruCache[i] = i + } + + // Remove the key-value pair with key 5 + XCTAssertEqual(lruCache.removeValue(forKey: 5), 5) + assertLRUCacheKeys(lruCache, expectedKeys: [1, 2, 3, 4, 6, 7, 8, 9, 10]) + + // Try to remove a key that does not exist in the cache + XCTAssertNil(lruCache.removeValue(forKey: 20)) + assertLRUCacheKeys(lruCache, expectedKeys: [1, 2, 3, 4, 6, 7, 8, 9, 10]) + } + + func testRemoveAll() { + var lruCache = LRUCache(capacity: 20) + for i in 1...20 { + lruCache[i] = i + } + + // Remove all even keys + lruCache.removeAll(where: { $0 % 2 == 0 }) + assertLRUCacheKeys(lruCache, expectedKeys: [1, 3, 5, 7, 9, 11, 13, 15, 17, 19]) + + // Remove all key-value pairs + lruCache.removeAll() + assertLRUCacheKeys(lruCache, expectedKeys: []) + } + + func testCaching() { + var lruCache = LRUCache(capacity: 5) + + // Insert 5 key-value pairs into the cache + for i in 1...5 { + lruCache[i] = i + } + assertLRUCacheKeys(lruCache, expectedKeys: [1, 2, 3, 4, 5]) + + // Adding a key-value pair should remove the LRU key 1 + lruCache[6] = 6 + assertLRUCacheKeys(lruCache, expectedKeys: [2, 3, 4, 5, 6]) + + // Remove 4 + lruCache[4] = nil + assertLRUCacheKeys(lruCache, expectedKeys: [2, 3, 5, 6]) + + // Accessing 2 should move it from LRU to MRU + XCTAssertEqual(lruCache[2], 2) + assertLRUCacheKeys(lruCache, expectedKeys: [2, 3, 5, 6]) + + // Adding two key-value pairs should remove the LRU key 3 + lruCache[7] = 7 + lruCache[8] = 8 + assertLRUCacheKeys(lruCache, expectedKeys: [2, 5, 6, 7, 8]) + + // Assigning to 5 should move it from LRU to MRU + lruCache[5] = 5 + assertLRUCacheKeys(lruCache, expectedKeys: [2, 5, 6, 7, 8]) + + // Adding another value should remove the LRU key 6 + lruCache[9] = 9 + assertLRUCacheKeys(lruCache, expectedKeys: [2, 5, 7, 8, 9]) + + // Adding five new key-value pairs should fill the cache + for i in 21...25 { + lruCache[i] = i + } + assertLRUCacheKeys(lruCache, expectedKeys: [21, 22, 23, 24, 25]) + + // Adding five new key-value pairs should fill the cache + for i in 26...30 { + lruCache[i] = i + } + assertLRUCacheKeys(lruCache, expectedKeys: [26, 27, 28, 29, 30]) + } +} + +fileprivate func assertLRUCacheKeys( + _ lruCache: LRUCache, + expectedKeys: [Key], + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssertEqual( + Array(lruCache.keys).sorted(), + expectedKeys.sorted(), + file: file, + line: line + ) +} diff --git a/Tests/SourceKitLSPTests/DoccDocumentationTests.swift b/Tests/SourceKitLSPTests/DoccDocumentationTests.swift index 0e13e700c..40320f753 100644 --- a/Tests/SourceKitLSPTests/DoccDocumentationTests.swift +++ b/Tests/SourceKitLSPTests/DoccDocumentationTests.swift @@ -10,7 +10,8 @@ // //===----------------------------------------------------------------------===// -#if canImport(SwiftDocC) +#if canImport(DocCDocumentation) +import DocCDocumentation import Foundation import LanguageServerProtocol import SKLogging @@ -20,6 +21,8 @@ import SwiftDocC import XCTest final class DoccDocumentationTests: XCTestCase { + // MARK: Swift Documentation + func testEmptySwiftFile() async throws { try await renderDocumentation( swiftFile: "1️⃣", @@ -467,11 +470,242 @@ final class DoccDocumentationTests: XCTestCase { expectedResponses: ["1️⃣": .renderNode(kind: .symbol, containing: "This is an amazing description")] ) } + + // MARK: Markdown Articles and Extensions + + func testMarkdownArticle() async throws { + let project = try await SwiftPMTestProject( + files: [ + "MyLibrary/MyFile.swift": "", + "MyLibrary/MyLibrary.docc/Getting Started.md": """ + 1️⃣# Getting Started with MyLibrary + + This is a getting started article not associated with any symbol. + """, + ], + enableBackgroundIndexing: true + ) + try await renderDocumentation( + fileName: "Getting Started.md", + project: project, + expectedResponses: ["1️⃣": .renderNode(kind: .article, containing: "This is a getting started article")] + ) + } + + func testMarkdownExtension() async throws { + let project = try await SwiftPMTestProject( + files: [ + "MyLibrary/MyFile.swift": """ + /// Documentation for foo() + public func 1️⃣foo() {} + + /// Documentation for bar() + public func 2️⃣bar() {} + + public struct Foo { + /// Documentation for Foo.bar + var b3️⃣ar: String + } + """, + "MyLibrary/MyLibrary.docc/Module.md": """ + 4️⃣# ``MyLibrary`` + """, + "MyLibrary/MyLibrary.docc/foo().md": """ + 5️⃣# ``MyLibrary/foo()`` + + # Additional information for foo() + + This will be appended to the end of foo()'s documentation page + """, + "MyLibrary/MyLibrary.docc/bar().md": """ + 6️⃣# ``MyLibrary/bar()`` + + # Additional information for bar() + + This will be appended to the end of bar()'s documentation page + """, + "MyLibrary/MyLibrary.docc/Foo.bar.md": """ + 7️⃣# ``MyLibrary/Foo/bar`` + + # Additional information for Foo.bar + + This will be appended to the end of Foo.bar's documentation page + """, + "MyLibrary/MyLibrary.docc/SymbolNotFound.md": """ + 8️⃣# ``MyLibrary/thisIsNotAValidSymbol`` + """, + ], + enableBackgroundIndexing: true + ) + try await renderDocumentation( + fileName: "MyFile.swift", + project: project, + expectedResponses: [ + "1️⃣": .renderNode(kind: .symbol, path: "MyLibrary/foo()", containing: "Additional information for foo()"), + "2️⃣": .renderNode(kind: .symbol, path: "MyLibrary/bar()", containing: "Additional information for bar()"), + "3️⃣": .renderNode(kind: .symbol, path: "MyLibrary/Foo/bar", containing: "Additional information for Foo.bar"), + ] + ) + try await renderDocumentation( + fileName: "Module.md", + project: project, + expectedResponses: ["4️⃣": .renderNode(kind: .symbol, path: "MyLibrary")] + ) + try await renderDocumentation( + fileName: "foo().md", + project: project, + expectedResponses: [ + "5️⃣": .renderNode(kind: .symbol, path: "MyLibrary/foo()", containing: "Documentation for foo()") + ] + ) + try await renderDocumentation( + fileName: "bar().md", + project: project, + expectedResponses: [ + "6️⃣": .renderNode(kind: .symbol, path: "MyLibrary/bar()", containing: "Documentation for bar()") + ] + ) + try await renderDocumentation( + fileName: "Foo.bar.md", + project: project, + expectedResponses: [ + "7️⃣": .renderNode(kind: .symbol, path: "MyLibrary/Foo/bar", containing: "Documentation for Foo.bar") + ] + ) + try await renderDocumentation( + fileName: "SymbolNotFound.md", + project: project, + expectedResponses: ["8️⃣": .error(.symbolNotFound("MyLibrary/thisIsNotAValidSymbol"))] + ) + } + + func testMarkdownExtensionForExtendedStruct() async throws { + let project = try await SwiftPMTestProject( + files: [ + "MyLibrary/Foo.swift": """ + public struct Foo { + let bar: String + } + """, + "MyLibrary/Color.swift": """ + extension Foo { + /// The color of Foo + 1️⃣enum Color { + /// A red color + case red + + /// A blue color + case blue + + /// A green color + case green + } + } + """, + "MyLibrary/MyLibrary.docc/Color.md": """ + 2️⃣# ``MyLibrary/Foo/Color-swift.enum`` + + # Additional information for the Color enum + + This will be appended to the end of Foo.bar's documentation page + """, + ], + enableBackgroundIndexing: true + ) + try await renderDocumentation( + fileName: "Color.swift", + project: project, + expectedResponses: [ + "1️⃣": .renderNode( + kind: .symbol, + path: "MyLibrary/Foo/Color", + containing: "Additional information for the Color enum" + ) + ] + ) + try await renderDocumentation( + fileName: "Color.md", + project: project, + expectedResponses: [ + "2️⃣": .renderNode(kind: .symbol, path: "MyLibrary/Foo/Color", containing: "The color of Foo") + ] + ) + } + + // MARK: Tutorials + + func testTutorial() async throws { + let project = try await SwiftPMTestProject( + files: [ + "MyLibrary/MyFile.swift": "", + "MyLibrary/MyLibrary.docc/MyTutorial.tutorial": """ + 1️⃣@Tutorial(time: 30) { + @Intro(title: "My Custom Tutorial") { + This tutorial will show you how to use MyLibrary + } + } + """, + ], + enableBackgroundIndexing: true + ) + try await renderDocumentation( + fileName: "MyTutorial.tutorial", + project: project, + expectedResponses: [ + "1️⃣": .renderNode(kind: .tutorial, containing: "This tutorial will show you how to use MyLibrary") + ] + ) + } + + func testTutorialOverview() async throws { + let project = try await SwiftPMTestProject( + files: [ + "MyLibrary/MyFile.swift": "", + "MyLibrary/MyLibrary.docc/MyTutorialOverview.tutorial": """ + 1️⃣@Tutorials(name: "MyTutorialOverview") { + @Intro(title: "My Custom Tutorial Overview") { + These tutorials will help you understand MyLibrary + } + } + """, + ], + enableBackgroundIndexing: true + ) + try await renderDocumentation( + fileName: "MyTutorialOverview.tutorial", + project: project, + expectedResponses: [ + "1️⃣": .renderNode(kind: .overview, containing: "These tutorials will help you understand MyLibrary") + ] + ) + } } fileprivate enum PartialConvertResponse { case renderNode(kind: RenderNode.Kind, path: String? = nil, containing: String? = nil) - case error(ConvertDocumentationError) + case error(DocCDocumentationError) +} + +fileprivate func renderDocumentation( + fileName: String, + project: SwiftPMTestProject, + expectedResponses: [String: PartialConvertResponse], + file: StaticString = #filePath, + line: UInt = #line +) async throws { + let (uri, positions) = try project.openDocument(fileName) + defer { + project.testClient.send(DidCloseTextDocumentNotification(textDocument: TextDocumentIdentifier(uri))) + } + + await renderDocumentation( + testClient: project.testClient, + uri: uri, + positions: positions, + expectedResponses: expectedResponses, + file: file, + line: line + ) } fileprivate func renderDocumentation( @@ -518,7 +752,7 @@ fileprivate func renderDocumentation( guard let renderNodeData = renderNodeString.data(using: .utf8), let renderNode = try? JSONDecoder().decode(RenderNode.self, from: renderNodeData) else { - XCTFail("failed to decode response from textDocument/doccDocumentation at position \(marker)") + XCTFail("failed to decode response from \(DoccDocumentationRequest.method) at position \(marker)") return } switch expectedResponse { @@ -549,7 +783,7 @@ fileprivate func renderDocumentation( } case .error(let error): XCTFail( - "expected error \(error.message), but received a render node at position \(marker)", + "expected error \(error.localizedDescription), but received a render node at position \(marker)", file: file, line: line ) @@ -558,7 +792,7 @@ fileprivate func renderDocumentation( switch expectedResponse { case .renderNode: XCTFail( - "textDocument/doccDocumentation failed at position \(marker): \(error.localizedDescription)", + "\(DoccDocumentationRequest.method) failed at position \(marker): \(error.message)", file: file, line: line ) @@ -572,8 +806,8 @@ fileprivate func renderDocumentation( ) XCTAssertEqual( error.message, - expectedError.message, - "expected an error with message \(expectedError.message) at position \(marker)", + expectedError.localizedDescription, + "expected an error with message \(expectedError.localizedDescription) at position \(marker)", file: file, line: line )