Skip to content

Add LSP extension to show Macro Expansions (or any document) in a "peeked" editor (and some minor quality improvements) #1479

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 3, 2024
Merged
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
37 changes: 37 additions & 0 deletions Documentation/LSP Extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,3 +436,40 @@ Users should not need to rely on this request. The index should always be update
```ts
export interface TriggerReindexParams {}
```

## `workspace/peekDocuments`

Request from the server to the client to show the given documents in a "peeked" editor.

This request is handled by the client to show the given documents in a "peeked" editor (i.e. inline with / inside the editor canvas).

It requires the experimental client capability `"workspace/peekDocuments"` to use.

- params: `PeekDocumentsParams`
- result: `PeekDocumentsResult`

```ts
export interface PeekDocumentsParams {
/**
* The `DocumentUri` of the text document in which to show the "peeked" editor
*/
uri: DocumentUri;

/**
* The `Position` in the given text document in which to show the "peeked editor"
*/
position: Position;

/**
* An array `DocumentUri` of the documents to appear inside the "peeked" editor
*/
locations: DocumentUri[];
}

/**
* Response to indicate the `success` of the `PeekDocumentsRequest`
*/
export interface PeekDocumentsResult {
success: boolean;
}
```
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ let package = Package(
"SwiftExtensions",
.product(name: "IndexStoreDB", package: "indexstore-db"),
.product(name: "SwiftBasicFormat", package: "swift-syntax"),
.product(name: "Crypto", package: "swift-crypto"),
.product(name: "SwiftDiagnostics", package: "swift-syntax"),
.product(name: "SwiftIDEUtils", package: "swift-syntax"),
.product(name: "SwiftParser", package: "swift-syntax"),
Expand Down
1 change: 1 addition & 0 deletions Sources/LanguageServerProtocol/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ add_library(LanguageServerProtocol STATIC
Requests/InlineValueRequest.swift
Requests/LinkedEditingRangeRequest.swift
Requests/MonikersRequest.swift
Requests/PeekDocumentsRequest.swift
Requests/PollIndexRequest.swift
Requests/PrepareRenameRequest.swift
Requests/ReferencesRequest.swift
Expand Down
1 change: 1 addition & 0 deletions Sources/LanguageServerProtocol/Messages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ public let builtinRequests: [_RequestType.Type] = [
InlineValueRequest.self,
LinkedEditingRangeRequest.self,
MonikersRequest.self,
PeekDocumentsRequest.self,
PollIndexRequest.self,
PrepareRenameRequest.self,
ReferencesRequest.self,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2024 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
//
//===----------------------------------------------------------------------===//

/// Request from the server to the client to show the given documents in a "peeked" editor **(LSP Extension)**
///
/// This request is handled by the client to show the given documents in a
/// "peeked" editor (i.e. inline with / inside the editor canvas). This is
/// similar to VS Code's built-in "editor.action.peekLocations" command.
///
/// - Parameters:
/// - uri: The DocumentURI of the text document in which to show the "peeked" editor
/// - position: The position in the given text document in which to show the "peeked editor"
/// - locations: The DocumentURIs of documents to appear inside the "peeked" editor
///
/// - Returns: `PeekDocumentsResponse` which indicates the `success` of the request.
///
/// ### LSP Extension
///
/// This request is an extension to LSP supported by SourceKit-LSP.
/// It requires the experimental client capability `"workspace/peekDocuments"` to use.
/// It also needs the client to handle the request and present the "peeked" editor.
public struct PeekDocumentsRequest: RequestType {
public static let method: String = "workspace/peekDocuments"
public typealias Response = PeekDocumentsResponse

public var uri: DocumentURI
public var position: Position
public var locations: [DocumentURI]

public init(
uri: DocumentURI,
position: Position,
locations: [DocumentURI]
) {
self.uri = uri
self.position = position
self.locations = locations
}
}

/// Response to indicate the `success` of the `PeekDocumentsRequest`
public struct PeekDocumentsResponse: ResponseType {
public var success: Bool

public init(success: Bool) {
self.success = success
}
}
32 changes: 0 additions & 32 deletions Sources/LanguageServerProtocol/SupportTypes/WorkspaceEdit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,35 +303,3 @@ public struct DeleteFile: Codable, Hashable, Sendable {
try container.encodeIfPresent(self.annotationId, forKey: .annotationId)
}
}

extension WorkspaceEdit: LSPAnyCodable {
public init?(fromLSPDictionary dictionary: [String: LSPAny]) {
guard case .dictionary(let lspDict) = dictionary[CodingKeys.changes.stringValue] else {
return nil
}
var dictionary = [DocumentURI: [TextEdit]]()
for (key, value) in lspDict {
guard
let uri = try? DocumentURI(string: key),
let edits = [TextEdit](fromLSPArray: value)
else {
return nil
}
dictionary[uri] = edits
}
self.changes = dictionary
}

public func encodeToLSPAny() -> LSPAny {
guard let changes = changes else {
return nil
}
let values = changes.map {
($0.key.stringValue, $0.value.encodeToLSPAny())
}
let dictionary = Dictionary(uniqueKeysWithValues: values)
return .dictionary([
CodingKeys.changes.stringValue: .dictionary(dictionary)
])
}
}
2 changes: 1 addition & 1 deletion Sources/SourceKitLSP/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ target_sources(SourceKitLSP PRIVATE
Swift/FoldingRange.swift
Swift/MacroExpansion.swift
Swift/OpenInterface.swift
Swift/Refactoring.swift
Swift/RefactoringResponse.swift
Swift/RefactoringEdit.swift
Swift/RefactorCommand.swift
Swift/RelatedIdentifiers.swift
Expand Down
20 changes: 19 additions & 1 deletion Sources/SourceKitLSP/SourceKitLSPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -956,7 +956,25 @@ extension SourceKitLSPServer {
}

func initialize(_ req: InitializeRequest) async throws -> InitializeResult {
capabilityRegistry = CapabilityRegistry(clientCapabilities: req.capabilities)
// If the client can handle `PeekDocumentsRequest`, they can enable the
// experimental client capability `"workspace/peekDocuments"` through the `req.capabilities.experimental`.
//
// The below is a workaround for the vscode-swift extension since it cannot set client capabilities.
// It passes "workspace/peekDocuments" through the `initializationOptions`.
var clientCapabilities = req.capabilities
if case .dictionary(let initializationOptions) = req.initializationOptions,
let peekDocuments = initializationOptions["workspace/peekDocuments"]
{
if case .dictionary(var experimentalCapabilities) = clientCapabilities.experimental {
experimentalCapabilities["workspace/peekDocuments"] = peekDocuments
clientCapabilities.experimental = .dictionary(experimentalCapabilities)
} else {
clientCapabilities.experimental = .dictionary(["workspace/peekDocuments": peekDocuments])
}
}

capabilityRegistry = CapabilityRegistry(clientCapabilities: clientCapabilities)

self.options = SourceKitLSPOptions.merging(
base: self.options,
override: orLog("Parsing SourceKitLSPOptions", { try SourceKitLSPOptions(fromLSPAny: req.initializationOptions) })
Expand Down
113 changes: 96 additions & 17 deletions Sources/SourceKitLSP/Swift/MacroExpansion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//
//===----------------------------------------------------------------------===//

import Crypto
import Foundation
import LSPLogging
import LanguageServerProtocol
Expand Down Expand Up @@ -46,17 +47,15 @@ struct MacroExpansion: RefactoringResponse {
extension SwiftLanguageService {
/// Handles the `ExpandMacroCommand`.
///
/// Makes a request to sourcekitd and wraps the result into a `MacroExpansion`
/// and then makes a `ShowDocumentRequest` to the client side for each
/// expansion to be displayed.
/// Makes a `PeekDocumentsRequest` or `ShowDocumentRequest`, containing the
/// location of each macro expansion, to the client depending on whether the
/// client supports the `experimental["workspace/peekDocuments"]` capability.
///
/// - Parameters:
/// - expandMacroCommand: The `ExpandMacroCommand` that triggered this request.
///
/// - Returns: A `[RefactoringEdit]` with the necessary edits and buffer name as a `LSPAny`
func expandMacro(
_ expandMacroCommand: ExpandMacroCommand
) async throws -> LSPAny {
) async throws {
guard let sourceKitLSPServer else {
// `SourceKitLSPServer` has been destructed. We are tearing down the
// language server. Nothing left to do.
Expand All @@ -69,6 +68,10 @@ extension SwiftLanguageService {

let expansion = try await self.refactoring(expandMacroCommand)

var completeExpansionFileContent = ""
var completeExpansionDirectoryName = ""

var macroExpansionFilePaths: [URL] = []
for macroEdit in expansion.edits {
if let bufferName = macroEdit.bufferName {
// buffer name without ".swift"
Expand All @@ -79,6 +82,9 @@ extension SwiftLanguageService {

let macroExpansionBufferDirectoryURL = self.generatedMacroExpansionsPath
.appendingPathComponent(macroExpansionBufferDirectoryName)

completeExpansionDirectoryName += "\(bufferName)-"

do {
try FileManager.default.createDirectory(
at: macroExpansionBufferDirectoryURL,
Expand All @@ -95,7 +101,7 @@ extension SwiftLanguageService {

// github permalink notation for position range
let macroExpansionPositionRangeIndicator =
"L\(macroEdit.range.lowerBound.line)C\(macroEdit.range.lowerBound.utf16index)-L\(macroEdit.range.upperBound.line)C\(macroEdit.range.upperBound.utf16index)"
"L\(macroEdit.range.lowerBound.line + 1)C\(macroEdit.range.lowerBound.utf16index + 1)-L\(macroEdit.range.upperBound.line + 1)C\(macroEdit.range.upperBound.utf16index + 1)"

let macroExpansionFilePath =
macroExpansionBufferDirectoryURL
Expand All @@ -111,22 +117,95 @@ extension SwiftLanguageService {
)
}

Task {
let req = ShowDocumentRequest(uri: DocumentURI(macroExpansionFilePath), selection: macroEdit.range)
macroExpansionFilePaths.append(macroExpansionFilePath)

let response = await orLog("Sending ShowDocumentRequest to Client") {
try await sourceKitLSPServer.sendRequestToClient(req)
}
let editContent =
"""
// \(sourceFileURL.lastPathComponent) @ \(macroEdit.range.lowerBound.line + 1):\(macroEdit.range.lowerBound.utf16index + 1) - \(macroEdit.range.upperBound.line + 1):\(macroEdit.range.upperBound.utf16index + 1)
\(macroEdit.newText)

if let response, !response.success {
logger.error("client refused to show document for \(expansion.title, privacy: .public)")
}
}

"""
completeExpansionFileContent += editContent
} else if !macroEdit.newText.isEmpty {
logger.fault("Unable to retrieve some parts of macro expansion")
}
}

return expansion.edits.encodeToLSPAny()
// removes superfluous newline
if completeExpansionFileContent.hasSuffix("\n\n") {
completeExpansionFileContent.removeLast()
}

if completeExpansionDirectoryName.hasSuffix("-") {
completeExpansionDirectoryName.removeLast()
}

var completeExpansionFilePath =
self.generatedMacroExpansionsPath.appendingPathComponent(
Insecure.MD5.hash(
data: Data(completeExpansionDirectoryName.utf8)
)
.map { String(format: "%02hhx", $0) } // maps each byte of the hash to its hex equivalent `String`
.joined()
)

do {
try FileManager.default.createDirectory(
at: completeExpansionFilePath,
withIntermediateDirectories: true
)
} catch {
throw ResponseError.unknown(
"Failed to create directory for complete macro expansion at path: \(completeExpansionFilePath.path)"
)
}

completeExpansionFilePath =
completeExpansionFilePath.appendingPathComponent(sourceFileURL.lastPathComponent)
do {
try completeExpansionFileContent.write(to: completeExpansionFilePath, atomically: true, encoding: .utf8)
} catch {
throw ResponseError.unknown(
"Unable to write complete macro expansion to file path: \"\(completeExpansionFilePath.path)\""
)
}

let completeMacroExpansionFilePath = completeExpansionFilePath
let expansionURIs = macroExpansionFilePaths.map {
return DocumentURI($0)
}

if case .dictionary(let experimentalCapabilities) = self.capabilityRegistry.clientCapabilities.experimental,
case .bool(true) = experimentalCapabilities["workspace/peekDocuments"]
{
Task {
let req = PeekDocumentsRequest(
uri: expandMacroCommand.textDocument.uri,
position: expandMacroCommand.positionRange.lowerBound,
locations: expansionURIs
)

let response = await orLog("Sending PeekDocumentsRequest to Client") {
try await sourceKitLSPServer.sendRequestToClient(req)
}

if let response, !response.success {
logger.error("client refused to peek macro")
}
}
} else {
Task {
let req = ShowDocumentRequest(uri: DocumentURI(completeMacroExpansionFilePath))

let response = await orLog("Sending ShowDocumentRequest to Client") {
try await sourceKitLSPServer.sendRequestToClient(req)
}

if let response, !response.success {
logger.error("client refused to show document for macro expansion")
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ extension RefactoringResponse {
return nil
}

var refactoringEdits = [RefactoringEdit]()
var refactoringEdits: [RefactoringEdit] = []

categorizedEdits.forEach { _, categorizedEdit in
guard let edits: SKDResponseArray = categorizedEdit[keys.edits] else {
Expand Down
2 changes: 1 addition & 1 deletion Sources/SourceKitLSP/Swift/SemanticRefactorCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ extension Array where Element == SemanticRefactorCommand {
guard let results = array else {
return nil
}
var commands = [SemanticRefactorCommand]()
var commands: [SemanticRefactorCommand] = []
results.forEach { _, value in
if let name: String = value[keys.actionName],
let actionuid: sourcekitd_api_uid_t = value[keys.actionUID],
Expand Down
6 changes: 1 addition & 5 deletions Sources/SourceKitLSP/Swift/SemanticRefactoring.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,9 @@ extension SwiftLanguageService {
///
/// - Parameters:
/// - semanticRefactorCommand: The `SemanticRefactorCommand` that triggered this request.
///
/// - Returns: A `WorkspaceEdit` with the necessary refactors as a `LSPAny`
func semanticRefactoring(
_ semanticRefactorCommand: SemanticRefactorCommand
) async throws -> LSPAny {
) async throws {
guard let sourceKitLSPServer else {
// `SourceKitLSPServer` has been destructed. We are tearing down the
// language server. Nothing left to do.
Expand All @@ -94,7 +92,5 @@ extension SwiftLanguageService {
}
logger.error("client refused to apply edit for \(semanticRefactor.title, privacy: .public) \(reason)")
}

return edit.encodeToLSPAny()
}
}
Loading