Skip to content

Commit 293f638

Browse files
committed
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.
1 parent 8e3bb6b commit 293f638

File tree

9 files changed

+243
-2
lines changed

9 files changed

+243
-2
lines changed

Sources/LanguageServerProtocol/SupportTypes/RegistrationOptions.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,48 @@ public struct DiagnosticRegistrationOptions: RegistrationOptions, TextDocumentRe
242242
}
243243
}
244244

245+
/// Describe options to be used when registering for code lenses.
246+
public struct CodeLensRegistrationOptions: RegistrationOptions, TextDocumentRegistrationOptionsProtocol {
247+
public var textDocumentRegistrationOptions: TextDocumentRegistrationOptions
248+
public var codeLensOptions: CodeLensOptions
249+
250+
public init(
251+
documentSelector: DocumentSelector? = nil,
252+
codeLensOptions: CodeLensOptions
253+
) {
254+
textDocumentRegistrationOptions = TextDocumentRegistrationOptions(documentSelector: documentSelector)
255+
self.codeLensOptions = codeLensOptions
256+
}
257+
258+
public init?(fromLSPDictionary dictionary: [String: LSPAny]) {
259+
self.codeLensOptions = CodeLensOptions()
260+
261+
if case .bool(let resolveProvider) = dictionary["resolveProvider"] {
262+
self.codeLensOptions.resolveProvider = resolveProvider
263+
}
264+
265+
guard let textDocumentRegistrationOptions = TextDocumentRegistrationOptions(fromLSPDictionary: dictionary) else {
266+
return nil
267+
}
268+
269+
self.textDocumentRegistrationOptions = textDocumentRegistrationOptions
270+
}
271+
272+
public func encodeToLSPAny() -> LSPAny {
273+
var dict: [String: LSPAny] = [:]
274+
275+
if let resolveProvider = codeLensOptions.resolveProvider {
276+
dict["resolveProvider"] = .bool(resolveProvider)
277+
}
278+
279+
if case .dictionary(let dictionary) = textDocumentRegistrationOptions.encodeToLSPAny() {
280+
dict.merge(dictionary) { (current, _) in current }
281+
}
282+
283+
return .dictionary(dict)
284+
}
285+
}
286+
245287
/// Describe options to be used when registering for file system change events.
246288
public struct DidChangeWatchedFilesRegistrationOptions: RegistrationOptions {
247289
/// The watchers to register.

Sources/SourceKitLSP/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ target_sources(SourceKitLSP PRIVATE
5757
Swift/SemanticRefactoring.swift
5858
Swift/SemanticTokens.swift
5959
Swift/SourceKitD+ResponseError.swift
60+
Swift/SwiftCodeLensScanner.swift
6061
Swift/SwiftCommand.swift
6162
Swift/SwiftLanguageService.swift
6263
Swift/SwiftTestingScanner.swift

Sources/SourceKitLSP/CapabilityRegistry.swift

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ package final actor CapabilityRegistry {
3838
/// Dynamically registered pull diagnostics options.
3939
private var pullDiagnostics: [CapabilityRegistration: DiagnosticRegistrationOptions] = [:]
4040

41+
/// Dynamically registered code lens options.
42+
private var codeLens: [CapabilityRegistration: CodeLensRegistrationOptions] = [:]
43+
4144
/// Dynamically registered file watchers.
4245
private var didChangeWatchedFiles: DidChangeWatchedFilesRegistrationOptions?
4346

@@ -279,6 +282,7 @@ package final actor CapabilityRegistry {
279282
server: SourceKitLSPServer
280283
) async {
281284
guard clientHasDynamicInlayHintRegistration else { return }
285+
282286
await registerLanguageSpecificCapability(
283287
options: InlayHintRegistrationOptions(
284288
documentSelector: DocumentSelector(for: languages),
@@ -314,6 +318,29 @@ package final actor CapabilityRegistry {
314318
)
315319
}
316320

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+
317344
/// Dynamically register executeCommand with the given IDs if the client supports
318345
/// it and we haven't yet registered the given command IDs yet.
319346
package func registerExecuteCommandIfNeeded(
@@ -345,7 +372,7 @@ package final actor CapabilityRegistry {
345372
}
346373

347374
fileprivate extension DocumentSelector {
348-
init(for languages: [Language]) {
349-
self.init(languages.map { DocumentFilter(language: $0.rawValue) })
375+
init(for languages: [Language], scheme: String? = nil) {
376+
self.init(languages.map { DocumentFilter(language: $0.rawValue, scheme: scheme) })
350377
}
351378
}

Sources/SourceKitLSP/Clang/ClangLanguageService.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,10 @@ extension ClangLanguageService {
622622
return try await forwardRequestToClangd(req)
623623
}
624624

625+
func codeLens(_ req: CodeLensRequest) async throws -> [CodeLens] {
626+
return try await forwardRequestToClangd(req) ?? []
627+
}
628+
625629
func foldingRange(_ req: FoldingRangeRequest) async throws -> [FoldingRange]? {
626630
guard self.capabilities?.foldingRangeProvider?.isSupported ?? false else {
627631
return nil

Sources/SourceKitLSP/LanguageService.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ package protocol LanguageService: AnyObject, Sendable {
197197
func colorPresentation(_ req: ColorPresentationRequest) async throws -> [ColorPresentation]
198198
func codeAction(_ req: CodeActionRequest) async throws -> CodeActionRequestResponse?
199199
func inlayHint(_ req: InlayHintRequest) async throws -> [InlayHint]
200+
func codeLens(_ req: CodeLensRequest) async throws -> [CodeLens]
200201
func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport
201202
func documentFormatting(_ req: DocumentFormattingRequest) async throws -> [TextEdit]?
202203

Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,8 @@ extension SourceKitLSPServer: MessageHandler {
700700
await self.handleRequest(for: request, requestHandler: self.prepareCallHierarchy)
701701
case let request as RequestAndReply<CodeActionRequest>:
702702
await self.handleRequest(for: request, requestHandler: self.codeAction)
703+
case let request as RequestAndReply<CodeLensRequest>:
704+
await self.handleRequest(for: request, requestHandler: self.codeLens)
703705
case let request as RequestAndReply<ColorPresentationRequest>:
704706
await self.handleRequest(for: request, requestHandler: self.colorPresentation)
705707
case let request as RequestAndReply<CompletionRequest>:
@@ -1100,6 +1102,7 @@ extension SourceKitLSPServer {
11001102
supportsCodeActions: true
11011103
)
11021104
),
1105+
codeLensProvider: CodeLensOptions(resolveProvider: false),
11031106
documentFormattingProvider: .value(DocumentFormattingOptions(workDoneProgress: false)),
11041107
renameProvider: .value(RenameOptions(prepareProvider: true)),
11051108
colorProvider: .bool(true),
@@ -1161,6 +1164,9 @@ extension SourceKitLSPServer {
11611164
if let diagnosticOptions = server.diagnosticProvider {
11621165
await registry.registerDiagnosticIfNeeded(options: diagnosticOptions, for: languages, server: self)
11631166
}
1167+
if let codeLensOptions = server.codeLensProvider {
1168+
await registry.registerCodeLensIfNeeded(options: codeLensOptions, for: languages, server: self)
1169+
}
11641170
if let commandOptions = server.executeCommandProvider {
11651171
await registry.registerExecuteCommandIfNeeded(commands: commandOptions.commands, server: self)
11661172
}
@@ -1642,6 +1648,14 @@ extension SourceKitLSPServer {
16421648
return req.injectMetadata(toResponse: response)
16431649
}
16441650

1651+
func codeLens(
1652+
_ req: CodeLensRequest,
1653+
workspace: Workspace,
1654+
languageService: LanguageService
1655+
) async throws -> [CodeLens] {
1656+
return try await languageService.codeLens(req)
1657+
}
1658+
16451659
func inlayHint(
16461660
_ req: InlayHintRequest,
16471661
workspace: Workspace,
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import LanguageServerProtocol
14+
import SwiftSyntax
15+
16+
/// Scans a source file for classes or structs annotated with `@main` and returns a code lens for them.
17+
final class SwiftCodeLensScanner: SyntaxVisitor {
18+
/// The document snapshot of the syntax tree that is being walked.
19+
private var snapshot: DocumentSnapshot
20+
21+
/// The collection of CodeLenses found in the document.
22+
private var result: [CodeLens] = []
23+
24+
private init(snapshot: DocumentSnapshot) {
25+
self.snapshot = snapshot
26+
super.init(viewMode: .fixedUp)
27+
}
28+
29+
/// Public entry point. Scans the syntax tree of the given snapshot for an `@main` annotation
30+
/// and returns CodeLens's with Commands to run/debug the application.
31+
public static func findCodeLenses(
32+
in snapshot: DocumentSnapshot,
33+
syntaxTreeManager: SyntaxTreeManager
34+
) async -> [CodeLens] {
35+
guard snapshot.text.contains("@main") else {
36+
// This is intended to filter out files that obviously do not contain an entry point.
37+
return []
38+
}
39+
40+
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
41+
let visitor = SwiftCodeLensScanner(snapshot: snapshot)
42+
visitor.walk(syntaxTree)
43+
return visitor.result
44+
}
45+
46+
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
47+
node.attributes.forEach(self.captureLensFromAttribute)
48+
return .skipChildren
49+
}
50+
51+
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
52+
node.attributes.forEach(self.captureLensFromAttribute)
53+
return .skipChildren
54+
}
55+
56+
private func captureLensFromAttribute(attribute: AttributeListSyntax.Element) {
57+
if attribute.trimmedDescription == "@main" {
58+
let range = self.snapshot.absolutePositionRange(of: attribute.trimmedRange)
59+
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)
66+
)
67+
)
68+
69+
self.result.append(
70+
CodeLens(
71+
range: range,
72+
command: Command(title: "Debug", command: "swift.debug", arguments: nil)
73+
)
74+
)
75+
}
76+
}
77+
}

Sources/SourceKitLSP/Swift/SwiftLanguageService.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,11 @@ extension SwiftLanguageService {
921921
return Array(hints)
922922
}
923923

924+
package func codeLens(_ req: CodeLensRequest) async throws -> [CodeLens] {
925+
let snapshot = try documentManager.latestSnapshot(req.textDocument.uri)
926+
return await SwiftCodeLensScanner.findCodeLenses(in: snapshot, syntaxTreeManager: self.syntaxTreeManager)
927+
}
928+
924929
package func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport {
925930
do {
926931
await semanticIndexManager?.prepareFileForEditorFunctionality(req.textDocument.uri)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import LSPTestSupport
14+
import LanguageServerProtocol
15+
import SKTestSupport
16+
import XCTest
17+
18+
final class CodeLensTests: XCTestCase {
19+
func testNoLenses() async throws {
20+
let project = try await SwiftPMTestProject(
21+
files: [
22+
"Test.swift": """
23+
struct MyApp {
24+
public static func main() {}
25+
}
26+
"""
27+
]
28+
)
29+
let (uri, _) = try project.openDocument("Test.swift")
30+
31+
let response = try await project.testClient.send(
32+
CodeLensRequest(textDocument: TextDocumentIdentifier(uri))
33+
)
34+
35+
XCTAssertEqual(response, [])
36+
}
37+
38+
func testSuccessfulCodeLensRequest() async throws {
39+
let project = try await SwiftPMTestProject(
40+
files: [
41+
"Test.swift": """
42+
1️⃣@main2️⃣
43+
struct MyApp {
44+
public static func main() {}
45+
}
46+
"""
47+
]
48+
)
49+
50+
let (uri, positions) = try project.openDocument("Test.swift")
51+
52+
let response = try await project.testClient.send(
53+
CodeLensRequest(textDocument: TextDocumentIdentifier(uri))
54+
)
55+
56+
XCTAssertEqual(
57+
response,
58+
[
59+
CodeLens(
60+
range: positions["1️⃣"]..<positions["2️⃣"],
61+
command: Command(title: "Run", command: "swift.run", arguments: nil)
62+
),
63+
CodeLens(
64+
range: positions["1️⃣"]..<positions["2️⃣"],
65+
command: Command(title: "Debug", command: "swift.debug", arguments: nil)
66+
),
67+
]
68+
)
69+
}
70+
}

0 commit comments

Comments
 (0)