From 293f6381869b359b4000ede7f98bfee9b38fe0e9 Mon Sep 17 00:00:00 2001 From: Paul LeMarquand Date: Thu, 4 Jul 2024 14:55:14 -0400 Subject: [PATCH 1/3] Add Run/Debug CodeLens Support Adds a response to the textDocument/codeLens request that returns two code lenses on the `@main` attribute of an application. The LSP documentation breaks out the code lens requests into a [`Code Lens Request`](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_codeLens) and a [`Code Lens Resolve Request`](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#codeLens_resolve), stating this is for performance reasons. However, there is no intensive work we need to do in order to resolve the commands for a CodeLens; we know them based on context at the time of discovery. For this reason we return resolved lenses with Commands for code lens requests. A missing piece is only returning code lenses if the file resides in an executable product. To my knoledge Libraries and Plugins can't have an `@main` entrypoint and so it doesn't make sense to provide these code lenses in those contexts. Some guidance is required on how to best determine if the textDocument in the request is within an executable product. `testCodeLensRequestWithInvalidProduct` asserts that no lenses are returned with the `@main` attribute is on a file in a `.executable`, and is currently failing until this is addressed. --- .../SupportTypes/RegistrationOptions.swift | 42 ++++++++++ Sources/SourceKitLSP/CMakeLists.txt | 1 + Sources/SourceKitLSP/CapabilityRegistry.swift | 31 +++++++- .../Clang/ClangLanguageService.swift | 4 + Sources/SourceKitLSP/LanguageService.swift | 1 + Sources/SourceKitLSP/SourceKitLSPServer.swift | 14 ++++ .../Swift/SwiftCodeLensScanner.swift | 77 +++++++++++++++++++ .../Swift/SwiftLanguageService.swift | 5 ++ Tests/SourceKitLSPTests/CodeLensTests.swift | 70 +++++++++++++++++ 9 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 Sources/SourceKitLSP/Swift/SwiftCodeLensScanner.swift create mode 100644 Tests/SourceKitLSPTests/CodeLensTests.swift diff --git a/Sources/LanguageServerProtocol/SupportTypes/RegistrationOptions.swift b/Sources/LanguageServerProtocol/SupportTypes/RegistrationOptions.swift index 8dba8c1c3..b8b0f26cc 100644 --- a/Sources/LanguageServerProtocol/SupportTypes/RegistrationOptions.swift +++ b/Sources/LanguageServerProtocol/SupportTypes/RegistrationOptions.swift @@ -242,6 +242,48 @@ public struct DiagnosticRegistrationOptions: RegistrationOptions, TextDocumentRe } } +/// Describe options to be used when registering for code lenses. +public struct CodeLensRegistrationOptions: RegistrationOptions, TextDocumentRegistrationOptionsProtocol { + public var textDocumentRegistrationOptions: TextDocumentRegistrationOptions + public var codeLensOptions: CodeLensOptions + + public init( + documentSelector: DocumentSelector? = nil, + codeLensOptions: CodeLensOptions + ) { + textDocumentRegistrationOptions = TextDocumentRegistrationOptions(documentSelector: documentSelector) + self.codeLensOptions = codeLensOptions + } + + public init?(fromLSPDictionary dictionary: [String: LSPAny]) { + self.codeLensOptions = CodeLensOptions() + + if case .bool(let resolveProvider) = dictionary["resolveProvider"] { + self.codeLensOptions.resolveProvider = resolveProvider + } + + guard let textDocumentRegistrationOptions = TextDocumentRegistrationOptions(fromLSPDictionary: dictionary) else { + return nil + } + + self.textDocumentRegistrationOptions = textDocumentRegistrationOptions + } + + public func encodeToLSPAny() -> LSPAny { + var dict: [String: LSPAny] = [:] + + if let resolveProvider = codeLensOptions.resolveProvider { + dict["resolveProvider"] = .bool(resolveProvider) + } + + if case .dictionary(let dictionary) = textDocumentRegistrationOptions.encodeToLSPAny() { + dict.merge(dictionary) { (current, _) in current } + } + + return .dictionary(dict) + } +} + /// Describe options to be used when registering for file system change events. public struct DidChangeWatchedFilesRegistrationOptions: RegistrationOptions { /// The watchers to register. diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index 00e45f1b7..ca36d34da 100644 --- a/Sources/SourceKitLSP/CMakeLists.txt +++ b/Sources/SourceKitLSP/CMakeLists.txt @@ -57,6 +57,7 @@ target_sources(SourceKitLSP PRIVATE Swift/SemanticRefactoring.swift Swift/SemanticTokens.swift Swift/SourceKitD+ResponseError.swift + Swift/SwiftCodeLensScanner.swift Swift/SwiftCommand.swift Swift/SwiftLanguageService.swift Swift/SwiftTestingScanner.swift diff --git a/Sources/SourceKitLSP/CapabilityRegistry.swift b/Sources/SourceKitLSP/CapabilityRegistry.swift index 06a199912..88b99a4d4 100644 --- a/Sources/SourceKitLSP/CapabilityRegistry.swift +++ b/Sources/SourceKitLSP/CapabilityRegistry.swift @@ -38,6 +38,9 @@ package final actor CapabilityRegistry { /// Dynamically registered pull diagnostics options. private var pullDiagnostics: [CapabilityRegistration: DiagnosticRegistrationOptions] = [:] + /// Dynamically registered code lens options. + private var codeLens: [CapabilityRegistration: CodeLensRegistrationOptions] = [:] + /// Dynamically registered file watchers. private var didChangeWatchedFiles: DidChangeWatchedFilesRegistrationOptions? @@ -279,6 +282,7 @@ package final actor CapabilityRegistry { server: SourceKitLSPServer ) async { guard clientHasDynamicInlayHintRegistration else { return } + await registerLanguageSpecificCapability( options: InlayHintRegistrationOptions( documentSelector: DocumentSelector(for: languages), @@ -314,6 +318,29 @@ package final actor CapabilityRegistry { ) } + /// Dynamically register code lens capabilities, + /// if the client supports it. + public func registerCodeLensIfNeeded( + options: CodeLensOptions, + for languages: [Language], + server: SourceKitLSPServer + ) async { + guard clientHasDynamicDocumentCodeLensRegistration else { return } + + await registerLanguageSpecificCapability( + options: CodeLensRegistrationOptions( + // Code lenses should only apply to saved files + documentSelector: DocumentSelector(for: languages, scheme: "file"), + codeLensOptions: options + ), + forMethod: CodeLensRequest.method, + languages: languages, + in: server, + registrationDict: codeLens, + setRegistrationDict: { codeLens[$0] = $1 } + ) + } + /// Dynamically register executeCommand with the given IDs if the client supports /// it and we haven't yet registered the given command IDs yet. package func registerExecuteCommandIfNeeded( @@ -345,7 +372,7 @@ package final actor CapabilityRegistry { } fileprivate extension DocumentSelector { - init(for languages: [Language]) { - self.init(languages.map { DocumentFilter(language: $0.rawValue) }) + init(for languages: [Language], scheme: String? = nil) { + self.init(languages.map { DocumentFilter(language: $0.rawValue, scheme: scheme) }) } } diff --git a/Sources/SourceKitLSP/Clang/ClangLanguageService.swift b/Sources/SourceKitLSP/Clang/ClangLanguageService.swift index 8647cdd5a..c3d85a063 100644 --- a/Sources/SourceKitLSP/Clang/ClangLanguageService.swift +++ b/Sources/SourceKitLSP/Clang/ClangLanguageService.swift @@ -622,6 +622,10 @@ extension ClangLanguageService { return try await forwardRequestToClangd(req) } + func codeLens(_ req: CodeLensRequest) async throws -> [CodeLens] { + return try await forwardRequestToClangd(req) ?? [] + } + func foldingRange(_ req: FoldingRangeRequest) async throws -> [FoldingRange]? { guard self.capabilities?.foldingRangeProvider?.isSupported ?? false else { return nil diff --git a/Sources/SourceKitLSP/LanguageService.swift b/Sources/SourceKitLSP/LanguageService.swift index ba7b84850..feaefa52f 100644 --- a/Sources/SourceKitLSP/LanguageService.swift +++ b/Sources/SourceKitLSP/LanguageService.swift @@ -197,6 +197,7 @@ package protocol LanguageService: AnyObject, Sendable { func colorPresentation(_ req: ColorPresentationRequest) async throws -> [ColorPresentation] func codeAction(_ req: CodeActionRequest) async throws -> CodeActionRequestResponse? func inlayHint(_ req: InlayHintRequest) async throws -> [InlayHint] + func codeLens(_ req: CodeLensRequest) async throws -> [CodeLens] func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport func documentFormatting(_ req: DocumentFormattingRequest) async throws -> [TextEdit]? diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index 13025e14d..ed6624603 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -700,6 +700,8 @@ extension SourceKitLSPServer: MessageHandler { await self.handleRequest(for: request, requestHandler: self.prepareCallHierarchy) case let request as RequestAndReply: await self.handleRequest(for: request, requestHandler: self.codeAction) + case let request as RequestAndReply: + await self.handleRequest(for: request, requestHandler: self.codeLens) case let request as RequestAndReply: await self.handleRequest(for: request, requestHandler: self.colorPresentation) case let request as RequestAndReply: @@ -1100,6 +1102,7 @@ extension SourceKitLSPServer { supportsCodeActions: true ) ), + codeLensProvider: CodeLensOptions(resolveProvider: false), documentFormattingProvider: .value(DocumentFormattingOptions(workDoneProgress: false)), renameProvider: .value(RenameOptions(prepareProvider: true)), colorProvider: .bool(true), @@ -1161,6 +1164,9 @@ extension SourceKitLSPServer { if let diagnosticOptions = server.diagnosticProvider { await registry.registerDiagnosticIfNeeded(options: diagnosticOptions, for: languages, server: self) } + if let codeLensOptions = server.codeLensProvider { + await registry.registerCodeLensIfNeeded(options: codeLensOptions, for: languages, server: self) + } if let commandOptions = server.executeCommandProvider { await registry.registerExecuteCommandIfNeeded(commands: commandOptions.commands, server: self) } @@ -1642,6 +1648,14 @@ extension SourceKitLSPServer { return req.injectMetadata(toResponse: response) } + func codeLens( + _ req: CodeLensRequest, + workspace: Workspace, + languageService: LanguageService + ) async throws -> [CodeLens] { + return try await languageService.codeLens(req) + } + func inlayHint( _ req: InlayHintRequest, workspace: Workspace, diff --git a/Sources/SourceKitLSP/Swift/SwiftCodeLensScanner.swift b/Sources/SourceKitLSP/Swift/SwiftCodeLensScanner.swift new file mode 100644 index 000000000..3b65e15a8 --- /dev/null +++ b/Sources/SourceKitLSP/Swift/SwiftCodeLensScanner.swift @@ -0,0 +1,77 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import LanguageServerProtocol +import SwiftSyntax + +/// Scans a source file for classes or structs annotated with `@main` and returns a code lens for them. +final class SwiftCodeLensScanner: SyntaxVisitor { + /// The document snapshot of the syntax tree that is being walked. + private var snapshot: DocumentSnapshot + + /// The collection of CodeLenses found in the document. + private var result: [CodeLens] = [] + + private init(snapshot: DocumentSnapshot) { + self.snapshot = snapshot + super.init(viewMode: .fixedUp) + } + + /// Public entry point. Scans the syntax tree of the given snapshot for an `@main` annotation + /// and returns CodeLens's with Commands to run/debug the application. + public static func findCodeLenses( + in snapshot: DocumentSnapshot, + syntaxTreeManager: SyntaxTreeManager + ) async -> [CodeLens] { + guard snapshot.text.contains("@main") else { + // This is intended to filter out files that obviously do not contain an entry point. + return [] + } + + let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) + let visitor = SwiftCodeLensScanner(snapshot: snapshot) + visitor.walk(syntaxTree) + return visitor.result + } + + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + node.attributes.forEach(self.captureLensFromAttribute) + return .skipChildren + } + + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + node.attributes.forEach(self.captureLensFromAttribute) + return .skipChildren + } + + private func captureLensFromAttribute(attribute: AttributeListSyntax.Element) { + if attribute.trimmedDescription == "@main" { + let range = self.snapshot.absolutePositionRange(of: attribute.trimmedRange) + + // Return commands for running/debugging the executable. + // These command names must be recognized by the client and so should not be chosen arbitrarily. + self.result.append( + CodeLens( + range: range, + command: Command(title: "Run", command: "swift.run", arguments: nil) + ) + ) + + self.result.append( + CodeLens( + range: range, + command: Command(title: "Debug", command: "swift.debug", arguments: nil) + ) + ) + } + } +} diff --git a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift index 381ae925f..c406e6042 100644 --- a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift +++ b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift @@ -921,6 +921,11 @@ extension SwiftLanguageService { return Array(hints) } + package func codeLens(_ req: CodeLensRequest) async throws -> [CodeLens] { + let snapshot = try documentManager.latestSnapshot(req.textDocument.uri) + return await SwiftCodeLensScanner.findCodeLenses(in: snapshot, syntaxTreeManager: self.syntaxTreeManager) + } + package func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport { do { await semanticIndexManager?.prepareFileForEditorFunctionality(req.textDocument.uri) diff --git a/Tests/SourceKitLSPTests/CodeLensTests.swift b/Tests/SourceKitLSPTests/CodeLensTests.swift new file mode 100644 index 000000000..675d6055c --- /dev/null +++ b/Tests/SourceKitLSPTests/CodeLensTests.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2021 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 LSPTestSupport +import LanguageServerProtocol +import SKTestSupport +import XCTest + +final class CodeLensTests: XCTestCase { + func testNoLenses() async throws { + let project = try await SwiftPMTestProject( + files: [ + "Test.swift": """ + struct MyApp { + public static func main() {} + } + """ + ] + ) + let (uri, _) = try project.openDocument("Test.swift") + + let response = try await project.testClient.send( + CodeLensRequest(textDocument: TextDocumentIdentifier(uri)) + ) + + XCTAssertEqual(response, []) + } + + func testSuccessfulCodeLensRequest() async throws { + let project = try await SwiftPMTestProject( + files: [ + "Test.swift": """ + 1️⃣@main2️⃣ + struct MyApp { + public static func main() {} + } + """ + ] + ) + + let (uri, positions) = try project.openDocument("Test.swift") + + let response = try await project.testClient.send( + CodeLensRequest(textDocument: TextDocumentIdentifier(uri)) + ) + + XCTAssertEqual( + response, + [ + CodeLens( + range: positions["1️⃣"].. Date: Wed, 17 Jul 2024 09:38:35 -0400 Subject: [PATCH 2/3] Let client supply code lenses it can run As part of its initialization options the client can pass a textDocument/codeLens object that lists the supported commands the client can handle. It is in the form of a dictionary where the key is the lens name recognized by SourceKit-LSP and the value is the command as recognized by the client. ``` initializationOptions: { "textDocument/codeLens": { supportedCommands: { "swift.run": "clientCommandName_Run", "swift.debug": "clientCommandName_Debug", } } } ``` --- .../SupportTypes/ClientCapabilities.swift | 29 ++++++++++++- Sources/SourceKitLSP/CapabilityRegistry.swift | 27 ++---------- Sources/SourceKitLSP/SourceKitLSPServer.swift | 37 ++++++++++------ .../Swift/SwiftCodeLensScanner.swift | 43 +++++++++++-------- .../Swift/SwiftLanguageService.swift | 7 ++- Tests/SourceKitLSPTests/CodeLensTests.swift | 39 ++++++++++++++++- 6 files changed, 126 insertions(+), 56 deletions(-) diff --git a/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift b/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift index 06e662d82..47ae64db6 100644 --- a/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift +++ b/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift @@ -465,6 +465,31 @@ public struct TextDocumentClientCapabilities: Hashable, Codable, Sendable { } } + public struct CodeLens: Hashable, Codable, Sendable { + + /// Whether the client supports dynamic registration of this request. + public var dynamicRegistration: Bool? + + public var supportedCommands: [String: String] + + public init(dynamicRegistration: Bool? = nil, supportedCommands: [String: String] = [:]) { + self.dynamicRegistration = dynamicRegistration + self.supportedCommands = supportedCommands + } + + public init(from decoder: any Decoder) throws { + let registration = try DynamicRegistrationCapability(from: decoder) + self = CodeLens( + dynamicRegistration: registration.dynamicRegistration + ) + } + + public func encode(to encoder: any Encoder) throws { + let registration = DynamicRegistrationCapability(dynamicRegistration: self.dynamicRegistration) + try registration.encode(to: encoder) + } + } + /// Capabilities specific to `textDocument/rename`. public struct Rename: Hashable, Codable, Sendable { @@ -666,7 +691,7 @@ public struct TextDocumentClientCapabilities: Hashable, Codable, Sendable { public var codeAction: CodeAction? = nil - public var codeLens: DynamicRegistrationCapability? = nil + public var codeLens: CodeLens? = nil public var documentLink: DynamicRegistrationCapability? = nil @@ -715,7 +740,7 @@ public struct TextDocumentClientCapabilities: Hashable, Codable, Sendable { documentHighlight: DynamicRegistrationCapability? = nil, documentSymbol: DocumentSymbol? = nil, codeAction: CodeAction? = nil, - codeLens: DynamicRegistrationCapability? = nil, + codeLens: CodeLens? = nil, documentLink: DynamicRegistrationCapability? = nil, colorProvider: DynamicRegistrationCapability? = nil, formatting: DynamicRegistrationCapability? = nil, diff --git a/Sources/SourceKitLSP/CapabilityRegistry.swift b/Sources/SourceKitLSP/CapabilityRegistry.swift index 88b99a4d4..3f8a1c651 100644 --- a/Sources/SourceKitLSP/CapabilityRegistry.swift +++ b/Sources/SourceKitLSP/CapabilityRegistry.swift @@ -83,6 +83,10 @@ package final actor CapabilityRegistry { clientCapabilities.textDocument?.publishDiagnostics?.codeDescriptionSupport == true } + public var supportedCodeLensCommands: [String: String] { + clientCapabilities.textDocument?.codeLens?.supportedCommands ?? [:] + } + /// Since LSP 3.17.0, diagnostics can be reported through pull-based requests in addition to the existing push-based /// publish notifications. /// @@ -318,29 +322,6 @@ package final actor CapabilityRegistry { ) } - /// Dynamically register code lens capabilities, - /// if the client supports it. - public func registerCodeLensIfNeeded( - options: CodeLensOptions, - for languages: [Language], - server: SourceKitLSPServer - ) async { - guard clientHasDynamicDocumentCodeLensRegistration else { return } - - await registerLanguageSpecificCapability( - options: CodeLensRegistrationOptions( - // Code lenses should only apply to saved files - documentSelector: DocumentSelector(for: languages, scheme: "file"), - codeLensOptions: options - ), - forMethod: CodeLensRequest.method, - languages: languages, - in: server, - registrationDict: codeLens, - setRegistrationDict: { codeLens[$0] = $1 } - ) - } - /// Dynamically register executeCommand with the given IDs if the client supports /// it and we haven't yet registered the given command IDs yet. package func registerExecuteCommandIfNeeded( diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index ed6624603..de999bdbc 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -963,14 +963,30 @@ extension SourceKitLSPServer { // The below is a workaround for the vscode-swift extension since it cannot set client capabilities. // It passes "workspace/peekDocuments" through the `initializationOptions`. var clientCapabilities = req.capabilities - if case .dictionary(let initializationOptions) = req.initializationOptions, - let peekDocuments = initializationOptions["workspace/peekDocuments"] - { - if case .dictionary(var experimentalCapabilities) = clientCapabilities.experimental { - experimentalCapabilities["workspace/peekDocuments"] = peekDocuments - clientCapabilities.experimental = .dictionary(experimentalCapabilities) - } else { - clientCapabilities.experimental = .dictionary(["workspace/peekDocuments": peekDocuments]) + if case .dictionary(let initializationOptions) = req.initializationOptions { + if let peekDocuments = initializationOptions["workspace/peekDocuments"] { + if case .dictionary(var experimentalCapabilities) = clientCapabilities.experimental { + experimentalCapabilities["workspace/peekDocuments"] = peekDocuments + clientCapabilities.experimental = .dictionary(experimentalCapabilities) + } else { + clientCapabilities.experimental = .dictionary(["workspace/peekDocuments": peekDocuments]) + } + } + + // The client announces what CodeLenses it supports, and the LSP will only return + // ones found in the supportedCommands dictionary. + if let codeLens = initializationOptions["textDocument/codeLens"], + case let .dictionary(codeLensConfig) = codeLens, + case let .dictionary(supportedCommands) = codeLensConfig["supportedCommands"] + { + let commandMap = supportedCommands.compactMapValues({ + if case let .string(val) = $0 { + return val + } + return nil + }) + + clientCapabilities.textDocument?.codeLens?.supportedCommands = commandMap } } @@ -1102,7 +1118,7 @@ extension SourceKitLSPServer { supportsCodeActions: true ) ), - codeLensProvider: CodeLensOptions(resolveProvider: false), + codeLensProvider: CodeLensOptions(), documentFormattingProvider: .value(DocumentFormattingOptions(workDoneProgress: false)), renameProvider: .value(RenameOptions(prepareProvider: true)), colorProvider: .bool(true), @@ -1164,9 +1180,6 @@ extension SourceKitLSPServer { if let diagnosticOptions = server.diagnosticProvider { await registry.registerDiagnosticIfNeeded(options: diagnosticOptions, for: languages, server: self) } - if let codeLensOptions = server.codeLensProvider { - await registry.registerCodeLensIfNeeded(options: codeLensOptions, for: languages, server: self) - } if let commandOptions = server.executeCommandProvider { await registry.registerExecuteCommandIfNeeded(commands: commandOptions.commands, server: self) } diff --git a/Sources/SourceKitLSP/Swift/SwiftCodeLensScanner.swift b/Sources/SourceKitLSP/Swift/SwiftCodeLensScanner.swift index 3b65e15a8..cf810a90a 100644 --- a/Sources/SourceKitLSP/Swift/SwiftCodeLensScanner.swift +++ b/Sources/SourceKitLSP/Swift/SwiftCodeLensScanner.swift @@ -16,13 +16,17 @@ import SwiftSyntax /// Scans a source file for classes or structs annotated with `@main` and returns a code lens for them. final class SwiftCodeLensScanner: SyntaxVisitor { /// The document snapshot of the syntax tree that is being walked. - private var snapshot: DocumentSnapshot + private let snapshot: DocumentSnapshot /// The collection of CodeLenses found in the document. private var result: [CodeLens] = [] - private init(snapshot: DocumentSnapshot) { + /// The map of supported commands and their client side command names + private let supportedCommands: [String: String] + + private init(snapshot: DocumentSnapshot, supportedCommands: [String: String]) { self.snapshot = snapshot + self.supportedCommands = supportedCommands super.init(viewMode: .fixedUp) } @@ -30,15 +34,16 @@ final class SwiftCodeLensScanner: SyntaxVisitor { /// and returns CodeLens's with Commands to run/debug the application. public static func findCodeLenses( in snapshot: DocumentSnapshot, - syntaxTreeManager: SyntaxTreeManager + syntaxTreeManager: SyntaxTreeManager, + supportedCommands: [String: String] ) async -> [CodeLens] { - guard snapshot.text.contains("@main") else { + guard snapshot.text.contains("@main") && !supportedCommands.isEmpty else { // This is intended to filter out files that obviously do not contain an entry point. return [] } let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) - let visitor = SwiftCodeLensScanner(snapshot: snapshot) + let visitor = SwiftCodeLensScanner(snapshot: snapshot, supportedCommands: supportedCommands) visitor.walk(syntaxTree) return visitor.result } @@ -57,21 +62,25 @@ final class SwiftCodeLensScanner: SyntaxVisitor { if attribute.trimmedDescription == "@main" { let range = self.snapshot.absolutePositionRange(of: attribute.trimmedRange) - // Return commands for running/debugging the executable. - // These command names must be recognized by the client and so should not be chosen arbitrarily. - self.result.append( - CodeLens( - range: range, - command: Command(title: "Run", command: "swift.run", arguments: nil) + if let runCommand = supportedCommands["swift.run"] { + // Return commands for running/debugging the executable. + // These command names must be recognized by the client and so should not be chosen arbitrarily. + self.result.append( + CodeLens( + range: range, + command: Command(title: "Run", command: runCommand, arguments: nil) + ) ) - ) + } - self.result.append( - CodeLens( - range: range, - command: Command(title: "Debug", command: "swift.debug", arguments: nil) + if let debugCommand = supportedCommands["swift.debug"] { + self.result.append( + CodeLens( + range: range, + command: Command(title: "Debug", command: debugCommand, arguments: nil) + ) ) - ) + } } } } diff --git a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift index c406e6042..404ae4e03 100644 --- a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift +++ b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift @@ -317,6 +317,7 @@ extension SwiftLanguageService { supportsCodeActions: true ) ), + codeLensProvider: CodeLensOptions(), colorProvider: .bool(true), foldingRangeProvider: .bool(true), executeCommandProvider: ExecuteCommandOptions( @@ -923,7 +924,11 @@ extension SwiftLanguageService { package func codeLens(_ req: CodeLensRequest) async throws -> [CodeLens] { let snapshot = try documentManager.latestSnapshot(req.textDocument.uri) - return await SwiftCodeLensScanner.findCodeLenses(in: snapshot, syntaxTreeManager: self.syntaxTreeManager) + return await SwiftCodeLensScanner.findCodeLenses( + in: snapshot, + syntaxTreeManager: self.syntaxTreeManager, + supportedCommands: self.capabilityRegistry.supportedCodeLensCommands + ) } package func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport { diff --git a/Tests/SourceKitLSPTests/CodeLensTests.swift b/Tests/SourceKitLSPTests/CodeLensTests.swift index 675d6055c..c25ac7b2a 100644 --- a/Tests/SourceKitLSPTests/CodeLensTests.swift +++ b/Tests/SourceKitLSPTests/CodeLensTests.swift @@ -17,6 +17,13 @@ import XCTest final class CodeLensTests: XCTestCase { func testNoLenses() async throws { + var codeLensCapabilities = TextDocumentClientCapabilities.CodeLens() + codeLensCapabilities.supportedCommands = [ + "swift.run": "swift.run", + "swift.debug": "swift.debug" + ] + let capabilities = ClientCapabilities(textDocument: TextDocumentClientCapabilities(codeLens: codeLensCapabilities)) + let project = try await SwiftPMTestProject( files: [ "Test.swift": """ @@ -24,8 +31,30 @@ final class CodeLensTests: XCTestCase { public static func main() {} } """ + ], + capabilities: capabilities + ) + let (uri, _) = try project.openDocument("Test.swift") + + let response = try await project.testClient.send( + CodeLensRequest(textDocument: TextDocumentIdentifier(uri)) + ) + + XCTAssertEqual(response, []) + } + + func testNoClientCodeLenses() async throws { + let project = try await SwiftPMTestProject( + files: [ + "Test.swift": """ + @main + struct MyApp { + public static func main() {} + } + """ ] ) + let (uri, _) = try project.openDocument("Test.swift") let response = try await project.testClient.send( @@ -36,6 +65,13 @@ final class CodeLensTests: XCTestCase { } func testSuccessfulCodeLensRequest() async throws { + var codeLensCapabilities = TextDocumentClientCapabilities.CodeLens() + codeLensCapabilities.supportedCommands = [ + "swift.run": "swift.run", + "swift.debug": "swift.debug" + ] + let capabilities = ClientCapabilities(textDocument: TextDocumentClientCapabilities(codeLens: codeLensCapabilities)) + let project = try await SwiftPMTestProject( files: [ "Test.swift": """ @@ -44,7 +80,8 @@ final class CodeLensTests: XCTestCase { public static func main() {} } """ - ] + ], + capabilities: capabilities ) let (uri, positions) = try project.openDocument("Test.swift") From 960317b6cc9981f3e38f9818e32f48d9484ce710 Mon Sep 17 00:00:00 2001 From: Paul LeMarquand Date: Thu, 18 Jul 2024 08:34:22 -0400 Subject: [PATCH 3/3] Enumerate supported code lens commands --- Sources/LanguageServerProtocol/CMakeLists.txt | 1 + .../SupportTypes/ClientCapabilities.swift | 22 +++++--------- .../SupportedCodeLensCommand.swift | 29 +++++++++++++++++++ Sources/SourceKitLSP/CapabilityRegistry.swift | 5 +--- Sources/SourceKitLSP/SourceKitLSPServer.swift | 10 +++---- .../Swift/SwiftCodeLensScanner.swift | 10 +++---- Tests/SourceKitLSPTests/CodeLensTests.swift | 8 ++--- 7 files changed, 53 insertions(+), 32 deletions(-) create mode 100644 Sources/LanguageServerProtocol/SupportTypes/SupportedCodeLensCommand.swift diff --git a/Sources/LanguageServerProtocol/CMakeLists.txt b/Sources/LanguageServerProtocol/CMakeLists.txt index a95d3a309..200d024e9 100644 --- a/Sources/LanguageServerProtocol/CMakeLists.txt +++ b/Sources/LanguageServerProtocol/CMakeLists.txt @@ -125,6 +125,7 @@ add_library(LanguageServerProtocol STATIC SupportTypes/SemanticTokenTypes.swift SupportTypes/ServerCapabilities.swift SupportTypes/StringOrMarkupContent.swift + SupportTypes/SupportedCodeLensCommand.swift SupportTypes/SymbolKind.swift SupportTypes/TestItem.swift SupportTypes/TextDocumentContentChangeEvent.swift diff --git a/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift b/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift index 47ae64db6..e9d676470 100644 --- a/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift +++ b/Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift @@ -470,24 +470,18 @@ public struct TextDocumentClientCapabilities: Hashable, Codable, Sendable { /// Whether the client supports dynamic registration of this request. public var dynamicRegistration: Bool? - public var supportedCommands: [String: String] + /// Dictionary of supported commands announced by the client. + /// The key is the CodeLens name recognized by SourceKit-LSP and the + /// value is the command as recognized by the client. + public var supportedCommands: [SupportedCodeLensCommand: String]? - public init(dynamicRegistration: Bool? = nil, supportedCommands: [String: String] = [:]) { + public init( + dynamicRegistration: Bool? = nil, + supportedCommands: [SupportedCodeLensCommand: String] = [:] + ) { self.dynamicRegistration = dynamicRegistration self.supportedCommands = supportedCommands } - - public init(from decoder: any Decoder) throws { - let registration = try DynamicRegistrationCapability(from: decoder) - self = CodeLens( - dynamicRegistration: registration.dynamicRegistration - ) - } - - public func encode(to encoder: any Encoder) throws { - let registration = DynamicRegistrationCapability(dynamicRegistration: self.dynamicRegistration) - try registration.encode(to: encoder) - } } /// Capabilities specific to `textDocument/rename`. diff --git a/Sources/LanguageServerProtocol/SupportTypes/SupportedCodeLensCommand.swift b/Sources/LanguageServerProtocol/SupportTypes/SupportedCodeLensCommand.swift new file mode 100644 index 000000000..9f4248055 --- /dev/null +++ b/Sources/LanguageServerProtocol/SupportTypes/SupportedCodeLensCommand.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Code lenses that LSP can annotate code with. +/// +/// Clients provide these as keys to the `supportedCommands` dictionary supplied +/// in the client's `InitializeRequest`. +public struct SupportedCodeLensCommand: Codable, Hashable, RawRepresentable, Sendable { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + /// Lens to run the application + public static let run: Self = Self(rawValue: "swift.run") + + /// Lens to debug the application + public static let debug: Self = Self(rawValue: "swift.debug") +} diff --git a/Sources/SourceKitLSP/CapabilityRegistry.swift b/Sources/SourceKitLSP/CapabilityRegistry.swift index 3f8a1c651..9c9823e00 100644 --- a/Sources/SourceKitLSP/CapabilityRegistry.swift +++ b/Sources/SourceKitLSP/CapabilityRegistry.swift @@ -38,9 +38,6 @@ package final actor CapabilityRegistry { /// Dynamically registered pull diagnostics options. private var pullDiagnostics: [CapabilityRegistration: DiagnosticRegistrationOptions] = [:] - /// Dynamically registered code lens options. - private var codeLens: [CapabilityRegistration: CodeLensRegistrationOptions] = [:] - /// Dynamically registered file watchers. private var didChangeWatchedFiles: DidChangeWatchedFilesRegistrationOptions? @@ -83,7 +80,7 @@ package final actor CapabilityRegistry { clientCapabilities.textDocument?.publishDiagnostics?.codeDescriptionSupport == true } - public var supportedCodeLensCommands: [String: String] { + public var supportedCodeLensCommands: [SupportedCodeLensCommand: String] { clientCapabilities.textDocument?.codeLens?.supportedCommands ?? [:] } diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index de999bdbc..b784cc025 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -979,14 +979,14 @@ extension SourceKitLSPServer { case let .dictionary(codeLensConfig) = codeLens, case let .dictionary(supportedCommands) = codeLensConfig["supportedCommands"] { - let commandMap = supportedCommands.compactMapValues({ - if case let .string(val) = $0 { - return val + let commandMap = supportedCommands.compactMap { (key, value) in + if case let .string(clientCommand) = value { + return (SupportedCodeLensCommand(rawValue: key), clientCommand) } return nil - }) + } - clientCapabilities.textDocument?.codeLens?.supportedCommands = commandMap + clientCapabilities.textDocument?.codeLens?.supportedCommands = Dictionary(uniqueKeysWithValues: commandMap) } } diff --git a/Sources/SourceKitLSP/Swift/SwiftCodeLensScanner.swift b/Sources/SourceKitLSP/Swift/SwiftCodeLensScanner.swift index cf810a90a..36613b94f 100644 --- a/Sources/SourceKitLSP/Swift/SwiftCodeLensScanner.swift +++ b/Sources/SourceKitLSP/Swift/SwiftCodeLensScanner.swift @@ -22,9 +22,9 @@ final class SwiftCodeLensScanner: SyntaxVisitor { private var result: [CodeLens] = [] /// The map of supported commands and their client side command names - private let supportedCommands: [String: String] + private let supportedCommands: [SupportedCodeLensCommand: String] - private init(snapshot: DocumentSnapshot, supportedCommands: [String: String]) { + private init(snapshot: DocumentSnapshot, supportedCommands: [SupportedCodeLensCommand: String]) { self.snapshot = snapshot self.supportedCommands = supportedCommands super.init(viewMode: .fixedUp) @@ -35,7 +35,7 @@ final class SwiftCodeLensScanner: SyntaxVisitor { public static func findCodeLenses( in snapshot: DocumentSnapshot, syntaxTreeManager: SyntaxTreeManager, - supportedCommands: [String: String] + supportedCommands: [SupportedCodeLensCommand: String] ) async -> [CodeLens] { guard snapshot.text.contains("@main") && !supportedCommands.isEmpty else { // This is intended to filter out files that obviously do not contain an entry point. @@ -62,7 +62,7 @@ final class SwiftCodeLensScanner: SyntaxVisitor { if attribute.trimmedDescription == "@main" { let range = self.snapshot.absolutePositionRange(of: attribute.trimmedRange) - if let runCommand = supportedCommands["swift.run"] { + if let runCommand = supportedCommands[SupportedCodeLensCommand.run] { // Return commands for running/debugging the executable. // These command names must be recognized by the client and so should not be chosen arbitrarily. self.result.append( @@ -73,7 +73,7 @@ final class SwiftCodeLensScanner: SyntaxVisitor { ) } - if let debugCommand = supportedCommands["swift.debug"] { + if let debugCommand = supportedCommands[SupportedCodeLensCommand.debug] { self.result.append( CodeLens( range: range, diff --git a/Tests/SourceKitLSPTests/CodeLensTests.swift b/Tests/SourceKitLSPTests/CodeLensTests.swift index c25ac7b2a..9d95ad63f 100644 --- a/Tests/SourceKitLSPTests/CodeLensTests.swift +++ b/Tests/SourceKitLSPTests/CodeLensTests.swift @@ -19,8 +19,8 @@ final class CodeLensTests: XCTestCase { func testNoLenses() async throws { var codeLensCapabilities = TextDocumentClientCapabilities.CodeLens() codeLensCapabilities.supportedCommands = [ - "swift.run": "swift.run", - "swift.debug": "swift.debug" + SupportedCodeLensCommand.run: "swift.run", + SupportedCodeLensCommand.debug: "swift.debug", ] let capabilities = ClientCapabilities(textDocument: TextDocumentClientCapabilities(codeLens: codeLensCapabilities)) @@ -67,8 +67,8 @@ final class CodeLensTests: XCTestCase { func testSuccessfulCodeLensRequest() async throws { var codeLensCapabilities = TextDocumentClientCapabilities.CodeLens() codeLensCapabilities.supportedCommands = [ - "swift.run": "swift.run", - "swift.debug": "swift.debug" + SupportedCodeLensCommand.run: "swift.run", + SupportedCodeLensCommand.debug: "swift.debug", ] let capabilities = ClientCapabilities(textDocument: TextDocumentClientCapabilities(codeLens: codeLensCapabilities))