From 9fb3d0e417012d36c8f41b2d65c3d78c478eace6 Mon Sep 17 00:00:00 2001 From: Lokesh T R Date: Fri, 28 Jun 2024 17:57:14 +0530 Subject: [PATCH 1/2] Add LSP support for showing `@attached` Macro Expansions --- .../SourceKitLSP/Swift/MacroExpansion.swift | 4 +- .../ExecuteCommandTests.swift | 204 +++++++++++++++++- 2 files changed, 205 insertions(+), 3 deletions(-) diff --git a/Sources/SourceKitLSP/Swift/MacroExpansion.swift b/Sources/SourceKitLSP/Swift/MacroExpansion.swift index 1e36719d1..cf69e1976 100644 --- a/Sources/SourceKitLSP/Swift/MacroExpansion.swift +++ b/Sources/SourceKitLSP/Swift/MacroExpansion.swift @@ -95,7 +95,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 @@ -112,7 +112,7 @@ extension SwiftLanguageService { } Task { - let req = ShowDocumentRequest(uri: DocumentURI(macroExpansionFilePath), selection: macroEdit.range) + let req = ShowDocumentRequest(uri: DocumentURI(macroExpansionFilePath)) let response = await orLog("Sending ShowDocumentRequest to Client") { try await sourceKitLSPServer.sendRequestToClient(req) diff --git a/Tests/SourceKitLSPTests/ExecuteCommandTests.swift b/Tests/SourceKitLSPTests/ExecuteCommandTests.swift index 490e6c4c4..80979bb91 100644 --- a/Tests/SourceKitLSPTests/ExecuteCommandTests.swift +++ b/Tests/SourceKitLSPTests/ExecuteCommandTests.swift @@ -252,7 +252,209 @@ final class ExecuteCommandTests: XCTestCase { XCTAssertEqual( url.lastPathComponent, - "MyMacroClient_L4C2-L4C19.swift", + "MyMacroClient_L5C3-L5C20.swift", + "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + } + } + + func testAttachedMacroExpansion() async throws { + try await SkipUnless.canBuildMacroUsingSwiftSyntaxFromSourceKitLSPBuild() + + let options = SourceKitLSPOptions.testDefault(experimentalFeatures: [.showMacroExpansions]) + + let project = try await SwiftPMTestProject( + files: [ + "MyMacros/MyMacros.swift": #""" + import SwiftCompilerPlugin + import SwiftSyntax + import SwiftSyntaxBuilder + import SwiftSyntaxMacros + + public struct DictionaryStorageMacro {} + + extension DictionaryStorageMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + return ["\n var _storage: [String: Any] = [:]"] + } + } + + extension DictionaryStorageMacro: MemberAttributeMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingAttributesFor member: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AttributeSyntax] { + return [ + AttributeSyntax( + leadingTrivia: [.newlines(1), .spaces(2)], + attributeName: IdentifierTypeSyntax( + name: .identifier("DictionaryStorageProperty") + ) + ) + ] + } + } + + public struct DictionaryStoragePropertyMacro: AccessorMacro { + public static func expansion< + Context: MacroExpansionContext, + Declaration: DeclSyntaxProtocol + >( + of node: AttributeSyntax, + providingAccessorsOf declaration: Declaration, + in context: Context + ) throws -> [AccessorDeclSyntax] { + guard let binding = declaration.as(VariableDeclSyntax.self)?.bindings.first, + let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier, + binding.accessorBlock == nil, + let type = binding.typeAnnotation?.type, + let defaultValue = binding.initializer?.value, + identifier.text != "_storage" + else { + return [] + } + + return [ + """ + get { + _storage[\(literal: identifier.text), default: \(defaultValue)] as! \(type) + } + """, + """ + set { + _storage[\(literal: identifier.text)] = newValue + } + """, + ] + } + } + + @main + struct MyMacroPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + DictionaryStorageMacro.self, + DictionaryStoragePropertyMacro.self + ] + } + """#, + "MyMacroClient/MyMacroClient.swift": #""" + @attached(memberAttribute) + @attached(member, names: named(_storage)) + public macro DictionaryStorage() = #externalMacro(module: "MyMacros", type: "DictionaryStorageMacro") + + @attached(accessor) + public macro DictionaryStorageProperty() = + #externalMacro(module: "MyMacros", type: "DictionaryStoragePropertyMacro") + + 1️⃣@2️⃣DictionaryStorage3️⃣ + struct Point { + var x: Int = 1 + var y: Int = 2 + } + """#, + ], + manifest: SwiftPMTestProject.macroPackageManifest, + options: options + ) + try await SwiftPMTestProject.build(at: project.scratchDirectory) + + let (uri, positions) = try project.openDocument("MyMacroClient.swift") + + let positionMarkersToBeTested = [ + (start: "1️⃣", end: "1️⃣"), + (start: "2️⃣", end: "2️⃣"), + (start: "1️⃣", end: "3️⃣"), + (start: "2️⃣", end: "3️⃣"), + ] + + for positionMarker in positionMarkersToBeTested { + let args = ExpandMacroCommand( + positionRange: positions[positionMarker.start]..(initialValue: [nil, nil, nil]) + + for i in 0...2 { + project.testClient.handleSingleRequest { (req: ShowDocumentRequest) in + showDocumentRequestURIs.value[i] = req.uri + expectation.fulfill() + return ShowDocumentResponse(success: true) + } + } + + let result = try await project.testClient.send(request) + + guard let resultArray: [RefactoringEdit] = Array(fromLSPArray: result ?? .null) else { + XCTFail( + "Result is not an array. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + return + } + + XCTAssertEqual( + resultArray.count, + 4, + "resultArray count is not equal to four. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + + XCTAssertEqual( + resultArray.map { $0.newText }.sorted(), + [ + "", + "@DictionaryStorageProperty", + "@DictionaryStorageProperty", + "var _storage: [String: Any] = [:]", + ].sorted(), + "Wrong macro expansion. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + + try await fulfillmentOfOrThrow([expectation]) + + let urls = try showDocumentRequestURIs.value.map { + try XCTUnwrap( + $0?.fileURL, + "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + } + + let filesContents = try urls.map { + try String(contentsOf: $0, encoding: .utf8) + } + + XCTAssertEqual( + filesContents.sorted(), + [ + "@DictionaryStorageProperty", + "@DictionaryStorageProperty", + "var _storage: [String: Any] = [:]", + ].sorted(), + "Files doesn't contain correct macro expansion. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + + XCTAssertEqual( + urls.map { $0.lastPathComponent }.sorted(), + [ + "MyMacroClient_L11C3-L11C3.swift", + "MyMacroClient_L12C3-L12C3.swift", + "MyMacroClient_L13C1-L13C1.swift", + ].sorted(), "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" ) } From 0221475b703fcafc5d13aa5e308d6990763f9e75 Mon Sep 17 00:00:00 2001 From: Lokesh T R Date: Fri, 28 Jun 2024 23:10:34 +0530 Subject: [PATCH 2/2] Implement `PeekDocumentsRequest` and update `ShowDocumentRequest`. ------------------------------------------------------------------------------- This implements an LSP Extension `PeekDocumentsRequest` to let `ExpandMacroCommand` to open the macro expansions in a "peeked" editor window. For this to work, the client has to pass "workspace/peekDocuments" enabled to `ClientCapabilities.experimental` and the client should handle the `PeekDocumentsRequest` and show the expansions in a "peeked" editor window. PR to support the above capability in the "Swift for VS Code" Extension: https://github.com/swiftlang/vscode-swift/pull/945 The "Swift for VS Code" extension cannot send the client capability, so it instead passes the same through `initializationOptions` in the `InitializeRequest`. For editors which doesn't support this capability, `sourcekit-lsp` sends a `ShowDocumentRequest`. The `ShowDocumentRequest` is updated to show all the macro expansions in a single generated file. Moreover, its folder structure is updated to use hex string of MD5 hash of concatenation of buffer names of expansions. Fixes https://github.com/swiftlang/vscode-swift/issues/564 Fixes https://github.com/swiftlang/sourcekit-lsp/issues/1498 ( rdar://130207754 ) --- Documentation/LSP Extensions.md | 37 ++ Package.swift | 1 + Sources/LanguageServerProtocol/CMakeLists.txt | 1 + Sources/LanguageServerProtocol/Messages.swift | 1 + .../Requests/PeekDocumentsRequest.swift | 57 ++ .../SupportTypes/WorkspaceEdit.swift | 32 - Sources/SourceKitLSP/CMakeLists.txt | 2 +- Sources/SourceKitLSP/SourceKitLSPServer.swift | 20 +- .../SourceKitLSP/Swift/MacroExpansion.swift | 111 +++- ...toring.swift => RefactoringResponse.swift} | 2 +- .../Swift/SemanticRefactorCommand.swift | 2 +- .../Swift/SemanticRefactoring.swift | 6 +- .../Swift/SwiftLanguageService.swift | 6 +- .../ExecuteCommandTests.swift | 587 +++++++++--------- 14 files changed, 529 insertions(+), 336 deletions(-) create mode 100644 Sources/LanguageServerProtocol/Requests/PeekDocumentsRequest.swift rename Sources/SourceKitLSP/Swift/{Refactoring.swift => RefactoringResponse.swift} (99%) diff --git a/Documentation/LSP Extensions.md b/Documentation/LSP Extensions.md index bd777a94b..a1ef65dde 100644 --- a/Documentation/LSP Extensions.md +++ b/Documentation/LSP Extensions.md @@ -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; +} +``` diff --git a/Package.swift b/Package.swift index 36289e114..543f5c895 100644 --- a/Package.swift +++ b/Package.swift @@ -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"), diff --git a/Sources/LanguageServerProtocol/CMakeLists.txt b/Sources/LanguageServerProtocol/CMakeLists.txt index 2ccbef4d3..a95d3a309 100644 --- a/Sources/LanguageServerProtocol/CMakeLists.txt +++ b/Sources/LanguageServerProtocol/CMakeLists.txt @@ -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 diff --git a/Sources/LanguageServerProtocol/Messages.swift b/Sources/LanguageServerProtocol/Messages.swift index 3440d67d3..fb2ed5480 100644 --- a/Sources/LanguageServerProtocol/Messages.swift +++ b/Sources/LanguageServerProtocol/Messages.swift @@ -58,6 +58,7 @@ public let builtinRequests: [_RequestType.Type] = [ InlineValueRequest.self, LinkedEditingRangeRequest.self, MonikersRequest.self, + PeekDocumentsRequest.self, PollIndexRequest.self, PrepareRenameRequest.self, ReferencesRequest.self, diff --git a/Sources/LanguageServerProtocol/Requests/PeekDocumentsRequest.swift b/Sources/LanguageServerProtocol/Requests/PeekDocumentsRequest.swift new file mode 100644 index 000000000..7303934ff --- /dev/null +++ b/Sources/LanguageServerProtocol/Requests/PeekDocumentsRequest.swift @@ -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 + } +} diff --git a/Sources/LanguageServerProtocol/SupportTypes/WorkspaceEdit.swift b/Sources/LanguageServerProtocol/SupportTypes/WorkspaceEdit.swift index 11841110b..9e2793068 100644 --- a/Sources/LanguageServerProtocol/SupportTypes/WorkspaceEdit.swift +++ b/Sources/LanguageServerProtocol/SupportTypes/WorkspaceEdit.swift @@ -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) - ]) - } -} diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index 821167f9f..cdb2339dd 100644 --- a/Sources/SourceKitLSP/CMakeLists.txt +++ b/Sources/SourceKitLSP/CMakeLists.txt @@ -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 diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index 4bc2f78c7..5f6f80253 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -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) }) diff --git a/Sources/SourceKitLSP/Swift/MacroExpansion.swift b/Sources/SourceKitLSP/Swift/MacroExpansion.swift index cf69e1976..dd8f6bd8d 100644 --- a/Sources/SourceKitLSP/Swift/MacroExpansion.swift +++ b/Sources/SourceKitLSP/Swift/MacroExpansion.swift @@ -10,6 +10,7 @@ // //===----------------------------------------------------------------------===// +import Crypto import Foundation import LSPLogging import LanguageServerProtocol @@ -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. @@ -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" @@ -79,6 +82,9 @@ extension SwiftLanguageService { let macroExpansionBufferDirectoryURL = self.generatedMacroExpansionsPath .appendingPathComponent(macroExpansionBufferDirectoryName) + + completeExpansionDirectoryName += "\(bufferName)-" + do { try FileManager.default.createDirectory( at: macroExpansionBufferDirectoryURL, @@ -111,22 +117,95 @@ extension SwiftLanguageService { ) } - Task { - let req = ShowDocumentRequest(uri: DocumentURI(macroExpansionFilePath)) + 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") + } + } + } } } diff --git a/Sources/SourceKitLSP/Swift/Refactoring.swift b/Sources/SourceKitLSP/Swift/RefactoringResponse.swift similarity index 99% rename from Sources/SourceKitLSP/Swift/Refactoring.swift rename to Sources/SourceKitLSP/Swift/RefactoringResponse.swift index 8c5dd9d6b..10dea7900 100644 --- a/Sources/SourceKitLSP/Swift/Refactoring.swift +++ b/Sources/SourceKitLSP/Swift/RefactoringResponse.swift @@ -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 { diff --git a/Sources/SourceKitLSP/Swift/SemanticRefactorCommand.swift b/Sources/SourceKitLSP/Swift/SemanticRefactorCommand.swift index 521dc51d5..d0a96f1b5 100644 --- a/Sources/SourceKitLSP/Swift/SemanticRefactorCommand.swift +++ b/Sources/SourceKitLSP/Swift/SemanticRefactorCommand.swift @@ -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], diff --git a/Sources/SourceKitLSP/Swift/SemanticRefactoring.swift b/Sources/SourceKitLSP/Swift/SemanticRefactoring.swift index 18f12e6c6..44e9cd3c3 100644 --- a/Sources/SourceKitLSP/Swift/SemanticRefactoring.swift +++ b/Sources/SourceKitLSP/Swift/SemanticRefactoring.swift @@ -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. @@ -94,7 +92,5 @@ extension SwiftLanguageService { } logger.error("client refused to apply edit for \(semanticRefactor.title, privacy: .public) \(reason)") } - - return edit.encodeToLSPAny() } } diff --git a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift index 9bf4462fb..dfead2e5c 100644 --- a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift +++ b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift @@ -957,15 +957,17 @@ extension SwiftLanguageService { public func executeCommand(_ req: ExecuteCommandRequest) async throws -> LSPAny? { if let command = req.swiftCommand(ofType: SemanticRefactorCommand.self) { - return try await semanticRefactoring(command) + try await semanticRefactoring(command) } else if let command = req.swiftCommand(ofType: ExpandMacroCommand.self), let experimentalFeatures = await self.sourceKitLSPServer?.options.experimentalFeatures, experimentalFeatures.contains(.showMacroExpansions) { - return try await expandMacro(command) + try await expandMacro(command) } else { throw ResponseError.unknown("unknown command \(req.command)") } + + return nil } } diff --git a/Tests/SourceKitLSPTests/ExecuteCommandTests.swift b/Tests/SourceKitLSPTests/ExecuteCommandTests.swift index 80979bb91..19dbb00f8 100644 --- a/Tests/SourceKitLSPTests/ExecuteCommandTests.swift +++ b/Tests/SourceKitLSPTests/ExecuteCommandTests.swift @@ -46,19 +46,28 @@ final class ExecuteCommandTests: XCTestCase { let request = ExecuteCommandRequest(command: command.command, arguments: command.arguments) + let expectation = self.expectation(description: "Handle ApplyEditRequest") + let applyEditTitle = ThreadSafeBox(initialValue: nil) + let applyEditWorkspaceEdit = ThreadSafeBox(initialValue: nil) + testClient.handleSingleRequest { (req: ApplyEditRequest) -> ApplyEditResponse in + applyEditTitle.value = req.label + applyEditWorkspaceEdit.value = req.edit + expectation.fulfill() + return ApplyEditResponse(applied: true, failureReason: nil) } - let result = try await testClient.send(request) + try await testClient.send(request) - guard case .dictionary(let resultDict) = result else { - XCTFail("Result is not a dictionary.") - return - } + try await fulfillmentOfOrThrow([expectation]) + let label = try XCTUnwrap(applyEditTitle.value) + let edit = try XCTUnwrap(applyEditWorkspaceEdit.value) + + XCTAssertEqual(label, "Localize String") XCTAssertEqual( - WorkspaceEdit(fromLSPDictionary: resultDict), + edit, WorkspaceEdit(changes: [ uri: [ TextEdit( @@ -102,19 +111,28 @@ final class ExecuteCommandTests: XCTestCase { let request = ExecuteCommandRequest(command: command.command, arguments: command.arguments) + let expectation = self.expectation(description: "Handle ApplyEditRequest") + let applyEditTitle = ThreadSafeBox(initialValue: nil) + let applyEditWorkspaceEdit = ThreadSafeBox(initialValue: nil) + testClient.handleSingleRequest { (req: ApplyEditRequest) -> ApplyEditResponse in + applyEditTitle.value = req.label + applyEditWorkspaceEdit.value = req.edit + expectation.fulfill() + return ApplyEditResponse(applied: true, failureReason: nil) } - let result = try await testClient.send(request) + try await testClient.send(request) - guard case .dictionary(let resultDict) = result else { - XCTFail("Result is not a dictionary.") - return - } + try await fulfillmentOfOrThrow([expectation]) + + let label = try XCTUnwrap(applyEditTitle.value) + let edit = try XCTUnwrap(applyEditWorkspaceEdit.value) + XCTAssertEqual(label, "Extract Method") XCTAssertEqual( - WorkspaceEdit(fromLSPDictionary: resultDict), + edit, WorkspaceEdit(changes: [ uri: [ TextEdit( @@ -141,322 +159,337 @@ final class ExecuteCommandTests: XCTestCase { func testFreestandingMacroExpansion() async throws { try await SkipUnless.canBuildMacroUsingSwiftSyntaxFromSourceKitLSPBuild() - let options = SourceKitLSPOptions.testDefault(experimentalFeatures: [.showMacroExpansions]) - - let project = try await SwiftPMTestProject( - files: [ - "MyMacros/MyMacros.swift": #""" - import SwiftCompilerPlugin - import SwiftSyntax - import SwiftSyntaxBuilder - import SwiftSyntaxMacros - - public struct StringifyMacro: ExpressionMacro { - public static func expansion( - of node: some FreestandingMacroExpansionSyntax, - in context: some MacroExpansionContext - ) -> ExprSyntax { - guard let argument = node.argumentList.first?.expression else { - fatalError("compiler bug: the macro does not have any arguments") - } - - return "(\(argument), \(literal: argument.description))" + let files: [RelativeFileLocation: String] = [ + "MyMacros/MyMacros.swift": #""" + import SwiftCompilerPlugin + import SwiftSyntax + import SwiftSyntaxBuilder + import SwiftSyntaxMacros + + public struct StringifyMacro: ExpressionMacro { + public static func expansion( + of node: some FreestandingMacroExpansionSyntax, + in context: some MacroExpansionContext + ) -> ExprSyntax { + guard let argument = node.argumentList.first?.expression else { + fatalError("compiler bug: the macro does not have any arguments") } - } - @main - struct MyMacroPlugin: CompilerPlugin { - let providingMacros: [Macro.Type] = [ - StringifyMacro.self, - ] - } - """#, - "MyMacroClient/MyMacroClient.swift": """ - @freestanding(expression) - public macro stringify(_ value: T) -> (T, String) = #externalMacro(module: "MyMacros", type: "StringifyMacro") - - func test() { - 1️⃣#2️⃣stringify3️⃣(1 + 2) + return "(\(argument), \(literal: argument.description))" } - """, - ], - manifest: SwiftPMTestProject.macroPackageManifest, - options: options - ) - try await SwiftPMTestProject.build(at: project.scratchDirectory) + } - let (uri, positions) = try project.openDocument("MyMacroClient.swift") + @main + struct MyMacroPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + StringifyMacro.self, + ] + } + """#, + "MyMacroClient/MyMacroClient.swift": """ + @freestanding(expression) + public macro stringify(_ value: T) -> (T, String) = #externalMacro(module: "MyMacros", type: "StringifyMacro") - let positionMarkersToBeTested = [ - (start: "1️⃣", end: "1️⃣"), - (start: "2️⃣", end: "2️⃣"), - (start: "1️⃣", end: "3️⃣"), - (start: "2️⃣", end: "3️⃣"), + func test() { + 1️⃣#2️⃣stringify3️⃣(1 + 2) + } + """, ] - for positionMarker in positionMarkersToBeTested { - let args = ExpandMacroCommand( - positionRange: positions[positionMarker.start]..(initialValue: nil) + let metadata = SourceKitLSPCommandMetadata(textDocument: TextDocumentIdentifier(uri)) - project.testClient.handleSingleRequest { (req: ShowDocumentRequest) in - showDocumentRequestURI.value = req.uri - expectation.fulfill() - return ShowDocumentResponse(success: true) - } + var command = args.asCommand() + command.arguments?.append(metadata.encodeToLSPAny()) - let result = try await project.testClient.send(request) + let request = ExecuteCommandRequest(command: command.command, arguments: command.arguments) - guard let resultArray: [RefactoringEdit] = Array(fromLSPArray: result ?? .null) else { - XCTFail( - "Result is not an array. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" - ) - return - } + if peekDocuments { + let expectation = self.expectation(description: "Handle Peek Documents Request") + let peekDocumentsRequestURIs = ThreadSafeBox<[DocumentURI]?>(initialValue: nil) - XCTAssertEqual( - resultArray.count, - 1, - "resultArray is empty. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" - ) - XCTAssertEqual( - resultArray.only?.newText, - "(1 + 2, \"1 + 2\")", - "Wrong macro expansion. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" - ) + project.testClient.handleSingleRequest { (req: PeekDocumentsRequest) in + peekDocumentsRequestURIs.value = req.locations + expectation.fulfill() + return PeekDocumentsResponse(success: true) + } - try await fulfillmentOfOrThrow([expectation]) + _ = try await project.testClient.send(request) - let url = try XCTUnwrap( - showDocumentRequestURI.value?.fileURL, - "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" - ) + try await fulfillmentOfOrThrow([expectation]) - let fileContents = try String(contentsOf: url, encoding: .utf8) + let urls = try XCTUnwrap( + peekDocumentsRequestURIs.value?.map { + return try XCTUnwrap( + $0.fileURL, + "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + }, + "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + + let filesContents = try urls.map { try String(contentsOf: $0, encoding: .utf8) } + + XCTAssertEqual( + filesContents.only, + "(1 + 2, \"1 + 2\")", + "File doesn't contain macro expansion. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + + XCTAssertEqual( + urls.only?.lastPathComponent, + "MyMacroClient_L5C3-L5C20.swift", + "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + } else { + let expectation = self.expectation(description: "Handle Show Document Request") + let showDocumentRequestURI = ThreadSafeBox(initialValue: nil) + + project.testClient.handleSingleRequest { (req: ShowDocumentRequest) in + showDocumentRequestURI.value = req.uri + expectation.fulfill() + return ShowDocumentResponse(success: true) + } - XCTAssert( - fileContents.contains("(1 + 2, \"1 + 2\")"), - "File doesn't contain macro expansion. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" - ) + _ = try await project.testClient.send(request) - XCTAssertEqual( - url.lastPathComponent, - "MyMacroClient_L5C3-L5C20.swift", - "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" - ) + try await fulfillmentOfOrThrow([expectation]) + + let url = try XCTUnwrap( + showDocumentRequestURI.value?.fileURL, + "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + + let fileContents = try String(contentsOf: url, encoding: .utf8) + + XCTAssertEqual( + fileContents, + """ + // MyMacroClient.swift @ 5:3 - 5:20 + (1 + 2, \"1 + 2\") + + """, + "File doesn't contain macro expansion. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + + XCTAssertEqual( + url.lastPathComponent, + "MyMacroClient.swift", + "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + } + } } } func testAttachedMacroExpansion() async throws { try await SkipUnless.canBuildMacroUsingSwiftSyntaxFromSourceKitLSPBuild() - let options = SourceKitLSPOptions.testDefault(experimentalFeatures: [.showMacroExpansions]) - - let project = try await SwiftPMTestProject( - files: [ - "MyMacros/MyMacros.swift": #""" - import SwiftCompilerPlugin - import SwiftSyntax - import SwiftSyntaxBuilder - import SwiftSyntaxMacros - - public struct DictionaryStorageMacro {} - - extension DictionaryStorageMacro: MemberMacro { - public static func expansion( - of node: AttributeSyntax, - providingMembersOf declaration: some DeclGroupSyntax, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - return ["\n var _storage: [String: Any] = [:]"] - } + let files: [RelativeFileLocation: String] = [ + "MyMacros/MyMacros.swift": #""" + import SwiftCompilerPlugin + import SwiftSyntax + import SwiftSyntaxBuilder + import SwiftSyntaxMacros + + public struct DictionaryStorageMacro {} + + extension DictionaryStorageMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + return ["\n var _storage: [String: Any] = [:]"] } + } - extension DictionaryStorageMacro: MemberAttributeMacro { - public static func expansion( - of node: AttributeSyntax, - attachedTo declaration: some DeclGroupSyntax, - providingAttributesFor member: some DeclSyntaxProtocol, - in context: some MacroExpansionContext - ) throws -> [AttributeSyntax] { - return [ - AttributeSyntax( - leadingTrivia: [.newlines(1), .spaces(2)], - attributeName: IdentifierTypeSyntax( - name: .identifier("DictionaryStorageProperty") - ) + extension DictionaryStorageMacro: MemberAttributeMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingAttributesFor member: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AttributeSyntax] { + return [ + AttributeSyntax( + leadingTrivia: [.newlines(1), .spaces(2)], + attributeName: IdentifierTypeSyntax( + name: .identifier("DictionaryStorageProperty") ) - ] - } - } - - public struct DictionaryStoragePropertyMacro: AccessorMacro { - public static func expansion< - Context: MacroExpansionContext, - Declaration: DeclSyntaxProtocol - >( - of node: AttributeSyntax, - providingAccessorsOf declaration: Declaration, - in context: Context - ) throws -> [AccessorDeclSyntax] { - guard let binding = declaration.as(VariableDeclSyntax.self)?.bindings.first, - let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier, - binding.accessorBlock == nil, - let type = binding.typeAnnotation?.type, - let defaultValue = binding.initializer?.value, - identifier.text != "_storage" - else { - return [] - } - - return [ - """ - get { - _storage[\(literal: identifier.text), default: \(defaultValue)] as! \(type) - } - """, - """ - set { - _storage[\(literal: identifier.text)] = newValue - } - """, - ] - } - } - - @main - struct MyMacroPlugin: CompilerPlugin { - let providingMacros: [Macro.Type] = [ - DictionaryStorageMacro.self, - DictionaryStoragePropertyMacro.self + ) ] } - """#, - "MyMacroClient/MyMacroClient.swift": #""" - @attached(memberAttribute) - @attached(member, names: named(_storage)) - public macro DictionaryStorage() = #externalMacro(module: "MyMacros", type: "DictionaryStorageMacro") - - @attached(accessor) - public macro DictionaryStorageProperty() = - #externalMacro(module: "MyMacros", type: "DictionaryStoragePropertyMacro") - - 1️⃣@2️⃣DictionaryStorage3️⃣ - struct Point { - var x: Int = 1 - var y: Int = 2 - } - """#, - ], - manifest: SwiftPMTestProject.macroPackageManifest, - options: options - ) - try await SwiftPMTestProject.build(at: project.scratchDirectory) - - let (uri, positions) = try project.openDocument("MyMacroClient.swift") + } - let positionMarkersToBeTested = [ - (start: "1️⃣", end: "1️⃣"), - (start: "2️⃣", end: "2️⃣"), - (start: "1️⃣", end: "3️⃣"), - (start: "2️⃣", end: "3️⃣"), + @main + struct MyMacroPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + DictionaryStorageMacro.self + ] + } + """#, + "MyMacroClient/MyMacroClient.swift": #""" + @attached(memberAttribute) + @attached(member, names: named(_storage)) + public macro DictionaryStorage() = #externalMacro(module: "MyMacros", type: "DictionaryStorageMacro") + + 1️⃣@2️⃣DictionaryStorage3️⃣ + struct Point { + var x: Int = 1 + var y: Int = 2 + } + """#, ] - for positionMarker in positionMarkersToBeTested { - let args = ExpandMacroCommand( - positionRange: positions[positionMarker.start]..(initialValue: [nil, nil, nil]) + var command = args.asCommand() + command.arguments?.append(metadata.encodeToLSPAny()) - for i in 0...2 { - project.testClient.handleSingleRequest { (req: ShowDocumentRequest) in - showDocumentRequestURIs.value[i] = req.uri - expectation.fulfill() - return ShowDocumentResponse(success: true) - } - } + let request = ExecuteCommandRequest(command: command.command, arguments: command.arguments) - let result = try await project.testClient.send(request) + if peekDocuments { + let expectation = self.expectation(description: "Handle Peek Documents Request") - guard let resultArray: [RefactoringEdit] = Array(fromLSPArray: result ?? .null) else { - XCTFail( - "Result is not an array. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" - ) - return - } + let peekDocumentsRequestURIs = ThreadSafeBox<[DocumentURI]?>(initialValue: nil) - XCTAssertEqual( - resultArray.count, - 4, - "resultArray count is not equal to four. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" - ) + project.testClient.handleSingleRequest { (req: PeekDocumentsRequest) in + peekDocumentsRequestURIs.value = req.locations + expectation.fulfill() + return PeekDocumentsResponse(success: true) + } - XCTAssertEqual( - resultArray.map { $0.newText }.sorted(), - [ - "", - "@DictionaryStorageProperty", - "@DictionaryStorageProperty", - "var _storage: [String: Any] = [:]", - ].sorted(), - "Wrong macro expansion. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" - ) + _ = try await project.testClient.send(request) - try await fulfillmentOfOrThrow([expectation]) + try await fulfillmentOfOrThrow([expectation]) - let urls = try showDocumentRequestURIs.value.map { - try XCTUnwrap( - $0?.fileURL, - "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" - ) - } + let urls = try XCTUnwrap( + peekDocumentsRequestURIs.value?.map { + return try XCTUnwrap( + $0.fileURL, + "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + }, + "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + + let filesContents = try urls.map { try String(contentsOf: $0, encoding: .utf8) } + + XCTAssertEqual( + filesContents, + [ + "@DictionaryStorageProperty", + "@DictionaryStorageProperty", + "var _storage: [String: Any] = [:]", + ], + "Files doesn't contain correct macro expansion. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + + XCTAssertEqual( + urls.map { $0.lastPathComponent }, + [ + "MyMacroClient_L7C3-L7C3.swift", + "MyMacroClient_L8C3-L8C3.swift", + "MyMacroClient_L9C1-L9C1.swift", + ], + "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + } else { + let expectation = self.expectation(description: "Handle Show Document Request") + let showDocumentRequestURI = ThreadSafeBox(initialValue: nil) + + project.testClient.handleSingleRequest { (req: ShowDocumentRequest) in + showDocumentRequestURI.value = req.uri + expectation.fulfill() + return ShowDocumentResponse(success: true) + } - let filesContents = try urls.map { - try String(contentsOf: $0, encoding: .utf8) - } + _ = try await project.testClient.send(request) - XCTAssertEqual( - filesContents.sorted(), - [ - "@DictionaryStorageProperty", - "@DictionaryStorageProperty", - "var _storage: [String: Any] = [:]", - ].sorted(), - "Files doesn't contain correct macro expansion. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" - ) + try await fulfillmentOfOrThrow([expectation]) - XCTAssertEqual( - urls.map { $0.lastPathComponent }.sorted(), - [ - "MyMacroClient_L11C3-L11C3.swift", - "MyMacroClient_L12C3-L12C3.swift", - "MyMacroClient_L13C1-L13C1.swift", - ].sorted(), - "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" - ) + let url = try XCTUnwrap( + showDocumentRequestURI.value?.fileURL, + "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + + let fileContents = try String(contentsOf: url, encoding: .utf8) + + XCTAssertEqual( + fileContents, + """ + // MyMacroClient.swift @ 7:3 - 7:3 + @DictionaryStorageProperty + + // MyMacroClient.swift @ 8:3 - 8:3 + @DictionaryStorageProperty + + // MyMacroClient.swift @ 9:1 - 9:1 + var _storage: [String: Any] = [:] + + """, + "File doesn't contain macro expansion. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + + XCTAssertEqual( + url.lastPathComponent, + "MyMacroClient.swift", + "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + } + } } }