From ddcddded67586b9e9022bcabe606bd72fca56d2b Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Wed, 29 Oct 2025 11:11:01 -0400 Subject: [PATCH] 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 Update Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift Co-authored-by: Alex Hoppen Update Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift Co-authored-by: Alex Hoppen Update Tests/SourceKitLSPTests/CodeLensTests.swift Co-authored-by: Alex Hoppen Address review comments Fix test failures Fix more review comments Update for swift-tools-core --- Contributor Documentation/LSP Extensions.md | 46 +++ .../SKTestSupport/SwiftPMTestProject.swift | 4 +- Sources/SwiftLanguageService/CMakeLists.txt | 1 + .../DocumentFormatting.swift | 3 +- .../SwiftCodeLensScanner.swift | 55 +++- .../SwiftLanguageService.swift | 17 +- .../SwiftPlaygroundsScanner.swift | 101 ++++++ Sources/ToolchainRegistry/Toolchain.swift | 17 +- Tests/SourceKitLSPTests/CodeLensTests.swift | 304 +++++++++++++++++- 9 files changed, 525 insertions(+), 23 deletions(-) create mode 100644 Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift diff --git a/Contributor Documentation/LSP Extensions.md b/Contributor Documentation/LSP Extensions.md index d99af467b..25e5a2f7a 100644 --- a/Contributor Documentation/LSP Extensions.md +++ b/Contributor Documentation/LSP Extensions.md @@ -690,6 +690,52 @@ export interface PeekDocumentsResult { } ``` +## `workspace/playgrounds` + +New request for returning the list of all #Playground macros in the workspace. + +Primarily designed to allow editors to provide a list of available playgrounds in the project workspace and allow +jumping to the locations where the #Playground macro was expanded. + +The request fetches the list of all macros found in the workspace, returning the location, identifier, and optional label +when available for each #Playground macro expansion. If you want to keep the list of playgrounds up to date without needing to +call `workspace/playgrounds` each time a document is changed, you can filter for `swift.play` CodeLens returned by the `textDocument/codelens` request. + +SourceKit-LSP will advertise `workspace/playgrounds` in its experimental server capabilities if it supports it. + +- params: `WorkspacePlaygroundParams` +- result: `Playground[]` + +```ts +export interface WorkspacePlaygroundParams {} + +/** + * A `Playground` represents a usage of the #Playground macro, providing the editor with the + * location of the playground and identifiers to allow executing the playground through a "swift play" command. + */ +export interface Playground { + /** + * Unique identifier for the `Playground`. Client can run the playground by executing `swift play `. + * + * This property is always present whether the `Playground` has a `label` or not. + * + * Follows the format output by `swift play --list`. + */ + id: string; + + /** + * The label that can be used as a display name for the playground. This optional property is only available + * for named playgrounds. For example: `#Playground("hello") { print("Hello!) }` would have a `label` of `"hello"`. + */ + label?: string + + /** + * The location of where the #Playground macro was used in the source code. + */ + location: Location +} +``` + ## `workspace/synchronize` 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. diff --git a/Sources/SKTestSupport/SwiftPMTestProject.swift b/Sources/SKTestSupport/SwiftPMTestProject.swift index 1883ce28f..3d9135100 100644 --- a/Sources/SKTestSupport/SwiftPMTestProject.swift +++ b/Sources/SKTestSupport/SwiftPMTestProject.swift @@ -17,7 +17,7 @@ package import SKOptions package import SourceKitLSP import SwiftExtensions import TSCBasic -import ToolchainRegistry +package import ToolchainRegistry @_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions import XCTest @@ -184,6 +184,7 @@ package class SwiftPMTestProject: MultiFileTestProject { initializationOptions: LSPAny? = nil, capabilities: ClientCapabilities = ClientCapabilities(), options: SourceKitLSPOptions? = nil, + toolchainRegistry: ToolchainRegistry = .forTesting, hooks: Hooks = Hooks(), enableBackgroundIndexing: Bool = false, usePullDiagnostics: Bool = true, @@ -225,6 +226,7 @@ package class SwiftPMTestProject: MultiFileTestProject { initializationOptions: initializationOptions, capabilities: capabilities, options: options, + toolchainRegistry: toolchainRegistry, hooks: hooks, enableBackgroundIndexing: enableBackgroundIndexing, usePullDiagnostics: usePullDiagnostics, diff --git a/Sources/SwiftLanguageService/CMakeLists.txt b/Sources/SwiftLanguageService/CMakeLists.txt index 022405bc7..cbdcdc6e8 100644 --- a/Sources/SwiftLanguageService/CMakeLists.txt +++ b/Sources/SwiftLanguageService/CMakeLists.txt @@ -25,6 +25,7 @@ add_library(SwiftLanguageService STATIC InlayHints.swift MacroExpansion.swift OpenInterface.swift + PlaygroundDiscovery.swift RefactoringEdit.swift RefactoringResponse.swift RelatedIdentifiers.swift diff --git a/Sources/SwiftLanguageService/DocumentFormatting.swift b/Sources/SwiftLanguageService/DocumentFormatting.swift index 4ede6c313..fbccc6da8 100644 --- a/Sources/SwiftLanguageService/DocumentFormatting.swift +++ b/Sources/SwiftLanguageService/DocumentFormatting.swift @@ -20,6 +20,7 @@ import SourceKitLSP import SwiftExtensions import SwiftParser import SwiftSyntax +import ToolchainRegistry import TSCExtensions @_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions @@ -171,7 +172,7 @@ extension SwiftLanguageService { options: FormattingOptions, range: Range? = nil ) async throws -> [TextEdit]? { - guard let swiftFormat else { + guard let swiftFormat = toolchain.swiftFormat else { throw ResponseError.unknown( "Formatting not supported because the toolchain is missing the swift-format executable" ) diff --git a/Sources/SwiftLanguageService/SwiftCodeLensScanner.swift b/Sources/SwiftLanguageService/SwiftCodeLensScanner.swift index d0a29c92b..b058a65d1 100644 --- a/Sources/SwiftLanguageService/SwiftCodeLensScanner.swift +++ b/Sources/SwiftLanguageService/SwiftCodeLensScanner.swift @@ -10,9 +10,12 @@ // //===----------------------------------------------------------------------===// +internal import BuildServerIntegration +import BuildServerProtocol @_spi(SourceKitLSP) import LanguageServerProtocol import SourceKitLSP import SwiftSyntax +import ToolchainRegistry /// Scans a source file for classes or structs annotated with `@main` and returns a code lens for them. final class SwiftCodeLensScanner: SyntaxVisitor { @@ -42,19 +45,57 @@ final class SwiftCodeLensScanner: SyntaxVisitor { /// and returns CodeLens's with Commands to run/debug the application. public static func findCodeLenses( in snapshot: DocumentSnapshot, + workspace: Workspace?, syntaxTreeManager: SyntaxTreeManager, - targetName: String? = nil, - supportedCommands: [SupportedCodeLensCommand: String] + supportedCommands: [SupportedCodeLensCommand: String], + toolchain: Toolchain ) async -> [CodeLens] { - guard snapshot.text.contains("@main") && !supportedCommands.isEmpty else { - // This is intended to filter out files that obviously do not contain an entry point. + guard !supportedCommands.isEmpty else { return [] } + var targetDisplayName: String? = nil + if let workspace, + let target = await workspace.buildServerManager.canonicalTarget(for: snapshot.uri), + let buildTarget = await workspace.buildServerManager.buildTarget(named: target) + { + targetDisplayName = buildTarget.displayName + } + + var codeLenses: [CodeLens] = [] let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) - let visitor = SwiftCodeLensScanner(snapshot: snapshot, targetName: targetName, supportedCommands: supportedCommands) - visitor.walk(syntaxTree) - return visitor.result + if snapshot.text.contains("@main") { + let visitor = SwiftCodeLensScanner( + snapshot: snapshot, + targetName: targetDisplayName, + supportedCommands: supportedCommands + ) + visitor.walk(syntaxTree) + codeLenses += visitor.result + } + + // "swift.play" CodeLens should be ignored if "swift-play" is not in the toolchain as the client has no way of running + if toolchain.swiftPlay != nil, let workspace, let playCommand = supportedCommands[SupportedCodeLensCommand.play], + snapshot.text.contains("#Playground") + { + let playgrounds = await SwiftPlaygroundsScanner.findDocumentPlaygrounds( + in: syntaxTree, + workspace: workspace, + snapshot: snapshot + ) + codeLenses += playgrounds.map({ + CodeLens( + range: $0.range, + command: Command( + title: "Play \"\($0.label ?? $0.id)\"", + command: playCommand, + arguments: [$0.encodeToLSPAny()] + ) + ) + }) + } + + return codeLenses } override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { diff --git a/Sources/SwiftLanguageService/SwiftLanguageService.swift b/Sources/SwiftLanguageService/SwiftLanguageService.swift index d76c4c697..5e647d20f 100644 --- a/Sources/SwiftLanguageService/SwiftLanguageService.swift +++ b/Sources/SwiftLanguageService/SwiftLanguageService.swift @@ -107,7 +107,7 @@ package actor SwiftLanguageService: LanguageService, Sendable { package let sourcekitd: SourceKitD /// Path to the swift-format executable if it exists in the toolchain. - let swiftFormat: URL? + let toolchain: Toolchain /// Queue on which notifications from sourcekitd are handled to ensure we are /// handling them in-order. @@ -213,7 +213,7 @@ package actor SwiftLanguageService: LanguageService, Sendable { } self.sourcekitdPath = sourcekitd self.sourceKitLSPServer = sourceKitLSPServer - self.swiftFormat = toolchain.swiftFormat + self.toolchain = toolchain let pluginPaths: PluginPaths? if let clientPlugin = options.sourcekitdOrDefault.clientPlugin, let servicePlugin = options.sourcekitdOrDefault.servicePlugin @@ -1032,18 +1032,13 @@ extension SwiftLanguageService { package func codeLens(_ req: CodeLensRequest) async throws -> [CodeLens] { let snapshot = try documentManager.latestSnapshot(req.textDocument.uri) - var targetDisplayName: String? = nil - if let workspace = await sourceKitLSPServer?.workspaceForDocument(uri: req.textDocument.uri), - let target = await workspace.buildServerManager.canonicalTarget(for: req.textDocument.uri), - let buildTarget = await workspace.buildServerManager.buildTarget(named: target) - { - targetDisplayName = buildTarget.displayName - } + let workspace = await sourceKitLSPServer?.workspaceForDocument(uri: req.textDocument.uri) return await SwiftCodeLensScanner.findCodeLenses( in: snapshot, + workspace: workspace, syntaxTreeManager: self.syntaxTreeManager, - targetName: targetDisplayName, - supportedCommands: self.capabilityRegistry.supportedCodeLensCommands + supportedCommands: self.capabilityRegistry.supportedCodeLensCommands, + toolchain: toolchain ) } diff --git a/Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift b/Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift new file mode 100644 index 000000000..274ff6e6f --- /dev/null +++ b/Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +internal import BuildServerIntegration +import Foundation +@_spi(SourceKitLSP) import LanguageServerProtocol +import SKLogging +import SourceKitLSP +import SwiftParser +import SwiftSyntax + +// MARK: - SwiftPlaygroundsScanner + +final class SwiftPlaygroundsScanner: SyntaxVisitor { + /// The base ID to use to generate IDs for any playgrounds found in this file. + private let baseID: String + + /// The snapshot of the document for which we are getting playgrounds. + private let snapshot: DocumentSnapshot + + /// Accumulating the result in here. + private var result: [TextDocumentPlayground] = [] + + /// Keep track of if "Playgrounds" has been imported + private var isPlaygroundImported: Bool = false + + private init(baseID: String, snapshot: DocumentSnapshot) { + self.baseID = baseID + self.snapshot = snapshot + super.init(viewMode: .sourceAccurate) + } + + /// Designated entry point for `SwiftPlaygroundsScanner`. + static func findDocumentPlaygrounds( + in node: some SyntaxProtocol, + workspace: Workspace, + snapshot: DocumentSnapshot + ) async -> [TextDocumentPlayground] { + guard let canonicalTarget = await workspace.buildServerManager.canonicalTarget(for: snapshot.uri), + let moduleName = await workspace.buildServerManager.moduleName(for: snapshot.uri, in: canonicalTarget), + let baseName = snapshot.uri.fileURL?.lastPathComponent + else { + return [] + } + let visitor = SwiftPlaygroundsScanner(baseID: "\(moduleName)/\(baseName)", snapshot: snapshot) + visitor.walk(node) + return visitor.isPlaygroundImported ? visitor.result : [] + } + + /// Add a playground location with the given parameters to the `result` array. + private func record( + id: String, + label: String?, + range: Range + ) { + let positionRange = snapshot.absolutePositionRange(of: range) + + result.append( + TextDocumentPlayground( + id: id, + label: label, + range: positionRange, + ) + ) + } + + override func visit(_ node: ImportPathComponentSyntax) -> SyntaxVisitorContinueKind { + if node.name.text == "Playgrounds" { + isPlaygroundImported = true + } + return .skipChildren + } + + override func visit(_ node: MacroExpansionExprSyntax) -> SyntaxVisitorContinueKind { + guard node.macroName.text == "Playground" else { + return .skipChildren + } + + let startPosition = snapshot.position(of: node.positionAfterSkippingLeadingTrivia) + let stringLiteral = node.arguments.first?.expression.as(StringLiteralExprSyntax.self) + let playgroundLabel = stringLiteral?.representedLiteralValue + let playgroundID = "\(baseID):\(startPosition.line + 1):\(startPosition.utf16index + 1)" + + record( + id: playgroundID, + label: playgroundLabel, + range: node.trimmedRange + ) + + return .skipChildren + } +} diff --git a/Sources/ToolchainRegistry/Toolchain.swift b/Sources/ToolchainRegistry/Toolchain.swift index d10bcc13a..a64021890 100644 --- a/Sources/ToolchainRegistry/Toolchain.swift +++ b/Sources/ToolchainRegistry/Toolchain.swift @@ -89,6 +89,9 @@ public final class Toolchain: Sendable { /// The path to the swift-format executable, if available. package let swiftFormat: URL? + /// The path to the swift-play executable, if available. + package let swiftPlay: URL? + /// The path to the clangd language server if available. package let clangd: URL? @@ -203,6 +206,7 @@ public final class Toolchain: Sendable { swift: URL? = nil, swiftc: URL? = nil, swiftFormat: URL? = nil, + swiftPlay: URL? = nil, clangd: URL? = nil, sourcekitd: URL? = nil, sourceKitClientPlugin: URL? = nil, @@ -216,6 +220,7 @@ public final class Toolchain: Sendable { self.swift = swift self.swiftc = swiftc self.swiftFormat = swiftFormat + self.swiftPlay = swiftPlay self.clangd = clangd self.sourcekitd = sourcekitd self.sourceKitClientPlugin = sourceKitClientPlugin @@ -240,7 +245,9 @@ public final class Toolchain: Sendable { } } return isSuperset(for: \.clang) && isSuperset(for: \.swift) && isSuperset(for: \.swiftc) - && isSuperset(for: \.clangd) && isSuperset(for: \.sourcekitd) && isSuperset(for: \.libIndexStore) + && isSuperset(for: \.swiftPlay) && isSuperset(for: \.swiftFormat) && isSuperset(for: \.sourceKitClientPlugin) + && isSuperset(for: \.sourceKitServicePlugin) && isSuperset(for: \.clangd) && isSuperset(for: \.sourcekitd) + && isSuperset(for: \.libIndexStore) } /// Same as `isSuperset` but returns `false` if both toolchains have the same set of tools. @@ -278,6 +285,7 @@ public final class Toolchain: Sendable { var swift: URL? = nil var swiftc: URL? = nil var swiftFormat: URL? = nil + var swiftPlay: URL? = nil var sourcekitd: URL? = nil var sourceKitClientPlugin: URL? = nil var sourceKitServicePlugin: URL? = nil @@ -337,6 +345,12 @@ public final class Toolchain: Sendable { foundAny = true } + let swiftPlayPath = binPath.appending(component: "swift-play\(execExt)") + if FileManager.default.isExecutableFile(atPath: swiftPlayPath.path) { + swiftPlay = swiftPlayPath + foundAny = true + } + // If 'currentPlatform' is nil it's most likely an unknown linux flavor. let dylibExtension: String if let dynamicLibraryExtension = Platform.current?.dynamicLibraryExtension { @@ -407,6 +421,7 @@ public final class Toolchain: Sendable { swift: swift, swiftc: swiftc, swiftFormat: swiftFormat, + swiftPlay: swiftPlay, clangd: clangd, sourcekitd: sourcekitd, sourceKitClientPlugin: sourceKitClientPlugin, diff --git a/Tests/SourceKitLSPTests/CodeLensTests.swift b/Tests/SourceKitLSPTests/CodeLensTests.swift index 917e82454..2d2de9dd8 100644 --- a/Tests/SourceKitLSPTests/CodeLensTests.swift +++ b/Tests/SourceKitLSPTests/CodeLensTests.swift @@ -13,9 +13,58 @@ @_spi(SourceKitLSP) import LanguageServerProtocol import SKLogging import SKTestSupport +import ToolchainRegistry import XCTest +fileprivate extension Toolchain { + #if compiler(>=6.4) + #warning( + "Once we require swift-play in the toolchain that's used to test SourceKit-LSP, we can just use `forTesting`" + ) + #endif + static var forTestingWithSwiftPlay: Toolchain { + get async throws { + let toolchain = try await unwrap(ToolchainRegistry.forTesting.default) + return Toolchain( + identifier: "\(toolchain.identifier)-swift-swift", + displayName: "\(toolchain.identifier) with swift-play", + path: toolchain.path, + clang: toolchain.clang, + swift: toolchain.swift, + swiftc: toolchain.swiftc, + swiftPlay: URL(fileURLWithPath: "/dummy/usr/bin/swift-play"), + clangd: toolchain.clangd, + sourcekitd: toolchain.sourcekitd, + sourceKitClientPlugin: toolchain.sourceKitClientPlugin, + sourceKitServicePlugin: toolchain.sourceKitServicePlugin, + libIndexStore: toolchain.libIndexStore + ) + } + } + + static var forTestingWithoutSwiftPlay: Toolchain { + get async throws { + let toolchain = try await unwrap(ToolchainRegistry.forTesting.default) + return Toolchain( + identifier: "\(toolchain.identifier)-no-swift-swift", + displayName: "\(toolchain.identifier) without swift-play", + path: toolchain.path, + clang: toolchain.clang, + swift: toolchain.swift, + swiftc: toolchain.swiftc, + swiftPlay: nil, + clangd: toolchain.clangd, + sourcekitd: toolchain.sourcekitd, + sourceKitClientPlugin: toolchain.sourceKitClientPlugin, + sourceKitServicePlugin: toolchain.sourceKitServicePlugin, + libIndexStore: toolchain.libIndexStore + ) + } + } +} + final class CodeLensTests: SourceKitLSPTestCase { + func testNoLenses() async throws { var codeLensCapabilities = TextDocumentClientCapabilities.CodeLens() codeLensCapabilities.supportedCommands = [ @@ -44,15 +93,26 @@ final class CodeLensTests: SourceKitLSPTestCase { } func testNoClientCodeLenses() async throws { + let toolchainRegistry = ToolchainRegistry(toolchains: [try await Toolchain.forTestingWithSwiftPlay]) let project = try await SwiftPMTestProject( files: [ "Test.swift": """ + import Playgrounds @main struct MyApp { public static func main() {} } + + #Playground { + print("Hello Playground!") + } + + #Playground("named") { + print("Hello named Playground!") + } """ - ] + ], + toolchainRegistry: toolchainRegistry ) let (uri, _) = try project.openDocument("Test.swift") @@ -69,16 +129,27 @@ final class CodeLensTests: SourceKitLSPTestCase { codeLensCapabilities.supportedCommands = [ SupportedCodeLensCommand.run: "swift.run", SupportedCodeLensCommand.debug: "swift.debug", + SupportedCodeLensCommand.play: "swift.play", ] let capabilities = ClientCapabilities(textDocument: TextDocumentClientCapabilities(codeLens: codeLensCapabilities)) + let toolchainRegistry = ToolchainRegistry(toolchains: [try await Toolchain.forTestingWithSwiftPlay]) let project = try await SwiftPMTestProject( files: [ "Sources/MyApp/Test.swift": """ + import Playgrounds 1️⃣@main2️⃣ struct MyApp { public static func main() {} } + + 3️⃣#Playground { + print("Hello Playground!") + }4️⃣ + + 5️⃣#Playground("named") { + print("Hello named Playground!") + }6️⃣ """ ], manifest: """ @@ -91,7 +162,159 @@ final class CodeLensTests: SourceKitLSPTestCase { targets: [.executableTarget(name: "MyApp")] ) """, - capabilities: capabilities + capabilities: capabilities, + toolchainRegistry: toolchainRegistry + ) + + let (uri, positions) = try project.openDocument("Test.swift") + + let response = try await project.testClient.send( + CodeLensRequest(textDocument: TextDocumentIdentifier(uri)) + ) + + XCTAssertEqual( + response, + [ + CodeLens( + range: positions["1️⃣"].. String { + "bar" + } + + #Playground("foo") { + print(foo()) + } + + #Playground { + print(foo()) + } + + public func bar(_ i: Int, _ j: Int) -> Int { + i + j + } + + #Playground("bar") { + var i = bar(1, 2) + i = i + 1 + print(i) + } + """ + ], + capabilities: capabilities, + toolchainRegistry: toolchainRegistry + ) + + let (uri, _) = try project.openDocument("Test.swift") + let response = try await project.testClient.send( + CodeLensRequest(textDocument: TextDocumentIdentifier(uri)) + ) + XCTAssertEqual(response, []) + } + + func testCodeLensRequestNoPlaygrounds() async throws { + var codeLensCapabilities = TextDocumentClientCapabilities.CodeLens() + codeLensCapabilities.supportedCommands = [ + SupportedCodeLensCommand.play: "swift.play" + ] + let capabilities = ClientCapabilities(textDocument: TextDocumentClientCapabilities(codeLens: codeLensCapabilities)) + let toolchainRegistry = ToolchainRegistry(toolchains: [try await Toolchain.forTestingWithSwiftPlay]) + let project = try await SwiftPMTestProject( + files: [ + "Sources/MyLibrary/Test.swift": """ + import Playgrounds + + public func Playground(_ i: Int, _ j: Int) -> Int { + i + j + } + + @Playground + struct MyPlayground { + public var playground: String = "" + } + """ + ], + capabilities: capabilities, + toolchainRegistry: toolchainRegistry + ) + + let (uri, _) = try project.openDocument("Test.swift") + let response = try await project.testClient.send( + CodeLensRequest(textDocument: TextDocumentIdentifier(uri)) + ) + XCTAssertEqual(response, []) + } }