Skip to content

Commit 3e8f413

Browse files
committed
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", } } } ```
1 parent 293f638 commit 3e8f413

File tree

6 files changed

+126
-56
lines changed

6 files changed

+126
-56
lines changed

Sources/LanguageServerProtocol/SupportTypes/ClientCapabilities.swift

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,31 @@ public struct TextDocumentClientCapabilities: Hashable, Codable, Sendable {
465465
}
466466
}
467467

468+
public struct CodeLens: Hashable, Codable, Sendable {
469+
470+
/// Whether the client supports dynamic registration of this request.
471+
public var dynamicRegistration: Bool?
472+
473+
public var supportedCommands: [String: String]
474+
475+
public init(dynamicRegistration: Bool? = nil, supportedCommands: [String: String] = [:]) {
476+
self.dynamicRegistration = dynamicRegistration
477+
self.supportedCommands = supportedCommands
478+
}
479+
480+
public init(from decoder: any Decoder) throws {
481+
let registration = try DynamicRegistrationCapability(from: decoder)
482+
self = CodeLens(
483+
dynamicRegistration: registration.dynamicRegistration
484+
)
485+
}
486+
487+
public func encode(to encoder: any Encoder) throws {
488+
let registration = DynamicRegistrationCapability(dynamicRegistration: self.dynamicRegistration)
489+
try registration.encode(to: encoder)
490+
}
491+
}
492+
468493
/// Capabilities specific to `textDocument/rename`.
469494
public struct Rename: Hashable, Codable, Sendable {
470495

@@ -666,7 +691,7 @@ public struct TextDocumentClientCapabilities: Hashable, Codable, Sendable {
666691

667692
public var codeAction: CodeAction? = nil
668693

669-
public var codeLens: DynamicRegistrationCapability? = nil
694+
public var codeLens: CodeLens? = nil
670695

671696
public var documentLink: DynamicRegistrationCapability? = nil
672697

@@ -715,7 +740,7 @@ public struct TextDocumentClientCapabilities: Hashable, Codable, Sendable {
715740
documentHighlight: DynamicRegistrationCapability? = nil,
716741
documentSymbol: DocumentSymbol? = nil,
717742
codeAction: CodeAction? = nil,
718-
codeLens: DynamicRegistrationCapability? = nil,
743+
codeLens: CodeLens? = nil,
719744
documentLink: DynamicRegistrationCapability? = nil,
720745
colorProvider: DynamicRegistrationCapability? = nil,
721746
formatting: DynamicRegistrationCapability? = nil,

Sources/SourceKitLSP/CapabilityRegistry.swift

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ package final actor CapabilityRegistry {
8383
clientCapabilities.textDocument?.publishDiagnostics?.codeDescriptionSupport == true
8484
}
8585

86+
public var supportedCodeLensCommands: [String: String] {
87+
clientCapabilities.textDocument?.codeLens?.supportedCommands ?? [:]
88+
}
89+
8690
/// Since LSP 3.17.0, diagnostics can be reported through pull-based requests in addition to the existing push-based
8791
/// publish notifications.
8892
///
@@ -318,29 +322,6 @@ package final actor CapabilityRegistry {
318322
)
319323
}
320324

321-
/// Dynamically register code lens capabilities,
322-
/// if the client supports it.
323-
public func registerCodeLensIfNeeded(
324-
options: CodeLensOptions,
325-
for languages: [Language],
326-
server: SourceKitLSPServer
327-
) async {
328-
guard clientHasDynamicDocumentCodeLensRegistration else { return }
329-
330-
await registerLanguageSpecificCapability(
331-
options: CodeLensRegistrationOptions(
332-
// Code lenses should only apply to saved files
333-
documentSelector: DocumentSelector(for: languages, scheme: "file"),
334-
codeLensOptions: options
335-
),
336-
forMethod: CodeLensRequest.method,
337-
languages: languages,
338-
in: server,
339-
registrationDict: codeLens,
340-
setRegistrationDict: { codeLens[$0] = $1 }
341-
)
342-
}
343-
344325
/// Dynamically register executeCommand with the given IDs if the client supports
345326
/// it and we haven't yet registered the given command IDs yet.
346327
package func registerExecuteCommandIfNeeded(

Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -963,14 +963,30 @@ extension SourceKitLSPServer {
963963
// The below is a workaround for the vscode-swift extension since it cannot set client capabilities.
964964
// It passes "workspace/peekDocuments" through the `initializationOptions`.
965965
var clientCapabilities = req.capabilities
966-
if case .dictionary(let initializationOptions) = req.initializationOptions,
967-
let peekDocuments = initializationOptions["workspace/peekDocuments"]
968-
{
969-
if case .dictionary(var experimentalCapabilities) = clientCapabilities.experimental {
970-
experimentalCapabilities["workspace/peekDocuments"] = peekDocuments
971-
clientCapabilities.experimental = .dictionary(experimentalCapabilities)
972-
} else {
973-
clientCapabilities.experimental = .dictionary(["workspace/peekDocuments": peekDocuments])
966+
if case .dictionary(let initializationOptions) = req.initializationOptions {
967+
if let peekDocuments = initializationOptions["workspace/peekDocuments"] {
968+
if case .dictionary(var experimentalCapabilities) = clientCapabilities.experimental {
969+
experimentalCapabilities["workspace/peekDocuments"] = peekDocuments
970+
clientCapabilities.experimental = .dictionary(experimentalCapabilities)
971+
} else {
972+
clientCapabilities.experimental = .dictionary(["workspace/peekDocuments": peekDocuments])
973+
}
974+
}
975+
976+
// The client announces what CodeLenses it supports, and the LSP will only return
977+
// ones found in the supportedCommands dictionary.
978+
if let codeLens = initializationOptions["textDocument/codeLens"],
979+
case let .dictionary(codeLensConfig) = codeLens,
980+
case let .dictionary(supportedCommands) = codeLensConfig["supportedCommands"]
981+
{
982+
let commandMap = supportedCommands.compactMapValues({
983+
if case let .string(val) = $0 {
984+
return val
985+
}
986+
return nil
987+
})
988+
989+
clientCapabilities.textDocument?.codeLens?.supportedCommands = commandMap
974990
}
975991
}
976992

@@ -1102,7 +1118,7 @@ extension SourceKitLSPServer {
11021118
supportsCodeActions: true
11031119
)
11041120
),
1105-
codeLensProvider: CodeLensOptions(resolveProvider: false),
1121+
codeLensProvider: CodeLensOptions(),
11061122
documentFormattingProvider: .value(DocumentFormattingOptions(workDoneProgress: false)),
11071123
renameProvider: .value(RenameOptions(prepareProvider: true)),
11081124
colorProvider: .bool(true),
@@ -1164,9 +1180,6 @@ extension SourceKitLSPServer {
11641180
if let diagnosticOptions = server.diagnosticProvider {
11651181
await registry.registerDiagnosticIfNeeded(options: diagnosticOptions, for: languages, server: self)
11661182
}
1167-
if let codeLensOptions = server.codeLensProvider {
1168-
await registry.registerCodeLensIfNeeded(options: codeLensOptions, for: languages, server: self)
1169-
}
11701183
if let commandOptions = server.executeCommandProvider {
11711184
await registry.registerExecuteCommandIfNeeded(commands: commandOptions.commands, server: self)
11721185
}

Sources/SourceKitLSP/Swift/SwiftCodeLensScanner.swift

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,29 +16,34 @@ import SwiftSyntax
1616
/// Scans a source file for classes or structs annotated with `@main` and returns a code lens for them.
1717
final class SwiftCodeLensScanner: SyntaxVisitor {
1818
/// The document snapshot of the syntax tree that is being walked.
19-
private var snapshot: DocumentSnapshot
19+
private let snapshot: DocumentSnapshot
2020

2121
/// The collection of CodeLenses found in the document.
2222
private var result: [CodeLens] = []
2323

24-
private init(snapshot: DocumentSnapshot) {
24+
/// The map of supported commands and their client side command names
25+
private let supportedCommands: [String: String]
26+
27+
private init(snapshot: DocumentSnapshot, supportedCommands: [String: String]) {
2528
self.snapshot = snapshot
29+
self.supportedCommands = supportedCommands
2630
super.init(viewMode: .fixedUp)
2731
}
2832

2933
/// Public entry point. Scans the syntax tree of the given snapshot for an `@main` annotation
3034
/// and returns CodeLens's with Commands to run/debug the application.
3135
public static func findCodeLenses(
3236
in snapshot: DocumentSnapshot,
33-
syntaxTreeManager: SyntaxTreeManager
37+
syntaxTreeManager: SyntaxTreeManager,
38+
supportedCommands: [String: String]
3439
) async -> [CodeLens] {
35-
guard snapshot.text.contains("@main") else {
40+
guard snapshot.text.contains("@main") && !supportedCommands.isEmpty else {
3641
// This is intended to filter out files that obviously do not contain an entry point.
3742
return []
3843
}
3944

4045
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
41-
let visitor = SwiftCodeLensScanner(snapshot: snapshot)
46+
let visitor = SwiftCodeLensScanner(snapshot: snapshot, supportedCommands: supportedCommands)
4247
visitor.walk(syntaxTree)
4348
return visitor.result
4449
}
@@ -57,21 +62,25 @@ final class SwiftCodeLensScanner: SyntaxVisitor {
5762
if attribute.trimmedDescription == "@main" {
5863
let range = self.snapshot.absolutePositionRange(of: attribute.trimmedRange)
5964

60-
// Return commands for running/debugging the executable.
61-
// These command names must be recognized by the client and so should not be chosen arbitrarily.
62-
self.result.append(
63-
CodeLens(
64-
range: range,
65-
command: Command(title: "Run", command: "swift.run", arguments: nil)
65+
if let runCommand = supportedCommands["swift.run"] {
66+
// Return commands for running/debugging the executable.
67+
// These command names must be recognized by the client and so should not be chosen arbitrarily.
68+
self.result.append(
69+
CodeLens(
70+
range: range,
71+
command: Command(title: "Run", command: runCommand, arguments: nil)
72+
)
6673
)
67-
)
74+
}
6875

69-
self.result.append(
70-
CodeLens(
71-
range: range,
72-
command: Command(title: "Debug", command: "swift.debug", arguments: nil)
76+
if let debugCommand = supportedCommands["swift.debug"] {
77+
self.result.append(
78+
CodeLens(
79+
range: range,
80+
command: Command(title: "Debug", command: debugCommand, arguments: nil)
81+
)
7382
)
74-
)
83+
}
7584
}
7685
}
7786
}

Sources/SourceKitLSP/Swift/SwiftLanguageService.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ extension SwiftLanguageService {
317317
supportsCodeActions: true
318318
)
319319
),
320+
codeLensProvider: CodeLensOptions(),
320321
colorProvider: .bool(true),
321322
foldingRangeProvider: .bool(true),
322323
executeCommandProvider: ExecuteCommandOptions(
@@ -923,7 +924,11 @@ extension SwiftLanguageService {
923924

924925
package func codeLens(_ req: CodeLensRequest) async throws -> [CodeLens] {
925926
let snapshot = try documentManager.latestSnapshot(req.textDocument.uri)
926-
return await SwiftCodeLensScanner.findCodeLenses(in: snapshot, syntaxTreeManager: self.syntaxTreeManager)
927+
return await SwiftCodeLensScanner.findCodeLenses(
928+
in: snapshot,
929+
syntaxTreeManager: self.syntaxTreeManager,
930+
supportedCommands: self.capabilityRegistry.supportedCodeLensCommands
931+
)
927932
}
928933

929934
package func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport {

Tests/SourceKitLSPTests/CodeLensTests.swift

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,44 @@ import XCTest
1717

1818
final class CodeLensTests: XCTestCase {
1919
func testNoLenses() async throws {
20+
var codeLensCapabilities = TextDocumentClientCapabilities.CodeLens()
21+
codeLensCapabilities.supportedCommands = [
22+
"swift.run": "swift.run",
23+
"swift.debug": "swift.debug"
24+
]
25+
let capabilities = ClientCapabilities(textDocument: TextDocumentClientCapabilities(codeLens: codeLensCapabilities))
26+
2027
let project = try await SwiftPMTestProject(
2128
files: [
2229
"Test.swift": """
2330
struct MyApp {
2431
public static func main() {}
2532
}
2633
"""
34+
],
35+
capabilities: capabilities
36+
)
37+
let (uri, _) = try project.openDocument("Test.swift")
38+
39+
let response = try await project.testClient.send(
40+
CodeLensRequest(textDocument: TextDocumentIdentifier(uri))
41+
)
42+
43+
XCTAssertEqual(response, [])
44+
}
45+
46+
func testNoClientCodeLenses() async throws {
47+
let project = try await SwiftPMTestProject(
48+
files: [
49+
"Test.swift": """
50+
@main
51+
struct MyApp {
52+
public static func main() {}
53+
}
54+
"""
2755
]
2856
)
57+
2958
let (uri, _) = try project.openDocument("Test.swift")
3059

3160
let response = try await project.testClient.send(
@@ -36,6 +65,13 @@ final class CodeLensTests: XCTestCase {
3665
}
3766

3867
func testSuccessfulCodeLensRequest() async throws {
68+
var codeLensCapabilities = TextDocumentClientCapabilities.CodeLens()
69+
codeLensCapabilities.supportedCommands = [
70+
"swift.run": "swift.run",
71+
"swift.debug": "swift.debug"
72+
]
73+
let capabilities = ClientCapabilities(textDocument: TextDocumentClientCapabilities(codeLens: codeLensCapabilities))
74+
3975
let project = try await SwiftPMTestProject(
4076
files: [
4177
"Test.swift": """
@@ -44,7 +80,8 @@ final class CodeLensTests: XCTestCase {
4480
public static func main() {}
4581
}
4682
"""
47-
]
83+
],
84+
capabilities: capabilities
4885
)
4986

5087
let (uri, positions) = try project.openDocument("Test.swift")

0 commit comments

Comments
 (0)