Skip to content

Commit 65325ef

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 007d2cf commit 65325ef

File tree

9 files changed

+247
-2
lines changed

9 files changed

+247
-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
@@ -56,6 +56,7 @@ target_sources(SourceKitLSP PRIVATE
5656
Swift/SemanticRefactoring.swift
5757
Swift/SemanticTokens.swift
5858
Swift/SourceKitD+ResponseError.swift
59+
Swift/SwiftCodeLensScanner.swift
5960
Swift/SwiftCommand.swift
6061
Swift/SwiftLanguageService.swift
6162
Swift/SwiftTestingScanner.swift

Sources/SourceKitLSP/CapabilityRegistry.swift

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ public 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

@@ -66,6 +69,10 @@ public final actor CapabilityRegistry {
6669
clientCapabilities.textDocument?.diagnostic?.dynamicRegistration == true
6770
}
6871

72+
public var clientHasDynamicDocumentCodeLensRegistration: Bool {
73+
clientCapabilities.textDocument?.codeLens?.dynamicRegistration == true
74+
}
75+
6976
public var clientHasDynamicExecuteCommandRegistration: Bool {
7077
clientCapabilities.workspace?.executeCommand?.dynamicRegistration == true
7178
}
@@ -279,6 +286,7 @@ public final actor CapabilityRegistry {
279286
server: SourceKitLSPServer
280287
) async {
281288
guard clientHasDynamicInlayHintRegistration else { return }
289+
282290
await registerLanguageSpecificCapability(
283291
options: InlayHintRegistrationOptions(
284292
documentSelector: DocumentSelector(for: languages),
@@ -314,6 +322,29 @@ public final actor CapabilityRegistry {
314322
)
315323
}
316324

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

347378
fileprivate extension DocumentSelector {
348-
init(for languages: [Language]) {
349-
self.init(languages.map { DocumentFilter(language: $0.rawValue) })
379+
init(for languages: [Language], scheme: String? = nil) {
380+
self.init(languages.map { DocumentFilter(language: $0.rawValue, scheme: scheme) })
350381
}
351382
}

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 @@ public 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
@@ -701,6 +701,8 @@ extension SourceKitLSPServer: MessageHandler {
701701
await self.handleRequest(for: request, requestHandler: self.prepareCallHierarchy)
702702
case let request as RequestAndReply<CodeActionRequest>:
703703
await self.handleRequest(for: request, requestHandler: self.codeAction)
704+
case let request as RequestAndReply<CodeLensRequest>:
705+
await self.handleRequest(for: request, requestHandler: self.codeLens)
704706
case let request as RequestAndReply<ColorPresentationRequest>:
705707
await self.handleRequest(for: request, requestHandler: self.colorPresentation)
706708
case let request as RequestAndReply<CompletionRequest>:
@@ -1101,6 +1103,7 @@ extension SourceKitLSPServer {
11011103
supportsCodeActions: true
11021104
)
11031105
),
1106+
codeLensProvider: CodeLensOptions(resolveProvider: false),
11041107
documentFormattingProvider: .value(DocumentFormattingOptions(workDoneProgress: false)),
11051108
renameProvider: .value(RenameOptions(prepareProvider: true)),
11061109
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+
public 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
public 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)