diff --git a/Sources/LanguageServerProtocol/Error.swift b/Sources/LanguageServerProtocol/Error.swift index 5fffbdda6..2ca77e0f8 100644 --- a/Sources/LanguageServerProtocol/Error.swift +++ b/Sources/LanguageServerProtocol/Error.swift @@ -118,6 +118,10 @@ extension ResponseError { public static func unknown(_ message: String) -> ResponseError { return ResponseError(code: .unknownErrorCode, message: message) } + + public static func internalError(_ message: String) -> ResponseError { + return ResponseError(code: .internalError, message: message) + } } /// An error during message decoding. diff --git a/Sources/SKSupport/LineTable.swift b/Sources/SKSupport/LineTable.swift index 5c22557ab..a4afda7fe 100644 --- a/Sources/SKSupport/LineTable.swift +++ b/Sources/SKSupport/LineTable.swift @@ -166,6 +166,16 @@ extension LineTable { return lineAndUTF16ColumnOf(content.utf8.index(content.startIndex, offsetBy: utf8Offset)) } + @inlinable func lineAndUTF8ColumnOf(utf8Offset: Int) -> (line: Int, utf8Column: Int)? { + guard let (line, utf16Column) = lineAndUTF16ColumnOf(utf8Offset: utf8Offset) else { + return nil + } + guard let utf8Column = utf8ColumnAt(line: line, utf16Column: utf16Column) else { + return nil + } + return (line, utf8Column) + } + /// Returns UTF16 column offset at UTF8 version of logical position. /// /// - parameter line: Line number (zero-based). diff --git a/Sources/SourceKitD/SKDRequestArray.swift b/Sources/SourceKitD/SKDRequestArray.swift index 28d3d75bd..61d3a7e8b 100644 --- a/Sources/SourceKitD/SKDRequestArray.swift +++ b/Sources/SourceKitD/SKDRequestArray.swift @@ -36,6 +36,10 @@ public final class SKDRequestArray { public func append(_ value: String) { sourcekitd.api.request_array_set_string(array, -1, value) } + + public func append(_ value: SKDRequestDictionary) { + sourcekitd.api.request_array_set_value(array, -1, value.dict) + } } extension SKDRequestArray: CustomStringConvertible { diff --git a/Sources/SourceKitD/sourcekitd_uids.swift b/Sources/SourceKitD/sourcekitd_uids.swift index da2afef4c..dd69172f6 100644 --- a/Sources/SourceKitD/sourcekitd_uids.swift +++ b/Sources/SourceKitD/sourcekitd_uids.swift @@ -17,12 +17,15 @@ public struct sourcekitd_keys { public let actionuid: sourcekitd_uid_t public let annotated_decl: sourcekitd_uid_t public let annotations: sourcekitd_uid_t + public let argindex: sourcekitd_uid_t public let associated_usrs: sourcekitd_uid_t public let bodylength: sourcekitd_uid_t public let bodyoffset: sourcekitd_uid_t public let cancelOnSubsequentRequest: sourcekitd_uid_t public let categories: sourcekitd_uid_t public let categorizededits: sourcekitd_uid_t + public let categorizedranges: sourcekitd_uid_t + public let category: sourcekitd_uid_t public let column: sourcekitd_uid_t public let compilerargs: sourcekitd_uid_t public let context: sourcekitd_uid_t @@ -33,25 +36,29 @@ public struct sourcekitd_keys { public let doc_full_as_xml: sourcekitd_uid_t public let edits: sourcekitd_uid_t public let educational_note_paths: sourcekitd_uid_t + public let enablesyntaxmap: sourcekitd_uid_t public let endcolumn: sourcekitd_uid_t public let endline: sourcekitd_uid_t public let expression_length: sourcekitd_uid_t public let expression_offset: sourcekitd_uid_t - public let expression_type: sourcekitd_uid_t public let expression_type_list: sourcekitd_uid_t + public let expression_type: sourcekitd_uid_t public let filepath: sourcekitd_uid_t public let fixits: sourcekitd_uid_t public let groupname: sourcekitd_uid_t public let id: sourcekitd_uid_t + public let includeNonEditableBaseNames: sourcekitd_uid_t public let is_system: sourcekitd_uid_t public let isDynamic: sourcekitd_uid_t public let kind: sourcekitd_uid_t public let length: sourcekitd_uid_t public let line: sourcekitd_uid_t + public let locations: sourcekitd_uid_t public let modulename: sourcekitd_uid_t public let name: sourcekitd_uid_t public let namelength: sourcekitd_uid_t public let nameoffset: sourcekitd_uid_t + public let nameType: sourcekitd_uid_t public let not_recommended: sourcekitd_uid_t public let notification: sourcekitd_uid_t public let num_bytes_to_erase: sourcekitd_uid_t @@ -59,26 +66,46 @@ public struct sourcekitd_keys { public let ranges: sourcekitd_uid_t public let receivers: sourcekitd_uid_t public let refactor_actions: sourcekitd_uid_t + public let renamelocations: sourcekitd_uid_t + public let renameRangeBase: sourcekitd_uid_t + public let renameRangeCallArgColon: sourcekitd_uid_t + public let renameRangeCallArgCombined: sourcekitd_uid_t + public let renameRangeCallArgLabel: sourcekitd_uid_t + public let renameRangeDeclArgLabel: sourcekitd_uid_t + public let renameRangeKeywordBase: sourcekitd_uid_t + public let renameRangeNoncollapsibleParam: sourcekitd_uid_t + public let renameRangeParam: sourcekitd_uid_t + public let renameRangeSelectorArgLabel: sourcekitd_uid_t public let request: sourcekitd_uid_t public let results: sourcekitd_uid_t public let retrieve_refactor_actions: sourcekitd_uid_t public let semantic_tokens: sourcekitd_uid_t public let severity: sourcekitd_uid_t + public let sourceEditKindActive: sourcekitd_uid_t + public let sourceEditKindComment: sourcekitd_uid_t + public let sourceEditKindInactive: sourcekitd_uid_t + public let sourceEditKindMismatch: sourcekitd_uid_t + public let sourceEditKindSelector: sourcekitd_uid_t + public let sourceEditKindString: sourcekitd_uid_t + public let sourceEditKindUnknown: sourcekitd_uid_t public let sourcefile: sourcekitd_uid_t public let sourcetext: sourcekitd_uid_t public let substructure: sourcekitd_uid_t public let syntactic_only: sourcekitd_uid_t + public let syntacticRenameCall: sourcekitd_uid_t + public let syntacticRenameDefinition: sourcekitd_uid_t + public let syntacticRenameReference: sourcekitd_uid_t + public let syntacticRenameUnknown: sourcekitd_uid_t public let syntaxmap: sourcekitd_uid_t public let synthesizedextensions: sourcekitd_uid_t - public let enablesyntaxmap: sourcekitd_uid_t public let text: sourcekitd_uid_t public let typename: sourcekitd_uid_t public let usr: sourcekitd_uid_t - public let variable_offset: sourcekitd_uid_t public let variable_length: sourcekitd_uid_t - public let variable_type: sourcekitd_uid_t + public let variable_offset: sourcekitd_uid_t public let variable_type_explicit: sourcekitd_uid_t public let variable_type_list: sourcekitd_uid_t + public let variable_type: sourcekitd_uid_t // Code Completion options. public let codecomplete_options: sourcekitd_uid_t @@ -97,12 +124,15 @@ public struct sourcekitd_keys { actionuid = api.uid_get_from_cstr("key.actionuid")! annotated_decl = api.uid_get_from_cstr("key.annotated_decl")! annotations = api.uid_get_from_cstr("key.annotations")! + argindex = api.uid_get_from_cstr("key.argindex")! associated_usrs = api.uid_get_from_cstr("key.associated_usrs")! bodylength = api.uid_get_from_cstr("key.bodylength")! bodyoffset = api.uid_get_from_cstr("key.bodyoffset")! cancelOnSubsequentRequest = api.uid_get_from_cstr("key.cancel_on_subsequent_request")! categories = api.uid_get_from_cstr("key.categories")! + category = api.uid_get_from_cstr("key.category")! categorizededits = api.uid_get_from_cstr("key.categorizededits")! + categorizedranges = api.uid_get_from_cstr("key.categorizedranges")! column = api.uid_get_from_cstr("key.column")! compilerargs = api.uid_get_from_cstr("key.compilerargs")! context = api.uid_get_from_cstr("key.context")! @@ -113,6 +143,7 @@ public struct sourcekitd_keys { doc_full_as_xml = api.uid_get_from_cstr("key.doc.full_as_xml")! edits = api.uid_get_from_cstr("key.edits")! educational_note_paths = api.uid_get_from_cstr("key.educational_note_paths")! + enablesyntaxmap = api.uid_get_from_cstr("key.enablesyntaxmap")! endcolumn = api.uid_get_from_cstr("key.endcolumn")! endline = api.uid_get_from_cstr("key.endline")! expression_length = api.uid_get_from_cstr("key.expression_length")! @@ -123,15 +154,18 @@ public struct sourcekitd_keys { fixits = api.uid_get_from_cstr("key.fixits")! groupname = api.uid_get_from_cstr("key.groupname")! id = api.uid_get_from_cstr("key.id")! + includeNonEditableBaseNames = api.uid_get_from_cstr("key.include_non_editable_base_names")! is_system = api.uid_get_from_cstr("key.is_system")! isDynamic = api.uid_get_from_cstr("key.is_dynamic")! kind = api.uid_get_from_cstr("key.kind")! length = api.uid_get_from_cstr("key.length")! line = api.uid_get_from_cstr("key.line")! + locations = api.uid_get_from_cstr("key.locations")! modulename = api.uid_get_from_cstr("key.modulename")! name = api.uid_get_from_cstr("key.name")! namelength = api.uid_get_from_cstr("key.namelength")! nameoffset = api.uid_get_from_cstr("key.nameoffset")! + nameType = api.uid_get_from_cstr("key.nametype")! not_recommended = api.uid_get_from_cstr("key.not_recommended")! notification = api.uid_get_from_cstr("key.notification")! num_bytes_to_erase = api.uid_get_from_cstr("key.num_bytes_to_erase")! @@ -139,23 +173,43 @@ public struct sourcekitd_keys { ranges = api.uid_get_from_cstr("key.ranges")! receivers = api.uid_get_from_cstr("key.receivers")! refactor_actions = api.uid_get_from_cstr("key.refactor_actions")! + renamelocations = api.uid_get_from_cstr("key.renamelocations")! + renameRangeBase = api.uid_get_from_cstr("source.refactoring.range.kind.basename")! + renameRangeCallArgColon = api.uid_get_from_cstr("source.refactoring.range.kind.call-argument-colon")! + renameRangeCallArgCombined = api.uid_get_from_cstr("source.refactoring.range.kind.call-argument-combined")! + renameRangeCallArgLabel = api.uid_get_from_cstr("source.refactoring.range.kind.call-argument-label")! + renameRangeDeclArgLabel = api.uid_get_from_cstr("source.refactoring.range.kind.decl-argument-label")! + renameRangeKeywordBase = api.uid_get_from_cstr("source.refactoring.range.kind.keyword-basename")! + renameRangeNoncollapsibleParam = api.uid_get_from_cstr("source.refactoring.range.kind.noncollapsible-parameter")! + renameRangeParam = api.uid_get_from_cstr("source.refactoring.range.kind.parameter-and-whitespace")! + renameRangeSelectorArgLabel = api.uid_get_from_cstr("source.refactoring.range.kind.selector-argument-label")! request = api.uid_get_from_cstr("key.request")! results = api.uid_get_from_cstr("key.results")! retrieve_refactor_actions = api.uid_get_from_cstr("key.retrieve_refactor_actions")! semantic_tokens = api.uid_get_from_cstr("key.semantic_tokens")! severity = api.uid_get_from_cstr("key.severity")! + sourceEditKindActive = api.uid_get_from_cstr("source.edit.kind.active")! + sourceEditKindComment = api.uid_get_from_cstr("source.edit.kind.comment")! + sourceEditKindInactive = api.uid_get_from_cstr("source.edit.kind.inactive")! + sourceEditKindMismatch = api.uid_get_from_cstr("source.edit.kind.mismatch")! + sourceEditKindSelector = api.uid_get_from_cstr("source.edit.kind.selector")! + sourceEditKindString = api.uid_get_from_cstr("source.edit.kind.string")! + sourceEditKindUnknown = api.uid_get_from_cstr("source.edit.kind.unknown")! sourcefile = api.uid_get_from_cstr("key.sourcefile")! sourcetext = api.uid_get_from_cstr("key.sourcetext")! substructure = api.uid_get_from_cstr("key.substructure")! syntactic_only = api.uid_get_from_cstr("key.syntactic_only")! + syntacticRenameCall = api.uid_get_from_cstr("source.syntacticrename.call")! + syntacticRenameDefinition = api.uid_get_from_cstr("source.syntacticrename.definition")! + syntacticRenameReference = api.uid_get_from_cstr("source.syntacticrename.reference")! + syntacticRenameUnknown = api.uid_get_from_cstr("source.syntacticrename.unknown")! syntaxmap = api.uid_get_from_cstr("key.syntaxmap")! - enablesyntaxmap = api.uid_get_from_cstr("key.enablesyntaxmap")! synthesizedextensions = api.uid_get_from_cstr("key.synthesizedextensions")! text = api.uid_get_from_cstr("key.text")! typename = api.uid_get_from_cstr("key.typename")! usr = api.uid_get_from_cstr("key.usr")! - variable_offset = api.uid_get_from_cstr("key.variable_offset")! variable_length = api.uid_get_from_cstr("key.variable_length")! + variable_offset = api.uid_get_from_cstr("key.variable_offset")! variable_type = api.uid_get_from_cstr("key.variable_type")! variable_type_explicit = api.uid_get_from_cstr("key.variable_type_explicit")! variable_type_list = api.uid_get_from_cstr("key.variable_type_list")! @@ -192,6 +246,7 @@ public struct sourcekitd_requests { public let variable_type: sourcekitd_uid_t public let relatedidents: sourcekitd_uid_t public let semantic_refactoring: sourcekitd_uid_t + public let find_syntactic_rename_ranges: sourcekitd_uid_t public init(api: sourcekitd_functions_t) { crash_exit = api.uid_get_from_cstr("source.request.crash_exit")! @@ -211,6 +266,7 @@ public struct sourcekitd_requests { variable_type = api.uid_get_from_cstr("source.request.variable.type")! relatedidents = api.uid_get_from_cstr("source.request.relatedidents")! semantic_refactoring = api.uid_get_from_cstr("source.request.semantic.refactoring")! + find_syntactic_rename_ranges = api.uid_get_from_cstr("source.request.find-syntactic-rename-ranges")! } } diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index f94e395f0..a7a8169e0 100644 --- a/Sources/SourceKitLSP/CMakeLists.txt +++ b/Sources/SourceKitLSP/CMakeLists.txt @@ -23,6 +23,8 @@ target_sources(SourceKitLSP PRIVATE Swift/DocumentSymbols.swift Swift/EditorPlaceholder.swift Swift/OpenInterface.swift + Swift/RelatedIdentifiers.swift + Swift/Rename.swift Swift/SemanticRefactorCommand.swift Swift/SemanticRefactoring.swift Swift/SemanticTokens.swift diff --git a/Sources/SourceKitLSP/Clang/ClangLanguageServer.swift b/Sources/SourceKitLSP/Clang/ClangLanguageServer.swift index 12143d5a9..c00092e02 100644 --- a/Sources/SourceKitLSP/Clang/ClangLanguageServer.swift +++ b/Sources/SourceKitLSP/Clang/ClangLanguageServer.swift @@ -597,6 +597,10 @@ extension ClangLanguageServerShim { func executeCommand(_ req: ExecuteCommandRequest) async throws -> LSPAny? { return try await forwardRequestToClangd(req) } + + func rename(_ request: RenameRequest) async throws -> WorkspaceEdit? { + return try await forwardRequestToClangd(request) + } } /// Clang build settings derived from a `FileBuildSettingsChange`. diff --git a/Sources/SourceKitLSP/SourceKitServer.swift b/Sources/SourceKitLSP/SourceKitServer.swift index a6fe6a275..4a2c46997 100644 --- a/Sources/SourceKitLSP/SourceKitServer.swift +++ b/Sources/SourceKitLSP/SourceKitServer.swift @@ -308,6 +308,10 @@ fileprivate enum TaskMetadata: DependencyTracker { self = .freestanding case is PollIndexRequest: self = .globalConfigurationChange + case is RenameRequest: + // Rename might touch multiple files. Make it a global configuration change so that edits to all files that might + // be affected have been processed. + self = .globalConfigurationChange case is RegisterCapabilityRequest: self = .globalConfigurationChange case is ShowMessageRequest: @@ -339,7 +343,7 @@ fileprivate enum TaskMetadata: DependencyTracker { default: logger.error( """ - Unknown request \(type(of: request)). Treating as a freestanding notification. \ + Unknown request \(type(of: request)). Treating as a freestanding request. \ This might lead to out-of-order request handling """ ) @@ -851,6 +855,8 @@ extension SourceKitServer: MessageHandler { await request.reply { try await supertypes(request.params) } case let request as RequestAndReply: await request.reply { try await subtypes(request.params) } + case let request as RequestAndReply: + await request.reply { try await rename(request.params) } case let request as RequestAndReply: await self.handleRequest(for: request, requestHandler: self.completion) case let request as RequestAndReply: @@ -1645,6 +1651,17 @@ extension SourceKitServer { return try await languageService.executeCommand(executeCommand) } + func rename(_ request: RenameRequest) async throws -> WorkspaceEdit? { + let uri = request.textDocument.uri + guard let workspace = await workspaceForDocument(uri: uri) else { + throw ResponseError.workspaceNotOpen(uri) + } + guard let languageService = workspace.documentService[uri] else { + return nil + } + return try await languageService.rename(request) + } + func codeAction( _ req: CodeActionRequest, workspace: Workspace, diff --git a/Sources/SourceKitLSP/Swift/RelatedIdentifiers.swift b/Sources/SourceKitLSP/Swift/RelatedIdentifiers.swift new file mode 100644 index 000000000..16c9beefe --- /dev/null +++ b/Sources/SourceKitLSP/Swift/RelatedIdentifiers.swift @@ -0,0 +1,128 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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 + +struct RelatedIdentifier { + enum Usage { + /// The definition of a function/subscript/variable/... + case definition + + /// The symbol is being referenced. + /// + /// This includes + /// - References to variables + /// - Unapplied references to functions (`myStruct.memberFunc`) + /// - Calls to subscripts (`myArray[1]`, location is `[` here, length 1) + case reference + + /// A function that is being called. + case call + + /// Unknown name usage occurs if we don't have an entry in the index that + /// tells us whether the location is a call, reference or a definition. The + /// most common reasons why this happens is if the editor is adding syntactic + /// results (eg. from comments or string literals). + case unknown + } + let range: Range + let usage: Usage +} + +extension RelatedIdentifier.Usage { + fileprivate init?(_ uid: sourcekitd_uid_t?, _ keys: sourcekitd_keys) { + switch uid { + case keys.syntacticRenameDefinition: + self = .definition + case keys.syntacticRenameReference: + self = .reference + case keys.syntacticRenameCall: + self = .call + case keys.syntacticRenameUnknown: + self = .unknown + default: + return nil + } + } + + func uid(keys: sourcekitd_keys) -> sourcekitd_uid_t { + switch self { + case .definition: + return keys.syntacticRenameDefinition + case .reference: + return keys.syntacticRenameReference + case .call: + return keys.syntacticRenameCall + case .unknown: + return keys.syntacticRenameUnknown + } + } +} + +struct RelatedIdentifiersResponse { + let relatedIdentifiers: [RelatedIdentifier] + /// The compound decl name at the requested location. This can be used as `name` parameter to a + /// `find-syntactic-rename-ranges` request. + /// + /// `nil` if `sourcekitd` is too old and doesn't return the `name` as part of the related identifiers request. + let name: String? +} + +extension SwiftLanguageServer { + func relatedIdentifiers(at position: Position, in snapshot: DocumentSnapshot, includeNonEditableBaseNames: Bool) + async throws -> RelatedIdentifiersResponse + { + guard let offset = snapshot.utf8Offset(of: position) else { + throw ResponseError.unknown("invalid position \(position)") + } + + let skreq = SKDRequestDictionary(sourcekitd: self.sourcekitd) + skreq[keys.request] = self.requests.relatedidents + skreq[keys.cancelOnSubsequentRequest] = 0 + skreq[keys.offset] = offset + skreq[keys.sourcefile] = snapshot.uri.pseudoPath + skreq[keys.includeNonEditableBaseNames] = includeNonEditableBaseNames ? 1 : 0 + + // FIXME: SourceKit should probably cache this for us. + if let compileCommand = await self.buildSettings(for: snapshot.uri) { + skreq[keys.compilerargs] = compileCommand.compilerArgs + } + + let dict = try await self.sourcekitd.send(skreq, fileContents: snapshot.text) + + guard let results: SKDResponseArray = dict[self.keys.results] else { + throw ResponseError.internalError("sourcekitd response did not contain results") + } + let name: String? = dict[self.keys.name] + + try Task.checkCancellation() + + var relatedIdentifiers: [RelatedIdentifier] = [] + + results.forEach { _, value in + if let offset: Int = value[keys.offset], + let start: Position = snapshot.positionOf(utf8Offset: offset), + let length: Int = value[keys.length], + let end: Position = snapshot.positionOf(utf8Offset: offset + length) + { + let usage = RelatedIdentifier.Usage(value[keys.nameType], keys) ?? .unknown + relatedIdentifiers.append( + RelatedIdentifier(range: start.. Int` + case keywordBaseName + + /// The internal parameter name (aka. second name) inside a function declaration + /// + /// ### Examples + /// - ` b` in `func foo(a b: Int)` + case parameterName + + /// Same as `parameterName` but cannot be removed if it is the same as the parameter's first name. This only happens + /// for subscripts where parameters are unnamed by default unless they have both a first and second name. + /// + /// ### Examples + /// The second ` a` in `subscript(a a: Int)` + case noncollapsibleParameterName + + /// The external argument label of a function parameter + /// + /// ### Examples + /// - `a` in `func foo(a b: Int)` + /// - `a` in `func foo(a: Int)` + case declArgumentLabel + + /// The argument label inside a call. + /// + /// ### Examples + /// - `a` in `foo(a: 1)` + case callArgumentLabel + + /// The colon after an argument label inside a call. This is reported so it can be removed if the parameter becomes + /// unnamed. + /// + /// ### Examples + /// - `: ` in `foo(a: 1)` + case callArgumentColon + + /// An empty range that point to the position before an unnamed argument. This is used to insert the argument label + /// if an unnamed parameter becomes named. + /// + /// ### Examples + /// - An empty range before `1` in `foo(1)`, which could expand to `foo(a: 1)` + case callArgumentCombined + + /// The argument label in a compound decl name. + /// + /// ### Examples + /// - `a` in `foo(a:)` + case selectorArgumentLabel + + init?(_ uid: sourcekitd_uid_t, keys: sourcekitd_keys) { + switch uid { + case keys.renameRangeBase: self = .baseName + case keys.renameRangeCallArgColon: self = .callArgumentColon + case keys.renameRangeCallArgCombined: self = .callArgumentCombined + case keys.renameRangeCallArgLabel: self = .callArgumentLabel + case keys.renameRangeDeclArgLabel: self = .declArgumentLabel + case keys.renameRangeKeywordBase: self = .keywordBaseName + case keys.renameRangeNoncollapsibleParam: self = .noncollapsibleParameterName + case keys.renameRangeParam: self = .parameterName + case keys.renameRangeSelectorArgLabel: self = .selectorArgumentLabel + default: return nil + } + } +} + +/// A single “piece” that is used for renaming a compound function name. +/// +/// See `SyntacticRenamePieceKind` for the different rename pieces that exist. +/// +/// ### Example +/// `foo(x: 1)` is represented by three pieces +/// - The base name `foo` +/// - The parameter name `x` +/// - The call argument colon `: `. +fileprivate struct SyntacticRenamePiece { + /// The range that represents this piece of the name + let range: Range + + /// The kind of the rename piece. + let kind: SyntacticRenamePieceKind + + /// If this piece belongs to a parameter, the index of that parameter (zero-based) or `nil` if this is the base name + /// piece. + let parameterIndex: Int? + + /// Create a `SyntacticRenamePiece` from a `sourcekitd` response. + init?(_ dict: SKDResponseDictionary, in snapshot: DocumentSnapshot, keys: sourcekitd_keys) { + guard let line: Int = dict[keys.line], + let column: Int = dict[keys.column], + let endLine: Int = dict[keys.endline], + let endColumn: Int = dict[keys.endcolumn], + let kind: sourcekitd_uid_t = dict[keys.kind] + else { + return nil + } + guard + let start = snapshot.positionOf(zeroBasedLine: line - 1, utf8Column: column - 1), + let end = snapshot.positionOf(zeroBasedLine: endLine - 1, utf8Column: endColumn - 1) + else { + return nil + } + guard let kind = SyntacticRenamePieceKind(kind, keys: keys) else { + return nil + } + + self.range = start.. [SyntacticRenameName] { + let locations = SKDRequestArray(sourcekitd: sourcekitd) + for relatedIdentifier in relatedIdentifiers.relatedIdentifiers { + let position = relatedIdentifier.range.lowerBound + guard let utf8Column = snapshot.lineTable.utf8ColumnAt(line: position.line, utf16Column: position.utf16index) + else { + logger.fault("Unable to find UTF-8 column for \(position.line):\(position.utf16index)") + continue + } + let renameLocation = SKDRequestDictionary(sourcekitd: sourcekitd) + renameLocation[keys.line] = position.line + 1 + renameLocation[keys.column] = utf8Column + 1 + renameLocation[keys.nameType] = relatedIdentifier.usage.uid(keys: keys) + locations.append(renameLocation) + } + guard let name = relatedIdentifiers.name else { + throw ResponseError.unknown("Running sourcekit-lsp with a version of sourcekitd that does not support rename") + } + let renameLocation = SKDRequestDictionary(sourcekitd: sourcekitd) + renameLocation[keys.locations] = locations + renameLocation[keys.name] = name + + let renameLocations = SKDRequestArray(sourcekitd: sourcekitd) + renameLocations.append(renameLocation) + + let skreq = SKDRequestDictionary(sourcekitd: sourcekitd) + skreq[keys.request] = requests.find_syntactic_rename_ranges + skreq[keys.sourcefile] = snapshot.uri.pseudoPath + // find-syntactic-rename-ranges is a syntactic sourcekitd request that doesn't use the in-memory file snapshot. + // We need to send the source text again. + skreq[keys.sourcetext] = snapshot.text + skreq[keys.renamelocations] = renameLocations + + let syntacticRenameRangesResponse = try await sourcekitd.send(skreq, fileContents: snapshot.text) + guard let categorizedRanges: SKDResponseArray = syntacticRenameRangesResponse[keys.categorizedranges] else { + throw ResponseError.internalError("sourcekitd did not return categorized ranges") + } + + return categorizedRanges.compactMap { SyntacticRenameName($0, in: snapshot, keys: keys) } + } + + public func rename(_ request: RenameRequest) async throws -> WorkspaceEdit? { + let snapshot = try self.documentManager.latestSnapshot(request.textDocument.uri) + + let relatedIdentifiers = try await self.relatedIdentifiers( + at: request.position, + in: snapshot, + includeNonEditableBaseNames: true + ) + + try Task.checkCancellation() + + let compoundRenameRanges = try await getSyntacticRenameRanges(relatedIdentifiers: relatedIdentifiers, in: snapshot) + + try Task.checkCancellation() + + let oldName = + if let name = relatedIdentifiers.name { + try CompoundDeclName(name) + } else { + throw ResponseError.unknown("Running sourcekit-lsp with a version of sourcekitd that does not support rename") + } + let newName = try CompoundDeclName(request.newName) + + let edits = compoundRenameRanges.flatMap { (compoundRenameRange) -> [TextEdit] in + switch compoundRenameRange.category { + case .unmatched, .mismatch: + // The location didn't match. Don't rename it + return [] + case .activeCode, .inactiveCode, .selector: + // Occurrences in active code and selectors should always be renamed. + // Inactive code is currently never returned by sourcekitd. + break + case .string, .comment: + // We currently never get any results in strings or comments because the related identifiers request doesn't + // provide any locations inside strings or comments. We would need to have a textual index to find these + // locations. + return [] + } + return compoundRenameRange.pieces.compactMap { (piece) -> TextEdit? in + if piece.kind == .baseName { + return TextEdit(range: piece.range, newText: newName.baseName) + } else if piece.kind == .keywordBaseName { + // Keyword base names can't be renamed + return nil + } + + guard let parameterIndex = piece.parameterIndex, + parameterIndex < newName.parameters.count, + parameterIndex < oldName.parameters.count + else { + // Be lenient and just keep the old parameter names if the new name doesn't specify them, eg. if we are + // renaming `func foo(a: Int, b: Int)` and the user specified `bar(x:)` as the new name. + return nil + } + let newParameterName = newName.parameters[parameterIndex] + let oldParameterName = oldName.parameters[parameterIndex] + switch piece.kind { + case .parameterName: + if newParameterName == .wildcard, piece.range.isEmpty, case .label(let oldParameterLabel) = oldParameterName { + // We are changing a named parameter to an unnamed one. If the parameter didn't have an internal parameter + // name, we need to transfer the previously external parameter name to be the internal one. + // E.g. `func foo(a: Int)` becomes `func foo(_ a: Int)`. + return TextEdit(range: piece.range, newText: " " + oldParameterLabel) + } else if let original = snapshot.lineTable[piece.range], + case .label(let newParameterLabel) = newParameterName, + newParameterLabel.trimmingCharacters(in: .whitespaces) == original.trimmingCharacters(in: .whitespaces) + { + // We are changing the external parameter name to be the same one as the internal parameter name. The + // internal name is thus no longer needed. Drop it. + // Eg. an old declaration `func foo(_ a: Int)` becomes `func foo(a: Int)` when renaming the parameter to `a` + return TextEdit(range: piece.range, newText: "") + } else { + // In all other cases, don't touch the internal parameter name. It's not part of the public API. + return nil + } + case .noncollapsibleParameterName: + // Noncollapsible parameter names should never be renamed because they are the same as `parameterName` but + // never fall into one of the two categories above. + return nil + case .declArgumentLabel: + if piece.range.isEmpty { + // If we are inserting a new external argument label where there wasn't one before, add a space after it to + // separate it from the internal name. + // E.g. `subscript(a: Int)` becomes `subscript(a a: Int)`. + return TextEdit(range: piece.range, newText: newParameterName.stringOrWildcard + " ") + } else { + // Otherwise, just update the name. + return TextEdit(range: piece.range, newText: newParameterName.stringOrWildcard) + } + case .callArgumentLabel: + // Argument labels of calls are just updated. + return TextEdit(range: piece.range, newText: newParameterName.stringOrEmpty) + case .callArgumentColon: + if case .wildcard = newParameterName { + // If the parameter becomes unnamed, remove the colon after the argument name. + return TextEdit(range: piece.range, newText: "") + } else { + return nil + } + case .callArgumentCombined: + if case .label(let newParameterLabel) = newParameterName { + // If an unnamed parameter becomes named, insert the new name and a colon. + return TextEdit(range: piece.range, newText: newParameterLabel + ": ") + } else { + return nil + } + case .selectorArgumentLabel: + return TextEdit(range: piece.range, newText: newParameterName.stringOrWildcard) + case .baseName, .keywordBaseName: + preconditionFailure("Handled above") + } + } + } + return WorkspaceEdit(changes: [ + snapshot.uri: edits + ]) + } +} + +extension LineTable { + subscript(range: Range) -> Substring? { + guard let start = self.stringIndexOf(line: range.lowerBound.line, utf16Column: range.lowerBound.utf16index), + let end = self.stringIndexOf(line: range.upperBound.line, utf16Column: range.upperBound.utf16index) + else { + return nil + } + return self.content[start.. [DocumentHighlight]? { let snapshot = try self.documentManager.latestSnapshot(req.textDocument.uri) - guard let offset = snapshot.utf8Offset(of: req.position) else { - logger.error("invalid position \(req.position, privacy: .public)") - return nil - } - - let skreq = SKDRequestDictionary(sourcekitd: self.sourcekitd) - skreq[keys.request] = self.requests.relatedidents - skreq[keys.cancelOnSubsequentRequest] = 0 - skreq[keys.offset] = offset - skreq[keys.sourcefile] = snapshot.uri.pseudoPath - - // FIXME: SourceKit should probably cache this for us. - if let compileCommand = await self.buildSettings(for: snapshot.uri) { - skreq[keys.compilerargs] = compileCommand.compilerArgs - } - - let dict = try await self.sourcekitd.send(skreq, fileContents: snapshot.text) - - guard let results: SKDResponseArray = dict[self.keys.results] else { - return [] - } - - try Task.checkCancellation() - - var highlights: [DocumentHighlight] = [] - - results.forEach { _, value in - if let offset: Int = value[self.keys.offset], - let start: Position = snapshot.positionOf(utf8Offset: offset), - let length: Int = value[self.keys.length], - let end: Position = snapshot.positionOf(utf8Offset: offset + length) - { - highlights.append( - DocumentHighlight( - range: start.. [FoldingRange]? { diff --git a/Sources/SourceKitLSP/ToolchainLanguageServer.swift b/Sources/SourceKitLSP/ToolchainLanguageServer.swift index 67d864cda..80e84453f 100644 --- a/Sources/SourceKitLSP/ToolchainLanguageServer.swift +++ b/Sources/SourceKitLSP/ToolchainLanguageServer.swift @@ -109,6 +109,8 @@ public protocol ToolchainLanguageServer: AnyObject { func executeCommand(_ req: ExecuteCommandRequest) async throws -> LSPAny? + func rename(_ request: RenameRequest) async throws -> WorkspaceEdit? + /// Crash the language server. Should be used for crash recovery testing only. func _crash() async } diff --git a/Tests/SourceKitLSPTests/RenameTests.swift b/Tests/SourceKitLSPTests/RenameTests.swift new file mode 100644 index 000000000..b146e9ebc --- /dev/null +++ b/Tests/SourceKitLSPTests/RenameTests.swift @@ -0,0 +1,489 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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 LSPTestSupport +import LanguageServerProtocol +import SKSupport +import SKTestSupport +import SourceKitLSP +import XCTest + +private func apply(edits: [TextEdit], to source: String) -> String { + var lineTable = LineTable(source) + let edits = edits.sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) + for edit in edits.reversed() { + lineTable.replace( + fromLine: edit.range.lowerBound.line, + utf16Offset: edit.range.lowerBound.utf16index, + toLine: edit.range.upperBound.line, + utf16Offset: edit.range.upperBound.utf16index, + with: edit.newText + ) + } + return lineTable.content +} + +private func assertRename( + _ markedSource: String, + newName: String, + expected: String, + file: StaticString = #file, + line: UInt = #line +) async throws { + let testClient = try await TestSourceKitLSPClient() + let uri = DocumentURI.for(.swift) + let positions = testClient.openDocument(markedSource, uri: uri) + let response = try await testClient.send( + RenameRequest( + textDocument: TextDocumentIdentifier(uri), + position: positions["1️⃣"], + newName: newName + ) + ) + let edits = try XCTUnwrap(response?.changes?[uri], file: file, line: line) + let source = extractMarkers(markedSource).textWithoutMarkers + let renamed = apply(edits: edits, to: source) + XCTAssertEqual(renamed, expected, file: file, line: line) +} + +final class RenameTests: XCTestCase { + func testRenameVariableBaseName() async throws { + try await assertRename( + """ + let 1️⃣foo = 1 + print(foo) + """, + newName: "bar", + expected: """ + let bar = 1 + print(bar) + """ + ) + } + + func testRenameFunctionBaseName() async throws { + try await assertRename( + """ + func 1️⃣foo() {} + foo() + """, + newName: "bar()", + expected: """ + func bar() {} + bar() + """ + ) + } + + func testRenameFunctionParameter() async throws { + try await assertRename( + """ + func 1️⃣foo(x: Int) {} + foo(x: 1) + """, + newName: "bar(y:)", + expected: """ + func bar(y: Int) {} + bar(y: 1) + """ + ) + } + + func testSecondParameterNameIfMatches() async throws { + try await assertRename( + """ + func 1️⃣foo(x y: Int) {} + foo(x: 1) + """, + newName: "foo(y:)", + expected: """ + func foo(y: Int) {} + foo(y: 1) + """ + ) + } + + func testIntroduceLabel() async throws { + try await assertRename( + """ + func 1️⃣foo(_ y: Int) {} + foo(1) + """, + newName: "foo(y:)", + expected: """ + func foo(y: Int) {} + foo(y: 1) + """ + ) + } + + func testRemoveLabel() async throws { + try await assertRename( + """ + func 1️⃣foo(x: Int) {} + foo(x: 1) + """, + newName: "foo(_:)", + expected: """ + func foo(_ x: Int) {} + foo(1) + """ + ) + } + + func testRemoveLabelWithExistingInternalName() async throws { + try await assertRename( + """ + func 1️⃣foo(x a: Int) {} + foo(x: 1) + """, + newName: "foo(_:)", + expected: """ + func foo(_ a: Int) {} + foo(1) + """ + ) + } + + func testRenameSubscript() async throws { + try await assertRename( + """ + struct Foo { + 1️⃣subscript(x x: Int) -> Int { x } + } + Foo()[x: 1] + """, + newName: "subscript(y:)", + expected: """ + struct Foo { + subscript(y x: Int) -> Int { x } + } + Foo()[y: 1] + """ + ) + } + + func testRemoveExternalLabelFromSubscript() async throws { + try await assertRename( + """ + struct Foo { + 1️⃣subscript(x x: Int) -> Int { x } + } + Foo()[x: 1] + """, + newName: "subscript(_:)", + expected: """ + struct Foo { + subscript(_ x: Int) -> Int { x } + } + Foo()[1] + """ + ) + } + + func testIntroduceExternalLabelFromSubscript() async throws { + try await assertRename( + """ + struct Foo { + 1️⃣subscript(x: Int) -> Int { x } + } + Foo()[1] + """, + newName: "subscript(x:)", + expected: """ + struct Foo { + subscript(x x: Int) -> Int { x } + } + Foo()[x: 1] + """ + ) + } + + func testIgnoreRenameSubscriptBaseName() async throws { + try await assertRename( + """ + struct Foo { + 1️⃣subscript(x: Int) -> Int { x } + } + Foo()[1] + """, + newName: "arrayAccess(x:)", + expected: """ + struct Foo { + subscript(x x: Int) -> Int { x } + } + Foo()[x: 1] + """ + ) + } + + func testRenameInitializerLabels() async throws { + try await assertRename( + """ + struct Foo { + 1️⃣init(x: Int) {} + } + Foo(x: 1) + """, + newName: "init(y:)", + expected: """ + struct Foo { + init(y: Int) {} + } + Foo(y: 1) + """ + ) + } + + func testIgnoreRenameOfInitBaseName() async throws { + try await assertRename( + """ + struct Foo { + 1️⃣init(x: Int) {} + } + Foo(x: 1) + """, + newName: "create(y:)", + expected: """ + struct Foo { + init(y: Int) {} + } + Foo(y: 1) + """ + ) + } + + func testRenameCompoundFunctionName() async throws { + try await assertRename( + """ + func 1️⃣foo(a: Int) {} + _ = foo(a:) + """, + newName: "foo(b:)", + expected: """ + func foo(b: Int) {} + _ = foo(b:) + """ + ) + } + + func testRemoveLabelFromCompoundFunctionName() async throws { + try await assertRename( + """ + func 1️⃣foo(a: Int) {} + _ = foo(a:) + """, + newName: "foo(_:)", + expected: """ + func foo(_ a: Int) {} + _ = foo(_:) + """ + ) + } + + func testIntroduceLabelToCompoundFunctionName() async throws { + try await assertRename( + """ + func 1️⃣foo(_ a: Int) {} + _ = foo(_:) + """, + newName: "foo(a:)", + expected: """ + func foo(a: Int) {} + _ = foo(a:) + """ + ) + } + + func testRenameFromReference() async throws { + try await assertRename( + """ + func foo(_ a: Int) {} + _ = 1️⃣foo(_:) + """, + newName: "foo(a:)", + expected: """ + func foo(a: Int) {} + _ = foo(a:) + """ + ) + } + + func testRenameMultipleParameters() async throws { + try await assertRename( + """ + func 1️⃣foo(a: Int, b: Int) {} + foo(a: 1, b: 1) + """, + newName: "foo(x:y:)", + expected: """ + func foo(x: Int, y: Int) {} + foo(x: 1, y: 1) + """ + ) + } + + func testDontRenameParametersOmittedFromNewName() async throws { + try await assertRename( + """ + func 1️⃣foo(a: Int, b: Int) {} + foo(a: 1, b: 1) + """, + newName: "foo(x:)", + expected: """ + func foo(x: Int, b: Int) {} + foo(x: 1, b: 1) + """ + ) + } + + func testIgnoreAdditionalParametersInNewName() async throws { + try await assertRename( + """ + func 1️⃣foo(a: Int) {} + foo(a: 1) + """, + newName: "foo(x:y:)", + expected: """ + func foo(x: Int) {} + foo(x: 1) + """ + ) + } + + func testOnlySpecifyBaseNameWhenRenamingFunction() async throws { + try await assertRename( + """ + func 1️⃣foo(a: Int) {} + foo(a: 1) + """, + newName: "bar", + expected: """ + func bar(a: Int) {} + bar(a: 1) + """ + ) + } + + func testIgnoreParametersInNewNameWhenRenamingVariable() async throws { + try await assertRename( + """ + let 1️⃣foo = 1 + _ = foo + """, + newName: "bar(x:y:)", + expected: """ + let bar = 1 + _ = bar + """ + ) + } + + func testErrorIfNewNameDoesntContainClosingParenthesis() async throws { + // FIXME: syntactic rename does not support in-memory files... It should + let ws = try await IndexedSingleSwiftFileWorkspace( + """ + func 1️⃣foo(a: Int) {} + foo(a: 1) + """ + ) + let request = RenameRequest( + textDocument: TextDocumentIdentifier(ws.fileURI), + position: ws.positions["1️⃣"], + newName: "bar(x:" + ) + await assertThrowsError(try await ws.testClient.send(request)) + } + + func testErrorIfNewNameContainsTextAfterParenthesis() async throws { + // FIXME: syntactic rename does not support in-memory files... It should + let ws = try await IndexedSingleSwiftFileWorkspace( + """ + func 1️⃣foo(a: Int) {} + foo(a: 1) + """ + ) + let request = RenameRequest( + textDocument: TextDocumentIdentifier(ws.fileURI), + position: ws.positions["1️⃣"], + newName: "bar(x:)other:" + ) + await assertThrowsError(try await ws.testClient.send(request)) + } + + func testSpacesInNewParameterNames() async throws { + try await assertRename( + """ + func 1️⃣foo(a: Int) {} + foo(a: 1) + """, + newName: "bar ( x : )", + expected: """ + func bar ( x : Int) {} + bar ( x : 1) + """ + ) + } + + func testRenameOperator() async throws { + try await assertRename( + """ + struct Foo {} + func 1️⃣+(x: Foo, y: Foo) {} + Foo() + Foo() + """, + newName: "-", + expected: """ + struct Foo {} + func -(x: Foo, y: Foo) {} + Foo() - Foo() + """ + ) + } + + func testRenameParameterToEmptyName() async throws { + try await assertRename( + """ + func 1️⃣foo(x: Int) {} + foo(x: 1) + """, + newName: "bar(:)", + expected: """ + func bar(_ x: Int) {} + bar(1) + """ + ) + } + + func testRenameInsidePoundSelector() async throws { + #if !canImport(Darwin) + throw XCTSkip("#selector in test case doesn't compile without Objective-C runtime.") + #endif + try await assertRename( + """ + import Foundation + class Foo: NSObject { + @objc public func 1️⃣bar(x: Int) {} + } + _ = #selector(Foo.bar(x:)) + """, + newName: "foo(y:)", + expected: """ + import Foundation + class Foo: NSObject { + @objc public func foo(y: Int) {} + } + _ = #selector(Foo.foo(y:)) + """ + ) + } +}