Skip to content

Commit

Permalink
Merge pull request #1479 from lokesh-tr/show-attached-macro-expansions
Browse files Browse the repository at this point in the history
Add LSP extension to show Macro Expansions (or any document) in a "peeked" editor (and some minor quality improvements)
  • Loading branch information
ahoppen authored Jul 3, 2024
2 parents 59711df + 0221475 commit 607292a
Show file tree
Hide file tree
Showing 14 changed files with 556 additions and 161 deletions.
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
57 changes: 57 additions & 0 deletions Sources/LanguageServerProtocol/Requests/PeekDocumentsRequest.swift
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

0 comments on commit 607292a

Please sign in to comment.