Skip to content

Commit ddcddde

Browse files
committed
Support swift.play in textDocument/codelens request
- New `swift.play` CodeLens support that is an experimental feature while [swift play](https://github.com/apple/swift-play-experimental/) is still experimental - Add #Playground macro visitor to parse the macro expansions - File must `import Playgrounds` to record the macro expansion - The `swift-play` binary must exist in the toolchain to - TextDocumentPlayground will record the id and optionally label to match detail you get from ``` $ swift play --list Building for debugging... Found 1 Playground * Fibonacci/Fibonacci.swift:23 "Fibonacci" ``` - Add LSP extension documentation for designing pending `workspace/playground` request - Add new parsing test cases - Update CMake files Issue: #2339 #2343 Add column to unnamed label Update Sources/SwiftLanguageService/SwiftCodeLensScanner.swift Co-authored-by: Alex Hoppen <alex@alexhoppen.de> Update Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift Co-authored-by: Alex Hoppen <alex@alexhoppen.de> Update Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift Co-authored-by: Alex Hoppen <alex@alexhoppen.de> Update Tests/SourceKitLSPTests/CodeLensTests.swift Co-authored-by: Alex Hoppen <alex@alexhoppen.de> Address review comments Fix test failures Fix more review comments Update for swift-tools-core
1 parent 1dc4296 commit ddcddde

File tree

9 files changed

+525
-23
lines changed

9 files changed

+525
-23
lines changed

Contributor Documentation/LSP Extensions.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,52 @@ export interface PeekDocumentsResult {
690690
}
691691
```
692692
693+
## `workspace/playgrounds`
694+
695+
New request for returning the list of all #Playground macros in the workspace.
696+
697+
Primarily designed to allow editors to provide a list of available playgrounds in the project workspace and allow
698+
jumping to the locations where the #Playground macro was expanded.
699+
700+
The request fetches the list of all macros found in the workspace, returning the location, identifier, and optional label
701+
when available for each #Playground macro expansion. If you want to keep the list of playgrounds up to date without needing to
702+
call `workspace/playgrounds` each time a document is changed, you can filter for `swift.play` CodeLens returned by the `textDocument/codelens` request.
703+
704+
SourceKit-LSP will advertise `workspace/playgrounds` in its experimental server capabilities if it supports it.
705+
706+
- params: `WorkspacePlaygroundParams`
707+
- result: `Playground[]`
708+
709+
```ts
710+
export interface WorkspacePlaygroundParams {}
711+
712+
/**
713+
* A `Playground` represents a usage of the #Playground macro, providing the editor with the
714+
* location of the playground and identifiers to allow executing the playground through a "swift play" command.
715+
*/
716+
export interface Playground {
717+
/**
718+
* Unique identifier for the `Playground`. Client can run the playground by executing `swift play <id>`.
719+
*
720+
* This property is always present whether the `Playground` has a `label` or not.
721+
*
722+
* Follows the format output by `swift play --list`.
723+
*/
724+
id: string;
725+
726+
/**
727+
* The label that can be used as a display name for the playground. This optional property is only available
728+
* for named playgrounds. For example: `#Playground("hello") { print("Hello!) }` would have a `label` of `"hello"`.
729+
*/
730+
label?: string
731+
732+
/**
733+
* The location of where the #Playground macro was used in the source code.
734+
*/
735+
location: Location
736+
}
737+
```
738+
693739
## `workspace/synchronize`
694740
695741
Request from the client to the server to wait for SourceKit-LSP to handle all ongoing requests and, optionally, wait for background activity to finish.

Sources/SKTestSupport/SwiftPMTestProject.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ package import SKOptions
1717
package import SourceKitLSP
1818
import SwiftExtensions
1919
import TSCBasic
20-
import ToolchainRegistry
20+
package import ToolchainRegistry
2121
@_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions
2222
import XCTest
2323

@@ -184,6 +184,7 @@ package class SwiftPMTestProject: MultiFileTestProject {
184184
initializationOptions: LSPAny? = nil,
185185
capabilities: ClientCapabilities = ClientCapabilities(),
186186
options: SourceKitLSPOptions? = nil,
187+
toolchainRegistry: ToolchainRegistry = .forTesting,
187188
hooks: Hooks = Hooks(),
188189
enableBackgroundIndexing: Bool = false,
189190
usePullDiagnostics: Bool = true,
@@ -225,6 +226,7 @@ package class SwiftPMTestProject: MultiFileTestProject {
225226
initializationOptions: initializationOptions,
226227
capabilities: capabilities,
227228
options: options,
229+
toolchainRegistry: toolchainRegistry,
228230
hooks: hooks,
229231
enableBackgroundIndexing: enableBackgroundIndexing,
230232
usePullDiagnostics: usePullDiagnostics,

Sources/SwiftLanguageService/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ add_library(SwiftLanguageService STATIC
2525
InlayHints.swift
2626
MacroExpansion.swift
2727
OpenInterface.swift
28+
PlaygroundDiscovery.swift
2829
RefactoringEdit.swift
2930
RefactoringResponse.swift
3031
RelatedIdentifiers.swift

Sources/SwiftLanguageService/DocumentFormatting.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import SourceKitLSP
2020
import SwiftExtensions
2121
import SwiftParser
2222
import SwiftSyntax
23+
import ToolchainRegistry
2324
import TSCExtensions
2425
@_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions
2526

@@ -171,7 +172,7 @@ extension SwiftLanguageService {
171172
options: FormattingOptions,
172173
range: Range<Position>? = nil
173174
) async throws -> [TextEdit]? {
174-
guard let swiftFormat else {
175+
guard let swiftFormat = toolchain.swiftFormat else {
175176
throw ResponseError.unknown(
176177
"Formatting not supported because the toolchain is missing the swift-format executable"
177178
)

Sources/SwiftLanguageService/SwiftCodeLensScanner.swift

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@
1010
//
1111
//===----------------------------------------------------------------------===//
1212

13+
internal import BuildServerIntegration
14+
import BuildServerProtocol
1315
@_spi(SourceKitLSP) import LanguageServerProtocol
1416
import SourceKitLSP
1517
import SwiftSyntax
18+
import ToolchainRegistry
1619

1720
/// Scans a source file for classes or structs annotated with `@main` and returns a code lens for them.
1821
final class SwiftCodeLensScanner: SyntaxVisitor {
@@ -42,19 +45,57 @@ final class SwiftCodeLensScanner: SyntaxVisitor {
4245
/// and returns CodeLens's with Commands to run/debug the application.
4346
public static func findCodeLenses(
4447
in snapshot: DocumentSnapshot,
48+
workspace: Workspace?,
4549
syntaxTreeManager: SyntaxTreeManager,
46-
targetName: String? = nil,
47-
supportedCommands: [SupportedCodeLensCommand: String]
50+
supportedCommands: [SupportedCodeLensCommand: String],
51+
toolchain: Toolchain
4852
) async -> [CodeLens] {
49-
guard snapshot.text.contains("@main") && !supportedCommands.isEmpty else {
50-
// This is intended to filter out files that obviously do not contain an entry point.
53+
guard !supportedCommands.isEmpty else {
5154
return []
5255
}
5356

57+
var targetDisplayName: String? = nil
58+
if let workspace,
59+
let target = await workspace.buildServerManager.canonicalTarget(for: snapshot.uri),
60+
let buildTarget = await workspace.buildServerManager.buildTarget(named: target)
61+
{
62+
targetDisplayName = buildTarget.displayName
63+
}
64+
65+
var codeLenses: [CodeLens] = []
5466
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
55-
let visitor = SwiftCodeLensScanner(snapshot: snapshot, targetName: targetName, supportedCommands: supportedCommands)
56-
visitor.walk(syntaxTree)
57-
return visitor.result
67+
if snapshot.text.contains("@main") {
68+
let visitor = SwiftCodeLensScanner(
69+
snapshot: snapshot,
70+
targetName: targetDisplayName,
71+
supportedCommands: supportedCommands
72+
)
73+
visitor.walk(syntaxTree)
74+
codeLenses += visitor.result
75+
}
76+
77+
// "swift.play" CodeLens should be ignored if "swift-play" is not in the toolchain as the client has no way of running
78+
if toolchain.swiftPlay != nil, let workspace, let playCommand = supportedCommands[SupportedCodeLensCommand.play],
79+
snapshot.text.contains("#Playground")
80+
{
81+
let playgrounds = await SwiftPlaygroundsScanner.findDocumentPlaygrounds(
82+
in: syntaxTree,
83+
workspace: workspace,
84+
snapshot: snapshot
85+
)
86+
codeLenses += playgrounds.map({
87+
CodeLens(
88+
range: $0.range,
89+
command: Command(
90+
title: "Play \"\($0.label ?? $0.id)\"",
91+
command: playCommand,
92+
arguments: [$0.encodeToLSPAny()]
93+
)
94+
)
95+
})
96+
}
97+
98+
return codeLenses
5899
}
59100

60101
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {

Sources/SwiftLanguageService/SwiftLanguageService.swift

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ package actor SwiftLanguageService: LanguageService, Sendable {
107107
package let sourcekitd: SourceKitD
108108

109109
/// Path to the swift-format executable if it exists in the toolchain.
110-
let swiftFormat: URL?
110+
let toolchain: Toolchain
111111

112112
/// Queue on which notifications from sourcekitd are handled to ensure we are
113113
/// handling them in-order.
@@ -213,7 +213,7 @@ package actor SwiftLanguageService: LanguageService, Sendable {
213213
}
214214
self.sourcekitdPath = sourcekitd
215215
self.sourceKitLSPServer = sourceKitLSPServer
216-
self.swiftFormat = toolchain.swiftFormat
216+
self.toolchain = toolchain
217217
let pluginPaths: PluginPaths?
218218
if let clientPlugin = options.sourcekitdOrDefault.clientPlugin,
219219
let servicePlugin = options.sourcekitdOrDefault.servicePlugin
@@ -1032,18 +1032,13 @@ extension SwiftLanguageService {
10321032

10331033
package func codeLens(_ req: CodeLensRequest) async throws -> [CodeLens] {
10341034
let snapshot = try documentManager.latestSnapshot(req.textDocument.uri)
1035-
var targetDisplayName: String? = nil
1036-
if let workspace = await sourceKitLSPServer?.workspaceForDocument(uri: req.textDocument.uri),
1037-
let target = await workspace.buildServerManager.canonicalTarget(for: req.textDocument.uri),
1038-
let buildTarget = await workspace.buildServerManager.buildTarget(named: target)
1039-
{
1040-
targetDisplayName = buildTarget.displayName
1041-
}
1035+
let workspace = await sourceKitLSPServer?.workspaceForDocument(uri: req.textDocument.uri)
10421036
return await SwiftCodeLensScanner.findCodeLenses(
10431037
in: snapshot,
1038+
workspace: workspace,
10441039
syntaxTreeManager: self.syntaxTreeManager,
1045-
targetName: targetDisplayName,
1046-
supportedCommands: self.capabilityRegistry.supportedCodeLensCommands
1040+
supportedCommands: self.capabilityRegistry.supportedCodeLensCommands,
1041+
toolchain: toolchain
10471042
)
10481043
}
10491044

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2025 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+
internal import BuildServerIntegration
14+
import Foundation
15+
@_spi(SourceKitLSP) import LanguageServerProtocol
16+
import SKLogging
17+
import SourceKitLSP
18+
import SwiftParser
19+
import SwiftSyntax
20+
21+
// MARK: - SwiftPlaygroundsScanner
22+
23+
final class SwiftPlaygroundsScanner: SyntaxVisitor {
24+
/// The base ID to use to generate IDs for any playgrounds found in this file.
25+
private let baseID: String
26+
27+
/// The snapshot of the document for which we are getting playgrounds.
28+
private let snapshot: DocumentSnapshot
29+
30+
/// Accumulating the result in here.
31+
private var result: [TextDocumentPlayground] = []
32+
33+
/// Keep track of if "Playgrounds" has been imported
34+
private var isPlaygroundImported: Bool = false
35+
36+
private init(baseID: String, snapshot: DocumentSnapshot) {
37+
self.baseID = baseID
38+
self.snapshot = snapshot
39+
super.init(viewMode: .sourceAccurate)
40+
}
41+
42+
/// Designated entry point for `SwiftPlaygroundsScanner`.
43+
static func findDocumentPlaygrounds(
44+
in node: some SyntaxProtocol,
45+
workspace: Workspace,
46+
snapshot: DocumentSnapshot
47+
) async -> [TextDocumentPlayground] {
48+
guard let canonicalTarget = await workspace.buildServerManager.canonicalTarget(for: snapshot.uri),
49+
let moduleName = await workspace.buildServerManager.moduleName(for: snapshot.uri, in: canonicalTarget),
50+
let baseName = snapshot.uri.fileURL?.lastPathComponent
51+
else {
52+
return []
53+
}
54+
let visitor = SwiftPlaygroundsScanner(baseID: "\(moduleName)/\(baseName)", snapshot: snapshot)
55+
visitor.walk(node)
56+
return visitor.isPlaygroundImported ? visitor.result : []
57+
}
58+
59+
/// Add a playground location with the given parameters to the `result` array.
60+
private func record(
61+
id: String,
62+
label: String?,
63+
range: Range<AbsolutePosition>
64+
) {
65+
let positionRange = snapshot.absolutePositionRange(of: range)
66+
67+
result.append(
68+
TextDocumentPlayground(
69+
id: id,
70+
label: label,
71+
range: positionRange,
72+
)
73+
)
74+
}
75+
76+
override func visit(_ node: ImportPathComponentSyntax) -> SyntaxVisitorContinueKind {
77+
if node.name.text == "Playgrounds" {
78+
isPlaygroundImported = true
79+
}
80+
return .skipChildren
81+
}
82+
83+
override func visit(_ node: MacroExpansionExprSyntax) -> SyntaxVisitorContinueKind {
84+
guard node.macroName.text == "Playground" else {
85+
return .skipChildren
86+
}
87+
88+
let startPosition = snapshot.position(of: node.positionAfterSkippingLeadingTrivia)
89+
let stringLiteral = node.arguments.first?.expression.as(StringLiteralExprSyntax.self)
90+
let playgroundLabel = stringLiteral?.representedLiteralValue
91+
let playgroundID = "\(baseID):\(startPosition.line + 1):\(startPosition.utf16index + 1)"
92+
93+
record(
94+
id: playgroundID,
95+
label: playgroundLabel,
96+
range: node.trimmedRange
97+
)
98+
99+
return .skipChildren
100+
}
101+
}

Sources/ToolchainRegistry/Toolchain.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ public final class Toolchain: Sendable {
8989
/// The path to the swift-format executable, if available.
9090
package let swiftFormat: URL?
9191

92+
/// The path to the swift-play executable, if available.
93+
package let swiftPlay: URL?
94+
9295
/// The path to the clangd language server if available.
9396
package let clangd: URL?
9497

@@ -203,6 +206,7 @@ public final class Toolchain: Sendable {
203206
swift: URL? = nil,
204207
swiftc: URL? = nil,
205208
swiftFormat: URL? = nil,
209+
swiftPlay: URL? = nil,
206210
clangd: URL? = nil,
207211
sourcekitd: URL? = nil,
208212
sourceKitClientPlugin: URL? = nil,
@@ -216,6 +220,7 @@ public final class Toolchain: Sendable {
216220
self.swift = swift
217221
self.swiftc = swiftc
218222
self.swiftFormat = swiftFormat
223+
self.swiftPlay = swiftPlay
219224
self.clangd = clangd
220225
self.sourcekitd = sourcekitd
221226
self.sourceKitClientPlugin = sourceKitClientPlugin
@@ -240,7 +245,9 @@ public final class Toolchain: Sendable {
240245
}
241246
}
242247
return isSuperset(for: \.clang) && isSuperset(for: \.swift) && isSuperset(for: \.swiftc)
243-
&& isSuperset(for: \.clangd) && isSuperset(for: \.sourcekitd) && isSuperset(for: \.libIndexStore)
248+
&& isSuperset(for: \.swiftPlay) && isSuperset(for: \.swiftFormat) && isSuperset(for: \.sourceKitClientPlugin)
249+
&& isSuperset(for: \.sourceKitServicePlugin) && isSuperset(for: \.clangd) && isSuperset(for: \.sourcekitd)
250+
&& isSuperset(for: \.libIndexStore)
244251
}
245252

246253
/// Same as `isSuperset` but returns `false` if both toolchains have the same set of tools.
@@ -278,6 +285,7 @@ public final class Toolchain: Sendable {
278285
var swift: URL? = nil
279286
var swiftc: URL? = nil
280287
var swiftFormat: URL? = nil
288+
var swiftPlay: URL? = nil
281289
var sourcekitd: URL? = nil
282290
var sourceKitClientPlugin: URL? = nil
283291
var sourceKitServicePlugin: URL? = nil
@@ -337,6 +345,12 @@ public final class Toolchain: Sendable {
337345
foundAny = true
338346
}
339347

348+
let swiftPlayPath = binPath.appending(component: "swift-play\(execExt)")
349+
if FileManager.default.isExecutableFile(atPath: swiftPlayPath.path) {
350+
swiftPlay = swiftPlayPath
351+
foundAny = true
352+
}
353+
340354
// If 'currentPlatform' is nil it's most likely an unknown linux flavor.
341355
let dylibExtension: String
342356
if let dynamicLibraryExtension = Platform.current?.dynamicLibraryExtension {
@@ -407,6 +421,7 @@ public final class Toolchain: Sendable {
407421
swift: swift,
408422
swiftc: swiftc,
409423
swiftFormat: swiftFormat,
424+
swiftPlay: swiftPlay,
410425
clangd: clangd,
411426
sourcekitd: sourcekitd,
412427
sourceKitClientPlugin: sourceKitClientPlugin,

0 commit comments

Comments
 (0)