diff --git a/Sources/LanguageServerProtocol/CMakeLists.txt b/Sources/LanguageServerProtocol/CMakeLists.txt index 4495c6f34..83a1aabb3 100644 --- a/Sources/LanguageServerProtocol/CMakeLists.txt +++ b/Sources/LanguageServerProtocol/CMakeLists.txt @@ -73,6 +73,7 @@ add_library(LanguageServerProtocol STATIC Requests/RegisterCapabilityRequest.swift Requests/RenameRequest.swift Requests/SelectionRangeRequest.swift + Requests/ShowDocumentRequest.swift Requests/ShowMessageRequest.swift Requests/ShutdownRequest.swift Requests/SignatureHelpRequest.swift diff --git a/Sources/LanguageServerProtocol/Messages.swift b/Sources/LanguageServerProtocol/Messages.swift index 051b09ac3..6252992da 100644 --- a/Sources/LanguageServerProtocol/Messages.swift +++ b/Sources/LanguageServerProtocol/Messages.swift @@ -65,6 +65,7 @@ public let builtinRequests: [_RequestType.Type] = [ RegisterCapabilityRequest.self, RenameRequest.self, SelectionRangeRequest.self, + ShowDocumentRequest.self, ShowMessageRequest.self, ShutdownRequest.self, SignatureHelpRequest.self, diff --git a/Sources/LanguageServerProtocol/Requests/ShowDocumentRequest.swift b/Sources/LanguageServerProtocol/Requests/ShowDocumentRequest.swift new file mode 100644 index 000000000..c40139ed9 --- /dev/null +++ b/Sources/LanguageServerProtocol/Requests/ShowDocumentRequest.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// +// 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 a document on the client +/// side. +public struct ShowDocumentRequest: RequestType { + public static let method: String = "window/showDocument" + public typealias Response = ShowDocumentResponse + + /// The uri to show. + public var uri: DocumentURI + + /// An optional boolean indicates to show the resource in an external + /// program. To show, for example, `https://www.swift.org/ in the default WEB + /// browser set `external` to `true`. + public var external: Bool? + + /// An optional boolean to indicate whether the editor showing the document + /// should take focus or not. Clients might ignore this property if an + /// external program is started. + public var takeFocus: Bool? + + /// An optional selection range if the document is a text document. Clients + /// might ignore the property if an external program is started or the file + /// is not a text file. + public var selection: Range? + + public init(uri: DocumentURI, external: Bool? = nil, takeFocus: Bool? = nil, selection: Range? = nil) { + self.uri = uri + self.external = external + self.takeFocus = takeFocus + self.selection = selection + } +} + +public struct ShowDocumentResponse: Codable, Hashable, ResponseType { + /// A boolean indicating if the show was successful. + public var success: Bool + + public init(success: Bool) { + self.success = success + } +} diff --git a/Sources/SKCore/ExperimentalFeatures.swift b/Sources/SKCore/ExperimentalFeatures.swift index 75d33d02a..3bb670563 100644 --- a/Sources/SKCore/ExperimentalFeatures.swift +++ b/Sources/SKCore/ExperimentalFeatures.swift @@ -18,4 +18,7 @@ public enum ExperimentalFeature: String, Codable, Sendable, CaseIterable { /// Add `--experimental-prepare-for-indexing` to the `swift build` command run to prepare a target for indexing. case swiftpmPrepareForIndexing = "swiftpm-prepare-for-indexing" + + /// Enable showing macro expansions via `ShowDocumentRequest` + case showMacroExpansions = "show-macro-expansions" } diff --git a/Sources/SKSupport/FileSystem.swift b/Sources/SKSupport/FileSystem.swift index e78820154..6f185017e 100644 --- a/Sources/SKSupport/FileSystem.swift +++ b/Sources/SKSupport/FileSystem.swift @@ -31,7 +31,8 @@ extension AbsolutePath { } } -/// The directory to write generated module interfaces -public var defaultDirectoryForGeneratedInterfaces: AbsolutePath { - try! AbsolutePath(validating: NSTemporaryDirectory()).appending(component: "GeneratedInterfaces") +/// The default directory to write generated files +/// `/sourcekit-lsp/` +public var defaultDirectoryForGeneratedFiles: AbsolutePath { + try! AbsolutePath(validating: NSTemporaryDirectory()).appending(component: "sourcekit-lsp") } diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index 0f7b6fc43..ddd8717c6 100644 --- a/Sources/SourceKitLSP/CMakeLists.txt +++ b/Sources/SourceKitLSP/CMakeLists.txt @@ -40,8 +40,13 @@ target_sources(SourceKitLSP PRIVATE Swift/DiagnosticReportManager.swift Swift/DocumentFormatting.swift Swift/DocumentSymbols.swift + Swift/ExpandMacroCommand.swift Swift/FoldingRange.swift + Swift/MacroExpansion.swift Swift/OpenInterface.swift + Swift/Refactoring.swift + Swift/RefactoringEdit.swift + Swift/RefactorCommand.swift Swift/RelatedIdentifiers.swift Swift/RewriteSourceKitPlaceholders.swift Swift/SemanticRefactorCommand.swift diff --git a/Sources/SourceKitLSP/SourceKitLSPServer+Options.swift b/Sources/SourceKitLSP/SourceKitLSPServer+Options.swift index 01e5cc8c1..0e7f1d68c 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer+Options.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer+Options.swift @@ -38,8 +38,20 @@ extension SourceKitLSPServer { /// Options for code-completion. public var completionOptions: SKCompletionOptions - /// Override the default directory where generated interfaces will be stored - public var generatedInterfacesPath: AbsolutePath + /// Override the default directory where generated files will be stored + public var generatedFilesPath: AbsolutePath + + /// Path to the generated interfaces + /// `/GeneratedInterfaces/` + public var generatedInterfacesPath: AbsolutePath { + generatedFilesPath.appending(component: "GeneratedInterfaces") + } + + /// Path to the generated macro expansions + /// ``/GeneratedMacroExpansions/ + public var generatedMacroExpansionsPath: AbsolutePath { + generatedFilesPath.appending(component: "GeneratedMacroExpansions") + } /// The time that `SwiftLanguageService` should wait after an edit before starting to compute diagnostics and /// sending a `PublishDiagnosticsNotification`. @@ -64,7 +76,7 @@ extension SourceKitLSPServer { compilationDatabaseSearchPaths: [RelativePath] = [], indexOptions: IndexOptions = .init(), completionOptions: SKCompletionOptions = .init(), - generatedInterfacesPath: AbsolutePath = defaultDirectoryForGeneratedInterfaces, + generatedFilesPath: AbsolutePath = defaultDirectoryForGeneratedFiles, swiftPublishDiagnosticsDebounceDuration: TimeInterval = 2, /* 2s */ workDoneProgressDebounceDuration: Duration = .seconds(0), experimentalFeatures: Set = [], @@ -75,7 +87,7 @@ extension SourceKitLSPServer { self.compilationDatabaseSearchPaths = compilationDatabaseSearchPaths self.indexOptions = indexOptions self.completionOptions = completionOptions - self.generatedInterfacesPath = generatedInterfacesPath + self.generatedFilesPath = generatedFilesPath self.swiftPublishDiagnosticsDebounceDuration = swiftPublishDiagnosticsDebounceDuration self.experimentalFeatures = experimentalFeatures self.workDoneProgressDebounceDuration = workDoneProgressDebounceDuration diff --git a/Sources/SourceKitLSP/Swift/ExpandMacroCommand.swift b/Sources/SourceKitLSP/Swift/ExpandMacroCommand.swift new file mode 100644 index 000000000..4bc2abd91 --- /dev/null +++ b/Sources/SourceKitLSP/Swift/ExpandMacroCommand.swift @@ -0,0 +1,80 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import LanguageServerProtocol +import SourceKitD + +public struct ExpandMacroCommand: RefactorCommand { + typealias Response = MacroExpansion + + public static let identifier: String = "expand.macro.command" + + /// The name of this refactoring action. + public var title = "Expand Macro" + + /// The sourcekitd identifier of the refactoring action. + public var actionString = "source.refactoring.kind.expand.macro" + + /// The range to expand. + public var positionRange: Range + + /// The text document related to the refactoring action. + public var textDocument: TextDocumentIdentifier + + public init(positionRange: Range, textDocument: TextDocumentIdentifier) { + self.positionRange = positionRange + self.textDocument = textDocument + } + + public init?(fromLSPDictionary dictionary: [String: LSPAny]) { + guard case .dictionary(let documentDict)? = dictionary[CodingKeys.textDocument.stringValue], + case .string(let title)? = dictionary[CodingKeys.title.stringValue], + case .string(let actionString)? = dictionary[CodingKeys.actionString.stringValue], + case .dictionary(let rangeDict)? = dictionary[CodingKeys.positionRange.stringValue] + else { + return nil + } + guard let positionRange = Range(fromLSPDictionary: rangeDict), + let textDocument = TextDocumentIdentifier(fromLSPDictionary: documentDict) + else { + return nil + } + + self.init( + title: title, + actionString: actionString, + positionRange: positionRange, + textDocument: textDocument + ) + } + + public init( + title: String, + actionString: String, + positionRange: Range, + textDocument: TextDocumentIdentifier + ) { + self.title = title + self.actionString = actionString + self.positionRange = positionRange + self.textDocument = textDocument + } + + public func encodeToLSPAny() -> LSPAny { + return .dictionary([ + CodingKeys.title.stringValue: .string(title), + CodingKeys.actionString.stringValue: .string(actionString), + CodingKeys.positionRange.stringValue: positionRange.encodeToLSPAny(), + CodingKeys.textDocument.stringValue: textDocument.encodeToLSPAny(), + ]) + } +} diff --git a/Sources/SourceKitLSP/Swift/MacroExpansion.swift b/Sources/SourceKitLSP/Swift/MacroExpansion.swift new file mode 100644 index 000000000..1e36719d1 --- /dev/null +++ b/Sources/SourceKitLSP/Swift/MacroExpansion.swift @@ -0,0 +1,132 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import Foundation +import LSPLogging +import LanguageServerProtocol +import SourceKitD + +/// Detailed information about the result of a macro expansion operation. +/// +/// Wraps the information returned by sourcekitd's `semantic_refactoring` +/// request, such as the necessary macro expansion edits. +struct MacroExpansion: RefactoringResponse { + /// The title of the refactoring action. + var title: String + + /// The URI of the file where the macro is used + var uri: DocumentURI + + /// The resulting array of `RefactoringEdit` of a semantic refactoring request + var edits: [RefactoringEdit] + + init(title: String, uri: DocumentURI, refactoringEdits: [RefactoringEdit]) { + self.title = title + self.uri = uri + self.edits = refactoringEdits.compactMap { refactoringEdit in + if refactoringEdit.bufferName == nil && !refactoringEdit.newText.isEmpty { + logger.fault("Unable to retrieve some parts of the expansion") + return nil + } + + return refactoringEdit + } + } +} + +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. + /// + /// - 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 { + guard let sourceKitLSPServer else { + // `SourceKitLSPServer` has been destructed. We are tearing down the + // language server. Nothing left to do. + throw ResponseError.unknown("Connection to the editor closed") + } + + guard let sourceFileURL = expandMacroCommand.textDocument.uri.fileURL else { + throw ResponseError.unknown("Given URI is not a file URL") + } + + let expansion = try await self.refactoring(expandMacroCommand) + + for macroEdit in expansion.edits { + if let bufferName = macroEdit.bufferName { + // buffer name without ".swift" + let macroExpansionBufferDirectoryName = + bufferName.hasSuffix(".swift") + ? String(bufferName.dropLast(6)) + : bufferName + + let macroExpansionBufferDirectoryURL = self.generatedMacroExpansionsPath + .appendingPathComponent(macroExpansionBufferDirectoryName) + do { + try FileManager.default.createDirectory( + at: macroExpansionBufferDirectoryURL, + withIntermediateDirectories: true + ) + } catch { + throw ResponseError.unknown( + "Failed to create directory for macro expansion buffer at path: \(macroExpansionBufferDirectoryURL.path)" + ) + } + + // name of the source file + let macroExpansionFileName = sourceFileURL.deletingPathExtension().lastPathComponent + + // 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)" + + let macroExpansionFilePath = + macroExpansionBufferDirectoryURL + .appendingPathComponent( + "\(macroExpansionFileName)_\(macroExpansionPositionRangeIndicator).\(sourceFileURL.pathExtension)" + ) + + do { + try macroEdit.newText.write(to: macroExpansionFilePath, atomically: true, encoding: .utf8) + } catch { + throw ResponseError.unknown( + "Unable to write macro expansion to file path: \"\(macroExpansionFilePath.path)\"" + ) + } + + Task { + let req = ShowDocumentRequest(uri: DocumentURI(macroExpansionFilePath), selection: macroEdit.range) + + 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 \(expansion.title, privacy: .public)") + } + } + } else if !macroEdit.newText.isEmpty { + logger.fault("Unable to retrieve some parts of macro expansion") + } + } + + return expansion.edits.encodeToLSPAny() + } +} diff --git a/Sources/SourceKitLSP/Swift/RefactorCommand.swift b/Sources/SourceKitLSP/Swift/RefactorCommand.swift new file mode 100644 index 000000000..e1961b9d3 --- /dev/null +++ b/Sources/SourceKitLSP/Swift/RefactorCommand.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import LanguageServerProtocol +import SourceKitD + +/// A protocol to be utilised by all commands that are served by sourcekitd refactorings. +protocol RefactorCommand: SwiftCommand { + /// The response type of the refactor command + associatedtype Response: RefactoringResponse + + /// The sourcekitd identifier of the refactoring action. + var actionString: String { get set } + + /// The range to refactor. + var positionRange: Range { get set } + + /// The text document related to the refactoring action. + var textDocument: TextDocumentIdentifier { get set } + + init(title: String, actionString: String, positionRange: Range, textDocument: TextDocumentIdentifier) +} diff --git a/Sources/SourceKitLSP/Swift/Refactoring.swift b/Sources/SourceKitLSP/Swift/Refactoring.swift new file mode 100644 index 000000000..0a1477df0 --- /dev/null +++ b/Sources/SourceKitLSP/Swift/Refactoring.swift @@ -0,0 +1,129 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import LSPLogging +import LanguageServerProtocol +import SourceKitD + +protocol RefactoringResponse { + init(title: String, uri: DocumentURI, refactoringEdits: [RefactoringEdit]) +} + +extension RefactoringResponse { + /// Create an instance of `RefactoringResponse` from a sourcekitd semantic + /// refactoring response dictionary, if possible. + /// + /// - Parameters: + /// - title: The title of the refactoring action. + /// - dict: Response dictionary to extract information from. + /// - snapshot: The snapshot that triggered the `semantic_refactoring` request. + /// - keys: The sourcekitd key set to use for looking up into `dict`. + init?(_ title: String, _ dict: SKDResponseDictionary, _ snapshot: DocumentSnapshot, _ keys: sourcekitd_api_keys) { + guard let categorizedEdits: SKDResponseArray = dict[keys.categorizedEdits] else { + logger.fault("categorizedEdits doesn't exist in response dictionary") + return nil + } + + var refactoringEdits = [RefactoringEdit]() + + categorizedEdits.forEach { _, categorizedEdit in + guard let edits: SKDResponseArray = categorizedEdit[keys.edits] else { + logger.fault("edits doesn't exist in categorizedEdit dictionary") + return true + } + edits.forEach { _, edit in + guard let startLine: Int = edit[keys.line], + let startColumn: Int = edit[keys.column], + let endLine: Int = edit[keys.endLine], + let endColumn: Int = edit[keys.endColumn], + let text: String = edit[keys.text] + else { + logger.fault("Failed to deserialise edit dictionary containing values: \(edit)") + return true // continue + } + + // The LSP is zero based, but semantic_refactoring is one based. + let startPosition = snapshot.positionOf( + zeroBasedLine: startLine - 1, + utf8Column: startColumn - 1 + ) + let endPosition = snapshot.positionOf( + zeroBasedLine: endLine - 1, + utf8Column: endColumn - 1 + ) + // Snippets are only supported in code completion. + // Remove SourceKit placeholders in refactoring actions because they + // can't be represented in the editor properly. + let textWithSnippets = rewriteSourceKitPlaceholders(in: text, clientSupportsSnippets: false) + refactoringEdits.append( + RefactoringEdit( + range: startPosition..( + _ refactorCommand: T + ) async throws -> T.Response { + let keys = self.keys + + let uri = refactorCommand.textDocument.uri + let snapshot = try self.documentManager.latestSnapshot(uri) + let line = refactorCommand.positionRange.lowerBound.line + let utf16Column = refactorCommand.positionRange.lowerBound.utf16index + let utf8Column = snapshot.lineTable.utf8ColumnAt(line: line, utf16Column: utf16Column) + + let skreq = sourcekitd.dictionary([ + keys.request: self.requests.semanticRefactoring, + // Preferred name for e.g. an extracted variable. + // Empty string means sourcekitd chooses a name automatically. + keys.name: "", + keys.sourceFile: uri.pseudoPath, + // LSP is zero based, but this request is 1 based. + keys.line: line + 1, + keys.column: utf8Column + 1, + keys.length: snapshot.utf8OffsetRange(of: refactorCommand.positionRange).count, + keys.actionUID: self.sourcekitd.api.uid_get_from_cstr(refactorCommand.actionString)!, + keys.compilerArgs: await self.buildSettings(for: snapshot.uri)?.compilerArgs as [SKDRequestValue]?, + ]) + + let dict = try await self.sourcekitd.send(skreq, fileContents: snapshot.text) + guard let refactor = T.Response(refactorCommand.title, dict, snapshot, self.keys) else { + throw SemanticRefactoringError.noEditsNeeded(uri) + } + return refactor + } +} diff --git a/Sources/SourceKitLSP/Swift/RefactoringEdit.swift b/Sources/SourceKitLSP/Swift/RefactoringEdit.swift new file mode 100644 index 000000000..df91a4781 --- /dev/null +++ b/Sources/SourceKitLSP/Swift/RefactoringEdit.swift @@ -0,0 +1,74 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import LanguageServerProtocol +import SourceKitD + +/// Represents an edit from semantic refactor response. Notionally, a subclass of `TextEdit` +@_spi(Testing) public struct RefactoringEdit: Hashable, Sendable, Codable { + /// The range of text to be replaced. + @CustomCodable + public var range: Range + + /// The new text. + public var newText: String + + /// If the new text of the edit should not be applied to the original source + /// file but to a separate buffer, a fake name for that buffer. For example + /// for expansion of macros, this is @ followed by the mangled name of the + /// macro expansion, followed by .swift. + public var bufferName: String? + + public init(range: Range, newText: String, bufferName: String?) { + self._range = CustomCodable(wrappedValue: range) + self.newText = newText + self.bufferName = bufferName + } +} + +extension RefactoringEdit: LSPAnyCodable { + public init?(fromLSPDictionary dictionary: [String: LSPAny]) { + guard case .dictionary(let rangeDict) = dictionary[CodingKeys.range.stringValue], + case .string(let newText) = dictionary[CodingKeys.newText.stringValue] + else { + return nil + } + + guard let range = Range(fromLSPDictionary: rangeDict) else { + return nil + } + + self._range = CustomCodable(wrappedValue: range) + self.newText = newText + + if case .string(let bufferName) = dictionary[CodingKeys.bufferName.stringValue] { + self.bufferName = bufferName + } else { + self.bufferName = nil + } + } + + public func encodeToLSPAny() -> LSPAny { + guard let bufferName = bufferName else { + return .dictionary([ + CodingKeys.range.stringValue: range.encodeToLSPAny(), + CodingKeys.newText.stringValue: .string(newText), + ]) + } + + return .dictionary([ + CodingKeys.range.stringValue: range.encodeToLSPAny(), + CodingKeys.newText.stringValue: .string(newText), + CodingKeys.bufferName.stringValue: .string(bufferName), + ]) + } +} diff --git a/Sources/SourceKitLSP/Swift/SemanticRefactorCommand.swift b/Sources/SourceKitLSP/Swift/SemanticRefactorCommand.swift index 998cd865e..521dc51d5 100644 --- a/Sources/SourceKitLSP/Swift/SemanticRefactorCommand.swift +++ b/Sources/SourceKitLSP/Swift/SemanticRefactorCommand.swift @@ -9,10 +9,12 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// + import LanguageServerProtocol import SourceKitD -public struct SemanticRefactorCommand: SwiftCommand { +public struct SemanticRefactorCommand: RefactorCommand { + typealias Response = SemanticRefactoring public static let identifier: String = "semantic.refactor.command" diff --git a/Sources/SourceKitLSP/Swift/SemanticRefactoring.swift b/Sources/SourceKitLSP/Swift/SemanticRefactoring.swift index 4a7c60942..18f12e6c6 100644 --- a/Sources/SourceKitLSP/Swift/SemanticRefactoring.swift +++ b/Sources/SourceKitLSP/Swift/SemanticRefactoring.swift @@ -9,13 +9,16 @@ // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// + +import LSPLogging import LanguageServerProtocol import SourceKitD /// Detailed information about the result of a specific refactoring operation. /// -/// Wraps the information returned by sourcekitd's `semantic_refactoring` request, such as the necessary edits and placeholder locations. -struct SemanticRefactoring { +/// Wraps the information returned by sourcekitd's `semantic_refactoring` +/// request, such as the necessary edits and placeholder locations. +struct SemanticRefactoring: RefactoringResponse { /// The title of the refactoring action. var title: String @@ -28,57 +31,11 @@ struct SemanticRefactoring { self.edit = edit } - /// Create a `SemanticRefactoring` from a sourcekitd response dictionary, if possible. - /// - /// - Parameters: - /// - title: The title of the refactoring action. - /// - dict: Response dictionary to extract information from. - /// - snapshot: The snapshot that triggered the `semantic_refactoring` request. - /// - keys: The sourcekitd key set to use for looking up into `dict`. - init?(_ title: String, _ dict: SKDResponseDictionary, _ snapshot: DocumentSnapshot, _ keys: sourcekitd_api_keys) { - guard let categorizedEdits: SKDResponseArray = dict[keys.categorizedEdits] else { - return nil - } - - var textEdits = [TextEdit]() - - categorizedEdits.forEach { _, value in - guard let edits: SKDResponseArray = value[keys.edits] else { - return false - } - edits.forEach { _, value in - // The LSP is zero based, but semantic_refactoring is one based. - guard let startLine: Int = value[keys.line], - let startColumn: Int = value[keys.column], - let endLine: Int = value[keys.endLine], - let endColumn: Int = value[keys.endColumn], - let text: String = value[keys.text] - else { - return true // continue - } - let startPosition = snapshot.positionOf( - zeroBasedLine: startLine - 1, - utf8Column: startColumn - 1 - ) - let endPosition = snapshot.positionOf( - zeroBasedLine: endLine - 1, - utf8Column: endColumn - 1 - ) - // Snippets are only supported in code completion. - // Remove SourceKit placeholders in refactoring actions because they can't be represented in the editor properly. - let textWithSnippets = rewriteSourceKitPlaceholders(in: text, clientSupportsSnippets: false) - textEdits.append(TextEdit(range: startPosition.. SemanticRefactoring { - let keys = self.keys - - let uri = refactorCommand.textDocument.uri - let snapshot = try self.documentManager.latestSnapshot(uri) - let line = refactorCommand.positionRange.lowerBound.line - let utf16Column = refactorCommand.positionRange.lowerBound.utf16index - let utf8Column = snapshot.lineTable.utf8ColumnAt(line: line, utf16Column: utf16Column) - - let skreq = sourcekitd.dictionary([ - keys.request: self.requests.semanticRefactoring, - // Preferred name for e.g. an extracted variable. - // Empty string means sourcekitd chooses a name automatically. - keys.name: "", - keys.sourceFile: uri.pseudoPath, - // LSP is zero based, but this request is 1 based. - keys.line: line + 1, - keys.column: utf8Column + 1, - keys.length: snapshot.utf8OffsetRange(of: refactorCommand.positionRange).count, - keys.actionUID: self.sourcekitd.api.uid_get_from_cstr(refactorCommand.actionString)!, - keys.compilerArgs: await self.buildSettings(for: snapshot.uri)?.compilerArgs as [SKDRequestValue]?, - ]) + _ semanticRefactorCommand: SemanticRefactorCommand + ) async throws -> LSPAny { + guard let sourceKitLSPServer else { + // `SourceKitLSPServer` has been destructed. We are tearing down the + // language server. Nothing left to do. + throw ResponseError.unknown("Connection to the editor closed") + } - let dict = try await self.sourcekitd.send(skreq, fileContents: snapshot.text) - guard let refactor = SemanticRefactoring(refactorCommand.title, dict, snapshot, self.keys) else { - throw SemanticRefactoringError.noEditsNeeded(uri) + let semanticRefactor = try await self.refactoring(semanticRefactorCommand) + + let edit = semanticRefactor.edit + let req = ApplyEditRequest(label: semanticRefactor.title, edit: edit) + let response = try await sourceKitLSPServer.sendRequestToClient(req) + if !response.applied { + let reason: String + if let failureReason = response.failureReason { + reason = " reason: \(failureReason)" + } else { + reason = "" + } + logger.error("client refused to apply edit for \(semanticRefactor.title, privacy: .public) \(reason)") } - return refactor + + return edit.encodeToLSPAny() } } diff --git a/Sources/SourceKitLSP/Swift/SwiftCommand.swift b/Sources/SourceKitLSP/Swift/SwiftCommand.swift index da28701ba..2f3ff178d 100644 --- a/Sources/SourceKitLSP/Swift/SwiftCommand.swift +++ b/Sources/SourceKitLSP/Swift/SwiftCommand.swift @@ -15,8 +15,11 @@ import LanguageServerProtocol /// /// All commands from the Swift LSP should be listed here. public let builtinSwiftCommands: [String] = [ - SemanticRefactorCommand.self -].map { $0.identifier } + SemanticRefactorCommand.self, + ExpandMacroCommand.self, +].map { (command: any SwiftCommand.Type) in + command.identifier +} /// A `Command` that should be executed by Swift's language server. public protocol SwiftCommand: Codable, Hashable, LSPAnyCodable { @@ -26,7 +29,7 @@ public protocol SwiftCommand: Codable, Hashable, LSPAnyCodable { extension SwiftCommand { /// Converts this `SwiftCommand` to a generic LSP `Command` object. - public func asCommand() throws -> Command { + public func asCommand() -> Command { let argument = encodeToLSPAny() return Command(title: title, command: Self.identifier, arguments: [argument]) } diff --git a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift index fbfffb53d..56c4b9019 100644 --- a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift +++ b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift @@ -109,8 +109,18 @@ public actor SwiftLanguageService: LanguageService, Sendable { let serverOptions: SourceKitLSPServer.Options + /// Directory where generated Files will be stored. + let generatedFilesPath: URL + /// Directory where generated Swift interfaces will be stored. - let generatedInterfacesPath: URL + var generatedInterfacesPath: URL { + generatedFilesPath.appendingPathComponent("GeneratedInterfaces") + } + + /// Directory where generated Macro expansions will be stored. + var generatedMacroExpansionsPath: URL { + generatedFilesPath.appendingPathComponent("GeneratedMacroExpansions") + } // FIXME: ideally we wouldn't need separate management from a parent server in the same process. var documentManager: DocumentManager @@ -207,8 +217,6 @@ public actor SwiftLanguageService: LanguageService, Sendable { self.serverOptions = options self.documentManager = DocumentManager() self.state = .connected - self.generatedInterfacesPath = options.generatedInterfacesPath.asURL - try FileManager.default.createDirectory(at: generatedInterfacesPath, withIntermediateDirectories: true) self.diagnosticReportManager = nil // Needed to work around rdar://116221716 // The debounce duration of 500ms was chosen arbitrarily without scientific research. @@ -221,12 +229,20 @@ public actor SwiftLanguageService: LanguageService, Sendable { try await sourceKitLSPServer.sendRequestToClient(DiagnosticsRefreshRequest()) } } + + self.generatedFilesPath = options.generatedFilesPath.asURL + try FileManager.default.createDirectory(at: generatedFilesPath, withIntermediateDirectories: true) + self.diagnosticReportManager = DiagnosticReportManager( sourcekitd: self.sourcekitd, syntaxTreeManager: syntaxTreeManager, documentManager: documentManager, clientHasDiagnosticsCodeDescriptionSupport: await self.clientHasDiagnosticsCodeDescriptionSupport ) + + // Create sub-directories for each type of generated file + try FileManager.default.createDirectory(at: generatedInterfacesPath, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: generatedMacroExpansionsPath, withIntermediateDirectories: true) } /// - Important: For testing only @@ -507,7 +523,7 @@ extension SwiftLanguageService { throw CancellationError() } - await sourceKitLSPServer.sendNotificationToClient( + sourceKitLSPServer.sendNotificationToClient( PublishDiagnosticsNotification( uri: document, diagnostics: diagnosticReport.items @@ -801,15 +817,29 @@ extension SwiftLanguageService { additionalParameters: additionalCursorInfoParameters ) - return cursorInfoResponse.refactorActions.compactMap { - do { - let lspCommand = try $0.asCommand() - return CodeAction(title: $0.title, kind: .refactor, command: lspCommand) - } catch { - logger.log("Failed to convert SwiftCommand to Command type: \(error.forLogging)") - return nil + var canInlineMacro = false + + let showMacroExpansionsIsEnabled = + await self.sourceKitLSPServer?.options.experimentalFeatures + .contains(.showMacroExpansions) ?? false + + var refactorActions = cursorInfoResponse.refactorActions.compactMap { + let lspCommand = $0.asCommand() + if !canInlineMacro, showMacroExpansionsIsEnabled { + canInlineMacro = $0.actionString == "source.refactoring.kind.inline.macro" } + + return CodeAction(title: $0.title, kind: .refactor, command: lspCommand) + } + + if canInlineMacro { + let expandMacroCommand = ExpandMacroCommand(positionRange: params.range, textDocument: params.textDocument) + .asCommand() + + refactorActions.append(CodeAction(title: expandMacroCommand.title, kind: .refactor, command: expandMacroCommand)) } + + return refactorActions } func retrieveQuickFixCodeActions(_ params: CodeActionRequest) async throws -> [CodeAction] { @@ -926,29 +956,16 @@ extension SwiftLanguageService { } public func executeCommand(_ req: ExecuteCommandRequest) async throws -> LSPAny? { - // TODO: If there's support for several types of commands, we might need to structure this similarly to the code actions request. - guard let sourceKitLSPServer else { - // `SourceKitLSPServer` has been destructed. We are tearing down the language - // server. Nothing left to do. - throw ResponseError.unknown("Connection to the editor closed") - } - guard let swiftCommand = req.swiftCommand(ofType: SemanticRefactorCommand.self) else { - throw ResponseError.unknown("semantic refactoring: unknown command \(req.command)") - } - let refactor = try await semanticRefactoring(swiftCommand) - let edit = refactor.edit - let req = ApplyEditRequest(label: refactor.title, edit: edit) - let response = try await sourceKitLSPServer.sendRequestToClient(req) - if !response.applied { - let reason: String - if let failureReason = response.failureReason { - reason = " reason: \(failureReason)" - } else { - reason = "" - } - logger.error("Client refused to apply edit for \(refactor.title, privacy: .public)!\(reason)") + if let command = req.swiftCommand(ofType: SemanticRefactorCommand.self) { + return 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) + } else { + throw ResponseError.unknown("unknown command \(req.command)") } - return edit.encodeToLSPAny() } } diff --git a/Sources/sourcekit-lsp/SourceKitLSP.swift b/Sources/sourcekit-lsp/SourceKitLSP.swift index e3e4dbf5e..c9fa83b5d 100644 --- a/Sources/sourcekit-lsp/SourceKitLSP.swift +++ b/Sources/sourcekit-lsp/SourceKitLSP.swift @@ -189,9 +189,9 @@ struct SourceKitLSP: AsyncParsableCommand { var compilationDatabaseSearchPaths = [RelativePath]() @Option( - help: "Specify the directory where generated interfaces will be stored" + help: "Specify the directory where generated files will be stored" ) - var generatedInterfacesPath = defaultDirectoryForGeneratedInterfaces + var generatedFilesPath = defaultDirectoryForGeneratedFiles @Option( name: .customLong("experimental-feature"), @@ -225,7 +225,8 @@ struct SourceKitLSP: AsyncParsableCommand { serverOptions.indexOptions.indexStorePath = indexStorePath serverOptions.indexOptions.indexDatabasePath = indexDatabasePath serverOptions.indexOptions.indexPrefixMappings = indexPrefixMappings - serverOptions.generatedInterfacesPath = generatedInterfacesPath + serverOptions.completionOptions.maxResults = completionMaxResults + serverOptions.generatedFilesPath = generatedFilesPath serverOptions.experimentalFeatures = Set(experimentalFeatures) serverOptions.completionOptions.maxResults = completionMaxResults serverOptions.workDoneProgressDebounceDuration = .milliseconds(workDoneProgressDebounceDuration) diff --git a/Tests/SKSwiftPMWorkspaceTests/SwiftPMBuildSystemTests.swift b/Tests/SKSwiftPMWorkspaceTests/SwiftPMBuildSystemTests.swift index f0b8b0cb7..9f666fcbc 100644 --- a/Tests/SKSwiftPMWorkspaceTests/SwiftPMBuildSystemTests.swift +++ b/Tests/SKSwiftPMWorkspaceTests/SwiftPMBuildSystemTests.swift @@ -842,53 +842,6 @@ final class SwiftPMBuildSystemTests: XCTestCase { assertArgumentsContain(aswift.pathString, arguments: arguments) } } - - func testBuildMacro() async throws { - try await SkipUnless.canBuildMacroUsingSwiftSyntaxFromSourceKitLSPBuild() - // This test is just a dummy to show how to create a `SwiftPMTestProject` that builds a macro using the SwiftSyntax - // modules that were already built during the build of SourceKit-LSP. - // It should be removed once we have a real test that tests macros (like macro expansion). - 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))" - } - } - - @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() { - #stringify(1 + 2) - } - """, - ], - manifest: SwiftPMTestProject.macroPackageManifest - ) - try await SwiftPMTestProject.build(at: project.scratchDirectory) - } } private func assertArgumentsDoNotContain( diff --git a/Tests/SourceKitLSPTests/ExecuteCommandTests.swift b/Tests/SourceKitLSPTests/ExecuteCommandTests.swift index 68536b259..f2678397f 100644 --- a/Tests/SourceKitLSPTests/ExecuteCommandTests.swift +++ b/Tests/SourceKitLSPTests/ExecuteCommandTests.swift @@ -13,7 +13,8 @@ import LSPTestSupport import LanguageServerProtocol import SKTestSupport -import SourceKitLSP +@_spi(Testing) import SourceKitLSP +import SwiftExtensions import XCTest final class ExecuteCommandTests: XCTestCase { @@ -39,7 +40,7 @@ final class ExecuteCommandTests: XCTestCase { let metadata = SourceKitLSPCommandMetadata(textDocument: TextDocumentIdentifier(uri)) - var command = try args.asCommand() + var command = args.asCommand() command.arguments?.append(metadata.encodeToLSPAny()) let request = ExecuteCommandRequest(command: command.command, arguments: command.arguments) @@ -95,7 +96,7 @@ final class ExecuteCommandTests: XCTestCase { let metadata = SourceKitLSPCommandMetadata(textDocument: TextDocumentIdentifier(uri)) - var command = try args.asCommand() + var command = args.asCommand() command.arguments?.append(metadata.encodeToLSPAny()) let request = ExecuteCommandRequest(command: command.command, arguments: command.arguments) @@ -136,6 +137,127 @@ final class ExecuteCommandTests: XCTestCase { ) } + func testFreestandingMacroExpansion() async throws { + try await SkipUnless.canBuildMacroUsingSwiftSyntaxFromSourceKitLSPBuild() + + var serverOptions = SourceKitLSPServer.Options.testDefault + serverOptions.experimentalFeatures.insert(.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))" + } + } + + @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) + } + """, + ], + manifest: SwiftPMTestProject.macroPackageManifest, + serverOptions: serverOptions + ) + 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) + + project.testClient.handleSingleRequest { (req: ShowDocumentRequest) in + showDocumentRequestURI.value = 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, + 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)" + ) + + 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) + + XCTAssert( + fileContents.contains("(1 + 2, \"1 + 2\")"), + "File doesn't contain macro expansion. Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + + XCTAssertEqual( + url.lastPathComponent, + "MyMacroClient_L4C2-L4C19.swift", + "Failed for position range between \(positionMarker.start) and \(positionMarker.end)" + ) + } + } + func testLSPCommandMetadataRetrieval() { var req = ExecuteCommandRequest(command: "", arguments: nil) XCTAssertNil(req.metadata)