Skip to content

Commit 14c649d

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 607292a commit 14c649d

File tree

9 files changed

+284
-0
lines changed

9 files changed

+284
-0
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: 23 additions & 0 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

@@ -314,6 +317,26 @@ public final actor CapabilityRegistry {
314317
)
315318
}
316319

320+
public func registerCodeLensIfNeeded(
321+
options: CodeLensOptions,
322+
for languages: [Language],
323+
server: SourceKitLSPServer
324+
) async {
325+
guard clientHasDynamicDocumentDiagnosticsRegistration else { return }
326+
327+
await registerLanguageSpecificCapability(
328+
options: CodeLensRegistrationOptions(
329+
documentSelector: DocumentSelector(for: languages),
330+
codeLensOptions: options
331+
),
332+
forMethod: CodeLensRequest.method,
333+
languages: languages,
334+
in: server,
335+
registrationDict: codeLens,
336+
setRegistrationDict: { codeLens[$0] = $1 }
337+
)
338+
}
339+
317340
/// Dynamically register executeCommand with the given IDs if the client supports
318341
/// it and we haven't yet registered the given command IDs yet.
319342
public func registerExecuteCommandIfNeeded(

Sources/SourceKitLSP/Clang/ClangLanguageService.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,10 @@ extension ClangLanguageService {
616616
return try await forwardRequestToClangd(req)
617617
}
618618

619+
func codeLens(_ req: CodeLensRequest) async throws -> [CodeLens] {
620+
return try await forwardRequestToClangd(req) ?? []
621+
}
622+
619623
func foldingRange(_ req: FoldingRangeRequest) async throws -> [FoldingRange]? {
620624
guard self.capabilities?.foldingRangeProvider?.isSupported ?? false else {
621625
return nil

Sources/SourceKitLSP/LanguageService.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ public protocol LanguageService: AnyObject, Sendable {
191191
func colorPresentation(_ req: ColorPresentationRequest) async throws -> [ColorPresentation]
192192
func codeAction(_ req: CodeActionRequest) async throws -> CodeActionRequestResponse?
193193
func inlayHint(_ req: InlayHintRequest) async throws -> [InlayHint]
194+
func codeLens(_ req: CodeLensRequest) async throws -> [CodeLens]
194195
func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport
195196
func documentFormatting(_ req: DocumentFormattingRequest) async throws -> [TextEdit]?
196197

Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,8 @@ extension SourceKitLSPServer: MessageHandler {
743743
initialized = true
744744
case let request as RequestAndReply<InlayHintRequest>:
745745
await self.handleRequest(for: request, requestHandler: self.inlayHint)
746+
case let request as RequestAndReply<CodeLensRequest>:
747+
await self.handleRequest(for: request, requestHandler: self.codeLens)
746748
case let request as RequestAndReply<PollIndexRequest>:
747749
await request.reply { try await pollIndex(request.params) }
748750
case let request as RequestAndReply<PrepareRenameRequest>:
@@ -1161,6 +1163,9 @@ extension SourceKitLSPServer {
11611163
if let diagnosticOptions = server.diagnosticProvider {
11621164
await registry.registerDiagnosticIfNeeded(options: diagnosticOptions, for: languages, server: self)
11631165
}
1166+
if let codeLensOptions = server.codeLensProvider {
1167+
await registry.registerCodeLensIfNeeded(options: codeLensOptions, for: languages, server: self)
1168+
}
11641169
if let commandOptions = server.executeCommandProvider {
11651170
await registry.registerExecuteCommandIfNeeded(commands: commandOptions.commands, server: self)
11661171
}
@@ -1622,6 +1627,14 @@ extension SourceKitLSPServer {
16221627
return try await languageService.inlayHint(req)
16231628
}
16241629

1630+
func codeLens(
1631+
_ req: CodeLensRequest,
1632+
workspace: Workspace,
1633+
languageService: LanguageService
1634+
) async throws -> [CodeLens] {
1635+
return try await languageService.codeLens(req)
1636+
}
1637+
16251638
func documentDiagnostic(
16261639
_ req: DocumentDiagnosticsRequest,
16271640
workspace: Workspace,
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
19+
/// The document snapshot of the syntax tree that is being walked.
20+
private var snapshot: DocumentSnapshot
21+
22+
/// The collection of CodeLenses found in the document.
23+
private var result: [CodeLens] = []
24+
25+
private init(snapshot: DocumentSnapshot) {
26+
self.snapshot = snapshot
27+
super.init(viewMode: .fixedUp)
28+
}
29+
30+
/// Public entry point. Scans the syntax tree of the given snapshot for an `@main` annotation
31+
/// and returns CodeLens's with Commands to run/debug the application.
32+
public static func findCodeLenses(
33+
in snapshot: DocumentSnapshot,
34+
syntaxTreeManager: SyntaxTreeManager
35+
) async -> [CodeLens] {
36+
guard snapshot.text.contains("@main") else {
37+
// This is intended to filter out files that obviously do not contain an entry point.
38+
return []
39+
}
40+
41+
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
42+
let visitor = SwiftCodeLensScanner(snapshot: snapshot)
43+
visitor.walk(syntaxTree)
44+
return visitor.result
45+
}
46+
47+
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
48+
node.attributes.forEach(self.captureLensFromAttribute)
49+
return .skipChildren
50+
}
51+
52+
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
53+
node.attributes.forEach(self.captureLensFromAttribute)
54+
return .skipChildren
55+
}
56+
57+
private func captureLensFromAttribute(attr: AttributeListSyntax.Element) {
58+
if attr.trimmedDescription == "@main" {
59+
let range = self.snapshot.absolutePositionRange(of: attr.trimmedRange)
60+
// TODO: Only provide these code lenses for `.executable` targets.
61+
// It doesn't makes sense to provide them for library/plugin targets, even if they have a `@main` attribute.
62+
// Until this is addressed `CodeLensTests.testCodeLensRequestWithInvalidProduct` will fail.
63+
self.result.append(
64+
CodeLens(
65+
range: range,
66+
command: Command(title: "Run", command: "swift.run", arguments: nil)
67+
)
68+
)
69+
70+
self.result.append(
71+
CodeLens(
72+
range: range,
73+
command: Command(title: "Debug", command: "swift.debug", arguments: nil)
74+
)
75+
)
76+
}
77+
}
78+
}

Sources/SourceKitLSP/Swift/SwiftLanguageService.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ extension SwiftLanguageService {
312312
supportsCodeActions: true
313313
)
314314
),
315+
codeLensProvider: CodeLensOptions(resolveProvider: true),
315316
colorProvider: .bool(true),
316317
foldingRangeProvider: .bool(true),
317318
executeCommandProvider: ExecuteCommandOptions(
@@ -922,6 +923,12 @@ extension SwiftLanguageService {
922923
return Array(hints)
923924
}
924925

926+
public func codeLens(_ req: CodeLensRequest) async throws -> [CodeLens] {
927+
let snapshot = try documentManager.latestSnapshot(req.textDocument.uri)
928+
let syntaxTreeManager = SyntaxTreeManager()
929+
return await SwiftCodeLensScanner.findCodeLenses(in: snapshot, syntaxTreeManager: syntaxTreeManager)
930+
}
931+
925932
public func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport {
926933
do {
927934
await semanticIndexManager?.prepareFileForEditorFunctionality(req.textDocument.uri)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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 SourceKitLSP
17+
import XCTest
18+
19+
final class CodeLensTests: XCTestCase {
20+
// MARK: - Helpers
21+
22+
func performCodeLensRequest(text: String, range: Range<Position>? = nil) async throws -> [CodeLens]? {
23+
let testClient = try await TestSourceKitLSPClient()
24+
let uri = DocumentURI(for: .swift)
25+
26+
testClient.openDocument(text, uri: uri)
27+
28+
let request = CodeLensRequest(textDocument: TextDocumentIdentifier(uri))
29+
return try await testClient.send(request)
30+
}
31+
32+
// MARK: - Tests
33+
34+
func testEmpty() async throws {
35+
let text = ""
36+
let lenses = try await performCodeLensRequest(text: text)
37+
XCTAssertEqual(lenses, [])
38+
}
39+
40+
func testSuccessfulCodeLensRequest() async throws {
41+
let packageManifest = """
42+
let package = Package(
43+
name: "MyExecutable",
44+
products: [.executable(name: "MyExecutable")],
45+
targets: [.target(name: "MyExecutable")]
46+
)
47+
"""
48+
49+
let project = try await SwiftPMTestProject(
50+
files: [:],
51+
manifest: packageManifest
52+
)
53+
let uri = DocumentURI(for: .swift)
54+
55+
let positions = project.testClient.openDocument(
56+
"""
57+
1️⃣@main2️⃣
58+
struct MyApp {
59+
public static func main() {}
60+
}
61+
""",
62+
uri: uri
63+
)
64+
65+
let response = try await project.testClient.send(
66+
CodeLensRequest(textDocument: TextDocumentIdentifier(uri))
67+
)
68+
69+
XCTAssertEqual(
70+
try XCTUnwrap(response),
71+
[
72+
CodeLens(
73+
range: positions["1️⃣"]..<positions["2️⃣"],
74+
command: Command(title: "Run", command: "swift.run", arguments: nil)
75+
),
76+
CodeLens(
77+
range: positions["1️⃣"]..<positions["2️⃣"],
78+
command: Command(title: "Debug", command: "swift.debug", arguments: nil)
79+
),
80+
]
81+
)
82+
}
83+
84+
func testCodeLensRequestWithInvalidProduct() async throws {
85+
let packageManifest = """
86+
let package = Package(
87+
name: "MyLibrary",
88+
products: [.library(name: "MyLibrary")],
89+
targets: [.target(name: "MyLibrary")]
90+
)
91+
"""
92+
93+
let project = try await SwiftPMTestProject(
94+
files: [:],
95+
manifest: packageManifest
96+
)
97+
let uri = DocumentURI(for: .swift)
98+
99+
project.testClient.openDocument(
100+
"""
101+
1️⃣@main2️⃣
102+
struct MyApp {
103+
public static func main() {}
104+
}
105+
""",
106+
uri: uri
107+
)
108+
109+
let response = try await project.testClient.send(
110+
CodeLensRequest(textDocument: TextDocumentIdentifier(uri))
111+
)
112+
113+
XCTAssertEqual(try XCTUnwrap(response), [])
114+
}
115+
}

0 commit comments

Comments
 (0)