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 1e36719d1..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, @@ -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 @@ -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") + } + } + } } } 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 490e6c4c4..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,120 +159,337 @@ final class ExecuteCommandTests: XCTestCase { func testFreestandingMacroExpansion() async throws { try await SkipUnless.canBuildMacroUsingSwiftSyntaxFromSourceKitLSPBuild() + 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") + } + + return "(\(argument), \(literal: argument.description))" + } + } + + @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) + } + """, + ] + 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))" + for peekDocuments in [false, true] { + let project = try await SwiftPMTestProject( + files: files, + manifest: SwiftPMTestProject.macroPackageManifest, + capabilities: ClientCapabilities(experimental: ["workspace/peekDocuments": .bool(peekDocuments)]), + options: options, + enableBackgroundIndexing: true + ) + + 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) + + project.testClient.handleSingleRequest { (req: PeekDocumentsRequest) in + peekDocumentsRequestURIs.value = req.locations + expectation.fulfill() + return PeekDocumentsResponse(success: true) } - } - @main - struct MyMacroPlugin: CompilerPlugin { - let providingMacros: [Macro.Type] = [ - StringifyMacro.self, - ] + _ = try await project.testClient.send(request) + + try await fulfillmentOfOrThrow([expectation]) + + 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) + } + + _ = try await project.testClient.send(request) + + 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)" + ) } - """#, - "MyMacroClient/MyMacroClient.swift": """ - @freestanding(expression) - public macro stringify(_ value: T) -> (T, String) = #externalMacro(module: "MyMacros", type: "StringifyMacro") + } + } + } + + func testAttachedMacroExpansion() async throws { + try await SkipUnless.canBuildMacroUsingSwiftSyntaxFromSourceKitLSPBuild() - func test() { - 1️⃣#2️⃣stringify3️⃣(1 + 2) + 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] = [:]"] } - """, - ], - manifest: SwiftPMTestProject.macroPackageManifest, - options: options - ) - try await SwiftPMTestProject.build(at: project.scratchDirectory) + } - let (uri, positions) = try project.openDocument("MyMacroClient.swift") + 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") + ) + ) + ] + } + } - 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) + 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") - 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)" - ) + let peekDocumentsRequestURIs = ThreadSafeBox<[DocumentURI]?>(initialValue: nil) - try await fulfillmentOfOrThrow([expectation]) + project.testClient.handleSingleRequest { (req: PeekDocumentsRequest) in + peekDocumentsRequestURIs.value = req.locations + expectation.fulfill() + return PeekDocumentsResponse(success: true) + } - let url = try XCTUnwrap( - showDocumentRequestURI.value?.fileURL, - "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" - ) + _ = try await project.testClient.send(request) + + try await fulfillmentOfOrThrow([expectation]) + + 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 fileContents = try String(contentsOf: url, encoding: .utf8) + _ = try await project.testClient.send(request) - 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 fulfillmentOfOrThrow([expectation]) - XCTAssertEqual( - url.lastPathComponent, - "MyMacroClient_L4C2-L4C19.swift", - "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)" + ) + } + } } }