Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions Contributor Documentation/LSP Extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`.
*
* 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.
Expand Down
4 changes: 3 additions & 1 deletion Sources/SKTestSupport/SwiftPMTestProject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -225,6 +226,7 @@ package class SwiftPMTestProject: MultiFileTestProject {
initializationOptions: initializationOptions,
capabilities: capabilities,
options: options,
toolchainRegistry: toolchainRegistry,
hooks: hooks,
enableBackgroundIndexing: enableBackgroundIndexing,
usePullDiagnostics: usePullDiagnostics,
Expand Down
1 change: 1 addition & 0 deletions Sources/SwiftLanguageService/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ add_library(SwiftLanguageService STATIC
InlayHints.swift
MacroExpansion.swift
OpenInterface.swift
PlaygroundDiscovery.swift
RefactoringEdit.swift
RefactoringResponse.swift
RelatedIdentifiers.swift
Expand Down
3 changes: 2 additions & 1 deletion Sources/SwiftLanguageService/DocumentFormatting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import SourceKitLSP
import SwiftExtensions
import SwiftParser
import SwiftSyntax
import ToolchainRegistry
import TSCExtensions
@_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions

Expand Down Expand Up @@ -171,7 +172,7 @@ extension SwiftLanguageService {
options: FormattingOptions,
range: Range<Position>? = 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"
)
Expand Down
55 changes: 48 additions & 7 deletions Sources/SwiftLanguageService/SwiftCodeLensScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 6 additions & 11 deletions Sources/SwiftLanguageService/SwiftLanguageService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}

Expand Down
101 changes: 101 additions & 0 deletions Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift
Original file line number Diff line number Diff line change
@@ -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<AbsolutePosition>
) {
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)"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know if swift play uses UTF-8 or UTF-16 based indices? You should be able to try this by putting a complicated emoji in front of #Playground such as

/* 🧑‍🧑‍🧒‍🧒 */ #Playground { print("blub") }

There are functions on DocumentSnapshot to convert to UTF-8 based columns as well if that’s what we need.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chrismiles doing something like in the example above compiles, and if it was named i.e.
/* 🧑‍🧑‍🧒‍🧒 */ #Playground("bar") { print("blub") }

then swift play bar runs fine, but using the column in the ID, ex. swift play MyPlaygroundsLibrary/MyPlaygroundsLibrary.swift:15:19, does not find the playground. So how would the column be computed for the ID with something more complicated like an emoji in the line?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bet would be that swift play is using UTF-8 based column offsets, so this would be position 15:33, I think, measured using the following. If that’s the case, you can use snapshot.sourcekitdPosition(of:) instead of snapshot.position(of:) to get the position with a UTF-8 based column.

print("/* 🧑‍🧑‍🧒‍🧒 */ ".utf8.count)
print("/* 🧑‍🧑‍🧒‍🧒 */ ".utf16.count)


record(
id: playgroundID,
label: playgroundLabel,
range: node.trimmedRange
)

return .skipChildren
}
}
17 changes: 16 additions & 1 deletion Sources/ToolchainRegistry/Toolchain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -407,6 +421,7 @@ public final class Toolchain: Sendable {
swift: swift,
swiftc: swiftc,
swiftFormat: swiftFormat,
swiftPlay: swiftPlay,
clangd: clangd,
sourcekitd: sourcekitd,
sourceKitClientPlugin: sourceKitClientPlugin,
Expand Down
Loading