Skip to content

Commit ff218ca

Browse files
committed
textDocument/playgrounds request
- New request that is tied to 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 - PlaygroundItem 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" ``` Issue: #2339
1 parent f384851 commit ff218ca

File tree

9 files changed

+351
-0
lines changed

9 files changed

+351
-0
lines changed

Sources/ClangLanguageService/ClangLanguageService.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,10 @@ extension ClangLanguageService {
647647
return []
648648
}
649649

650+
package func syntacticDocumentPlaygrounds(for uri: DocumentURI, in workspace: Workspace) async throws -> [PlaygroundItem] {
651+
return []
652+
}
653+
650654
package func editsToRename(
651655
locations renameLocations: [RenameLocation],
652656
in snapshot: DocumentSnapshot,

Sources/LanguageServerProtocol/Messages.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public let builtinRequests: [_RequestType.Type] = [
4040
DocumentLinkRequest.self,
4141
DocumentLinkResolveRequest.self,
4242
DocumentOnTypeFormattingRequest.self,
43+
DocumentPlaygroundsRequest.self,
4344
DocumentRangeFormattingRequest.self,
4445
DocumentSemanticTokensDeltaRequest.self,
4546
DocumentSemanticTokensRangeRequest.self,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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+
/// A request that returns #Playground macro expansion locations within a file.
14+
///
15+
/// **(LSP Extension)**
16+
public struct DocumentPlaygroundsRequest: TextDocumentRequest, Hashable {
17+
public static let method: String = "textDocument/playgrounds"
18+
public typealias Response = [PlaygroundItem]
19+
20+
public var textDocument: TextDocumentIdentifier
21+
22+
public init(textDocument: TextDocumentIdentifier) {
23+
self.textDocument = textDocument
24+
}
25+
}

Sources/LanguageServerProtocol/SupportTypes/Location.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,11 @@ public struct Location: ResponseType, Hashable, Codable, CustomDebugStringConver
3737
public var debugDescription: String {
3838
return "\(uri):\(range.lowerBound)-\(range.upperBound)"
3939
}
40+
41+
public func encodeToLSPAny() -> LSPAny {
42+
return .dictionary([
43+
"uri": .string(uri.stringValue),
44+
"range": range.encodeToLSPAny()
45+
])
46+
}
4047
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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+
/// A playground item that can be used to identify playgrounds alongside a source file.
14+
public struct PlaygroundItem: ResponseType, Equatable {
15+
/// Identifier for the `PlaygroundItem`.
16+
///
17+
/// This identifier uniquely identifies the playground. It can be used to run an individual playground with `swift play`.
18+
public var id: String
19+
20+
/// Display name describing the test.
21+
public var label: String?
22+
23+
/// The range of the playground item in the source code.
24+
public var location: Location
25+
26+
public init(
27+
id: String,
28+
label: String?,
29+
location: Location,
30+
) {
31+
self.id = id
32+
self.label = label
33+
self.location = location
34+
}
35+
36+
public func encodeToLSPAny() -> LSPAny {
37+
var dict: [String: LSPAny] = [
38+
"id": .string(id),
39+
"location": location.encodeToLSPAny()
40+
]
41+
42+
if let label {
43+
dict["label"] = .string(label)
44+
}
45+
46+
return .dictionary(dict)
47+
}
48+
}

Sources/SourceKitLSP/LanguageService.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,11 @@ package protocol LanguageService: AnyObject, Sendable {
320320
/// The order of the returned tests is not defined. The results should be sorted before being returned to the editor.
321321
static func syntacticTestItems(in uri: DocumentURI) async -> [AnnotatedTestItem]
322322

323+
/// Syntactically scans the file at the given URL for #Playground macro expansions within it.
324+
///
325+
/// Does not write the results to the index.
326+
func syntacticDocumentPlaygrounds(for uri: DocumentURI, in workspace: Workspace) async throws -> [PlaygroundItem]
327+
323328
/// A position that is canonical for all positions within a declaration. For example, if we have the following
324329
/// declaration, then all `|` markers should return the same canonical position.
325330
/// ```
@@ -527,6 +532,10 @@ package extension LanguageService {
527532
throw ResponseError.internalError("syntacticDocumentTests not implemented in \(Self.self) for \(uri)")
528533
}
529534

535+
func syntacticDocumentPlaygrounds(for uri: DocumentURI, in workspace: Workspace) async throws -> [PlaygroundItem] {
536+
throw ResponseError.requestNotImplemented(DocumentOnTypeFormattingRequest.self)
537+
}
538+
530539
func canonicalDeclarationPosition(of position: Position, in uri: DocumentURI) async -> Position? {
531540
logger.error("\(#function) not implemented in \(Self.self) for \(uri)")
532541
return nil

Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,8 @@ extension SourceKitLSPServer: QueueBasedMessageHandler {
813813
await self.handleRequest(for: request, requestHandler: self.documentSymbol)
814814
case let request as RequestAndReply<DocumentTestsRequest>:
815815
await self.handleRequest(for: request, requestHandler: self.documentTests)
816+
case let request as RequestAndReply<DocumentPlaygroundsRequest>:
817+
await self.handleRequest(for: request, requestHandler: self.documentPlaygrounds)
816818
case let request as RequestAndReply<ExecuteCommandRequest>:
817819
await request.reply { try await executeCommand(request.params) }
818820
case let request as RequestAndReply<FoldingRangeRequest>:
@@ -1117,6 +1119,7 @@ extension SourceKitLSPServer {
11171119
TriggerReindexRequest.method: .dictionary(["version": .int(1)]),
11181120
GetReferenceDocumentRequest.method: .dictionary(["version": .int(1)]),
11191121
DidChangeActiveDocumentNotification.method: .dictionary(["version": .int(1)]),
1122+
DocumentPlaygroundsRequest.method: .dictionary(["version": .int(1)]),
11201123
]
11211124
for (key, value) in languageServiceRegistry.languageServices.flatMap({ $0.type.experimentalCapabilities }) {
11221125
if let existingValue = experimentalCapabilities[key] {
@@ -1817,6 +1820,14 @@ extension SourceKitLSPServer {
18171820
return try await languageService.documentOnTypeFormatting(req)
18181821
}
18191822

1823+
func documentPlaygrounds(
1824+
_ req: DocumentPlaygroundsRequest,
1825+
workspace: Workspace,
1826+
languageService: LanguageService
1827+
) async throws -> [PlaygroundItem] {
1828+
return try await languageService.syntacticDocumentPlaygrounds(for: req.textDocument.uri, in: workspace)
1829+
}
1830+
18201831
func colorPresentation(
18211832
_ req: ColorPresentationRequest,
18221833
workspace: Workspace,
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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+
import Foundation
14+
package import LanguageServerProtocol
15+
import SKLogging
16+
package import SourceKitLSP
17+
import SwiftSyntax
18+
import Playgrounds
19+
import SwiftParser
20+
internal import BuildServerIntegration
21+
22+
extension SwiftLanguageService {
23+
package func syntacticDocumentPlaygrounds(for uri: DocumentURI, in workspace: Workspace) async throws -> [PlaygroundItem] {
24+
let snapshot = try self.documentManager.latestSnapshot(uri)
25+
26+
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
27+
28+
try Task.checkCancellation()
29+
return
30+
await PlaygroundMacroFinder.find(
31+
in: [Syntax(syntaxTree)],
32+
workspace: workspace,
33+
snapshot: snapshot,
34+
range: syntaxTree.position..<syntaxTree.endPosition
35+
)
36+
}
37+
}
38+
39+
// MARK: - PlaygroundMacroFinder
40+
41+
final class PlaygroundMacroFinder: SyntaxAnyVisitor {
42+
/// The base ID to use to generate IDs for any playgrounds found in this file.
43+
private let baseID: String
44+
45+
/// The snapshot of the document for which we are getting playgrounds.
46+
private let snapshot: DocumentSnapshot
47+
48+
/// Only playgrounds that intersect with this range get reported.
49+
private let range: Range<AbsolutePosition>
50+
51+
/// Accumulating the result in here.
52+
private var result: [PlaygroundItem] = []
53+
54+
/// Keep track of if "Playgrounds" has been imported
55+
private var isPlaygroundImported: Bool = false
56+
57+
private init(baseID: String, snapshot: DocumentSnapshot, range: Range<AbsolutePosition>) {
58+
self.baseID = baseID
59+
self.snapshot = snapshot
60+
self.range = range
61+
super.init(viewMode: .sourceAccurate)
62+
}
63+
64+
/// Designated entry point for `PlaygroundMacroFinder`.
65+
static func find(
66+
in nodes: some Sequence<Syntax>,
67+
workspace: Workspace,
68+
snapshot: DocumentSnapshot,
69+
range: Range<AbsolutePosition>
70+
) async -> [PlaygroundItem] {
71+
guard let canonicalTarget = await workspace.buildServerManager.canonicalTarget(for: snapshot.uri),
72+
let moduleName = await workspace.buildServerManager.moduleName(for: snapshot.uri, in: canonicalTarget),
73+
let baseName = snapshot.uri.fileURL?.lastPathComponent
74+
else {
75+
return []
76+
}
77+
let visitor = PlaygroundMacroFinder(baseID: "\(moduleName)/\(baseName)", snapshot: snapshot, range: range)
78+
for node in nodes {
79+
visitor.walk(node)
80+
}
81+
return visitor.result
82+
}
83+
84+
/// Add a playground location with the given parameters to the `result` array.
85+
private func record(
86+
id: String,
87+
label: String?,
88+
range: Range<AbsolutePosition>
89+
) {
90+
if !self.range.overlaps(range) {
91+
return
92+
}
93+
let positionRange = snapshot.absolutePositionRange(of: range)
94+
let location = Location(uri: snapshot.uri, range: positionRange)
95+
96+
result.append(
97+
PlaygroundItem(
98+
id: id,
99+
label: label,
100+
location: location,
101+
)
102+
)
103+
}
104+
105+
override func visit(_ node: ImportPathComponentSyntax) -> SyntaxVisitorContinueKind {
106+
if node.name.text == "Playgrounds" {
107+
isPlaygroundImported = true
108+
}
109+
return .skipChildren
110+
}
111+
112+
override func visit(_ node: MacroExpansionExprSyntax) -> SyntaxVisitorContinueKind {
113+
guard isPlaygroundImported, node.macroName.text == "Playground" else {
114+
return .skipChildren
115+
}
116+
117+
let startPosition = snapshot.position(of: node.positionAfterSkippingLeadingTrivia)
118+
let stringLiteral = node.arguments.first?.expression.as(StringLiteralExprSyntax.self)
119+
let playgroundLabel = stringLiteral?.representedLiteralValue
120+
let playgroundID = "\(baseID):\(startPosition.line + 1)"
121+
122+
record(
123+
id: playgroundID,
124+
label: playgroundLabel,
125+
range: node.positionAfterSkippingLeadingTrivia..<node.endPositionBeforeTrailingTrivia
126+
)
127+
128+
return .skipChildren
129+
}
130+
}

0 commit comments

Comments
 (0)