From 67d3137b29c34b1ad8b27e04f8f279e3854572aa Mon Sep 17 00:00:00 2001 From: Mostafa Date: Sun, 21 Dec 2025 18:17:33 +0100 Subject: [PATCH 1/3] Add Copy Objective-C Selector code action --- Sources/SourceKitD/sourcekitd_uids.swift | 3 + .../CopyObjCSelector.swift | 46 ++++++++++++ .../CopyObjCSelectorCommand.swift | 73 +++++++++++++++++++ .../SwiftLanguageService/SwiftCommand.swift | 1 + .../SwiftLanguageService.swift | 50 +++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 Sources/SwiftLanguageService/CopyObjCSelector.swift create mode 100644 Sources/SwiftLanguageService/CopyObjCSelectorCommand.swift diff --git a/Sources/SourceKitD/sourcekitd_uids.swift b/Sources/SourceKitD/sourcekitd_uids.swift index 09a73cdb9..f92b5a28c 100644 --- a/Sources/SourceKitD/sourcekitd_uids.swift +++ b/Sources/SourceKitD/sourcekitd_uids.swift @@ -878,6 +878,8 @@ package struct sourcekitd_api_requests { package let findLocalRenameRanges: sourcekitd_api_uid_t /// `source.request.semantic.refactoring` package let semanticRefactoring: sourcekitd_api_uid_t + /// `source.request.objc.selector` + package let objcSelector: sourcekitd_api_uid_t /// `source.request.enable-compile-notifications` package let enableCompileNotifications: sourcekitd_api_uid_t /// `source.request.test_notification` @@ -951,6 +953,7 @@ package struct sourcekitd_api_requests { findRenameRanges = api.uid_get_from_cstr("source.request.find-syntactic-rename-ranges")! findLocalRenameRanges = api.uid_get_from_cstr("source.request.find-local-rename-ranges")! semanticRefactoring = api.uid_get_from_cstr("source.request.semantic.refactoring")! + objcSelector = api.uid_get_from_cstr("source.request.objc.selector")! enableCompileNotifications = api.uid_get_from_cstr("source.request.enable-compile-notifications")! testNotification = api.uid_get_from_cstr("source.request.test_notification")! collectExpressionType = api.uid_get_from_cstr("source.request.expression.type")! diff --git a/Sources/SwiftLanguageService/CopyObjCSelector.swift b/Sources/SwiftLanguageService/CopyObjCSelector.swift new file mode 100644 index 000000000..9f14e6fdc --- /dev/null +++ b/Sources/SwiftLanguageService/CopyObjCSelector.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// 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 +@_spi(SourceKitLSP) import LanguageServerProtocol +@_spi(SourceKitLSP) import SKLogging +import SourceKitD +import SourceKitLSP + +extension SwiftLanguageService { + /// Executes the direct SourceKitD request to fetch the Objective-C selector at the cursor position. + func copyObjCSelector( + _ command: CopyObjCSelectorCommand + ) async throws -> LSPAny { + let snapshot = try documentManager.latestSnapshot(command.textDocument.uri) + let compileCommand = await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: true) + let offset = snapshot.utf8Offset(of: command.positionRange.lowerBound) + + let keys = self.keys + let skreq = sourcekitd.dictionary([:]) + skreq.set(keys.offset, to: offset) + skreq.set(keys.sourceFile, to: snapshot.uri.sourcekitdSourceFile) + if let primaryFile = snapshot.uri.primaryFile?.pseudoPath { + skreq.set(keys.primaryFile, to: primaryFile) + } + if let compilerArgs = compileCommand?.compilerArgs { + skreq.set(keys.compilerArgs, to: compilerArgs as [any SKDRequestValue]) + } + + let dict = try await send(sourcekitdRequest: \.objcSelector, skreq, snapshot: snapshot) + if let selectorText: String = dict[keys.text] { + return .string(selectorText) + } + + throw ResponseError.unknown("No Objective-C selector found at cursor position") + } +} diff --git a/Sources/SwiftLanguageService/CopyObjCSelectorCommand.swift b/Sources/SwiftLanguageService/CopyObjCSelectorCommand.swift new file mode 100644 index 000000000..d1a4bb4ce --- /dev/null +++ b/Sources/SwiftLanguageService/CopyObjCSelectorCommand.swift @@ -0,0 +1,73 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +@_spi(SourceKitLSP) package import LanguageServerProtocol +import SourceKitD + +package struct CopyObjCSelectorCommand: SwiftCommand { + package static let identifier: String = "copy.objc.selector.command" + + package var title = "Copy Objective-C Selector" + package var actionString = "source.refactoring.kind.copy.objc.selector" + + package var positionRange: Range + package var textDocument: LanguageServerProtocol.TextDocumentIdentifier + + package init(positionRange: Range, textDocument: LanguageServerProtocol.TextDocumentIdentifier) { + self.positionRange = positionRange + self.textDocument = textDocument + } + + package 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 = LanguageServerProtocol.TextDocumentIdentifier(fromLSPDictionary: documentDict) + else { + return nil + } + + self.init( + title: title, + actionString: actionString, + positionRange: positionRange, + textDocument: textDocument + ) + } + + package init( + title: String, + actionString: String, + positionRange: Range, + textDocument: LanguageServerProtocol.TextDocumentIdentifier + ) { + self.title = title + self.actionString = actionString + self.positionRange = positionRange + self.textDocument = textDocument + } + + package 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/SwiftLanguageService/SwiftCommand.swift b/Sources/SwiftLanguageService/SwiftCommand.swift index c6a51197a..9a92f0a49 100644 --- a/Sources/SwiftLanguageService/SwiftCommand.swift +++ b/Sources/SwiftLanguageService/SwiftCommand.swift @@ -51,6 +51,7 @@ extension SwiftLanguageService { [ SemanticRefactorCommand.self, ExpandMacroCommand.self, + CopyObjCSelectorCommand.self, ].map { (command: any SwiftCommand.Type) in command.identifier } diff --git a/Sources/SwiftLanguageService/SwiftLanguageService.swift b/Sources/SwiftLanguageService/SwiftLanguageService.swift index 3bebc98a0..e4de9fb9d 100644 --- a/Sources/SwiftLanguageService/SwiftLanguageService.swift +++ b/Sources/SwiftLanguageService/SwiftLanguageService.swift @@ -954,6 +954,8 @@ extension SwiftLanguageService { skreq.set(self.keys.retrieveRefactorActions, to: 1) } + let snapshot = try documentManager.latestSnapshot(params.textDocument.uri) + let cursorInfoResponse = try await cursorInfo( params.textDocument.uri, params.range, @@ -979,9 +981,55 @@ extension SwiftLanguageService { refactorActions.append(CodeAction(title: expandMacroCommand.title, kind: .refactor, command: expandMacroCommand)) } + if let copySelectorAction = await makeCopyObjCSelectorAction( + range: params.range, + textDocument: params.textDocument, + snapshot: snapshot + ) { + refactorActions.append(copySelectorAction) + } + return refactorActions } + private func makeCopyObjCSelectorAction( + range: Range, + textDocument: LanguageServerProtocol.TextDocumentIdentifier, + snapshot: DocumentSnapshot + ) async -> CodeAction? { + let compileCommand = await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: true) + let offset = snapshot.utf8Offset(of: range.lowerBound) + + let keys = self.keys + let skreq = sourcekitd.dictionary([ + keys.offset: offset, + keys.sourceFile: snapshot.uri.sourcekitdSourceFile, + keys.primaryFile: primaryFile, + keys.compilerArgs: compilerArgs as [any SKDRequestValue] + ]) + if let primaryFile = snapshot.uri.primaryFile?.pseudoPath { + skreq.set(keys.primaryFile, to: primaryFile) + } + if let compilerArgs = compileCommand?.compilerArgs { + skreq.set(keys.compilerArgs, to: compilerArgs as [any SKDRequestValue]) + } + + do { + let dict = try await send(sourcekitdRequest: \.objcSelector, skreq, snapshot: snapshot) + if dict[keys.text] as String? != nil { + let copyCommand = CopyObjCSelectorCommand( + positionRange: range, + textDocument: textDocument + ).asCommand() + return CodeAction(title: "Copy Objective-C Selector", kind: .refactor, command: copyCommand) + } + } catch { + logger.debug("CopyObjCSelector: applicability check failed: \(error.forLogging)") + } + + return nil + } + func retrieveQuickFixCodeActions(_ params: CodeActionRequest) async throws -> [CodeAction] { let snapshot = try await self.latestSnapshot(for: params.textDocument.uri) let buildSettings = await self.compileCommand(for: params.textDocument.uri, fallbackAfterTimeout: true) @@ -1096,6 +1144,8 @@ extension SwiftLanguageService { try await semanticRefactoring(command) } else if let command = req.swiftCommand(ofType: ExpandMacroCommand.self) { try await expandMacro(command) + } else if let command = req.swiftCommand(ofType: CopyObjCSelectorCommand.self) { + return try await copyObjCSelector(command) } else if let command = req.swiftCommand(ofType: RemoveUnusedImportsCommand.self) { try await removeUnusedImports(command) } else { From 89d989b74dd2b9123ea26035068e00910812820d Mon Sep 17 00:00:00 2001 From: iMostafa Date: Mon, 22 Dec 2025 13:32:34 +0100 Subject: [PATCH 2/3] Create the request using the dict --- Sources/SwiftLanguageService/CopyObjCSelector.swift | 8 +++++--- .../SwiftLanguageService/SwiftLanguageService.swift | 10 +++------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/Sources/SwiftLanguageService/CopyObjCSelector.swift b/Sources/SwiftLanguageService/CopyObjCSelector.swift index 9f14e6fdc..c4304729b 100644 --- a/Sources/SwiftLanguageService/CopyObjCSelector.swift +++ b/Sources/SwiftLanguageService/CopyObjCSelector.swift @@ -26,9 +26,11 @@ extension SwiftLanguageService { let offset = snapshot.utf8Offset(of: command.positionRange.lowerBound) let keys = self.keys - let skreq = sourcekitd.dictionary([:]) - skreq.set(keys.offset, to: offset) - skreq.set(keys.sourceFile, to: snapshot.uri.sourcekitdSourceFile) + let skreq = sourcekitd.dictionary([ + keys.offset: offset, + keys.sourceFile: snapshot.uri.sourcekitdSourceFile, + ]) + if let primaryFile = snapshot.uri.primaryFile?.pseudoPath { skreq.set(keys.primaryFile, to: primaryFile) } diff --git a/Sources/SwiftLanguageService/SwiftLanguageService.swift b/Sources/SwiftLanguageService/SwiftLanguageService.swift index e4de9fb9d..f50fda81e 100644 --- a/Sources/SwiftLanguageService/SwiftLanguageService.swift +++ b/Sources/SwiftLanguageService/SwiftLanguageService.swift @@ -1001,18 +1001,14 @@ extension SwiftLanguageService { let offset = snapshot.utf8Offset(of: range.lowerBound) let keys = self.keys + let primaryFile = snapshot.uri.primaryFile?.pseudoPath + let compilerArgs: [any SKDRequestValue] = compileCommand?.compilerArgs ?? [] let skreq = sourcekitd.dictionary([ keys.offset: offset, keys.sourceFile: snapshot.uri.sourcekitdSourceFile, keys.primaryFile: primaryFile, - keys.compilerArgs: compilerArgs as [any SKDRequestValue] + keys.compilerArgs: compilerArgs ]) - if let primaryFile = snapshot.uri.primaryFile?.pseudoPath { - skreq.set(keys.primaryFile, to: primaryFile) - } - if let compilerArgs = compileCommand?.compilerArgs { - skreq.set(keys.compilerArgs, to: compilerArgs as [any SKDRequestValue]) - } do { let dict = try await send(sourcekitdRequest: \.objcSelector, skreq, snapshot: snapshot) From e37ec29e3086035ff9f477b2443bb2e49e9db6da Mon Sep 17 00:00:00 2001 From: iMostafa Date: Mon, 22 Dec 2025 23:48:43 +0100 Subject: [PATCH 3/3] Use Copy option --- .../CopyObjCSelector.swift | 39 ++++++------ .../SwiftLanguageService.swift | 60 +++++-------------- 2 files changed, 35 insertions(+), 64 deletions(-) diff --git a/Sources/SwiftLanguageService/CopyObjCSelector.swift b/Sources/SwiftLanguageService/CopyObjCSelector.swift index c4304729b..420b692ee 100644 --- a/Sources/SwiftLanguageService/CopyObjCSelector.swift +++ b/Sources/SwiftLanguageService/CopyObjCSelector.swift @@ -17,32 +17,35 @@ import SourceKitD import SourceKitLSP extension SwiftLanguageService { - /// Executes the direct SourceKitD request to fetch the Objective-C selector at the cursor position. + /// Executes the refactoring-based copy and extracts the selector string without applying edits. func copyObjCSelector( _ command: CopyObjCSelectorCommand ) async throws -> LSPAny { - let snapshot = try documentManager.latestSnapshot(command.textDocument.uri) - let compileCommand = await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: true) - let offset = snapshot.utf8Offset(of: command.positionRange.lowerBound) + let refactorCommand = SemanticRefactorCommand( + title: command.title, + actionString: command.actionString, + positionRange: command.positionRange, + textDocument: command.textDocument + ) - let keys = self.keys - let skreq = sourcekitd.dictionary([ - keys.offset: offset, - keys.sourceFile: snapshot.uri.sourcekitdSourceFile, - ]) + let semanticRefactor = try await self.refactoring(refactorCommand) - if let primaryFile = snapshot.uri.primaryFile?.pseudoPath { - skreq.set(keys.primaryFile, to: primaryFile) - } - if let compilerArgs = compileCommand?.compilerArgs { - skreq.set(keys.compilerArgs, to: compilerArgs as [any SKDRequestValue]) + guard let edit = semanticRefactor.edit.changes?.first?.value.first else { + throw ResponseError.unknown("No selector found at cursor position") } - let dict = try await send(sourcekitdRequest: \.objcSelector, skreq, snapshot: snapshot) - if let selectorText: String = dict[keys.text] { - return .string(selectorText) + let prefix = "// Objective-C Selector: " + if let range = edit.newText.range(of: prefix) { + let selector = String(edit.newText[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) + if let sourceKitLSPServer { + // Notify with just the selector text (no prefix, no buttons). + sourceKitLSPServer.sendNotificationToClient( + ShowMessageNotification(type: .info, message: selector) + ) + } + return .string(selector) } - throw ResponseError.unknown("No Objective-C selector found at cursor position") + throw ResponseError.unknown("Could not extract selector from refactoring result") } } diff --git a/Sources/SwiftLanguageService/SwiftLanguageService.swift b/Sources/SwiftLanguageService/SwiftLanguageService.swift index f50fda81e..551f1beec 100644 --- a/Sources/SwiftLanguageService/SwiftLanguageService.swift +++ b/Sources/SwiftLanguageService/SwiftLanguageService.swift @@ -965,13 +965,23 @@ extension SwiftLanguageService { var canInlineMacro = false - var refactorActions = cursorInfoResponse.refactorActions.compactMap { - let lspCommand = $0.asCommand() + var refactorActions: [CodeAction] = cursorInfoResponse.refactorActions.compactMap { action in + if action.actionString == "source.refactoring.kind.copy.objc.selector" { + let copyCommand = CopyObjCSelectorCommand( + title: "Copy Objective-C Selector", + actionString: action.actionString, + positionRange: params.range, + textDocument: params.textDocument + ).asCommand() + return CodeAction(title: "Copy Objective-C Selector", kind: .refactor, command: copyCommand) + } + + let lspCommand = action.asCommand() if !canInlineMacro { - canInlineMacro = $0.actionString == "source.refactoring.kind.inline.macro" + canInlineMacro = action.actionString == "source.refactoring.kind.inline.macro" } - return CodeAction(title: $0.title, kind: .refactor, command: lspCommand) + return CodeAction(title: action.title, kind: .refactor, command: lspCommand) } if canInlineMacro { @@ -981,51 +991,9 @@ extension SwiftLanguageService { refactorActions.append(CodeAction(title: expandMacroCommand.title, kind: .refactor, command: expandMacroCommand)) } - if let copySelectorAction = await makeCopyObjCSelectorAction( - range: params.range, - textDocument: params.textDocument, - snapshot: snapshot - ) { - refactorActions.append(copySelectorAction) - } - return refactorActions } - private func makeCopyObjCSelectorAction( - range: Range, - textDocument: LanguageServerProtocol.TextDocumentIdentifier, - snapshot: DocumentSnapshot - ) async -> CodeAction? { - let compileCommand = await self.compileCommand(for: snapshot.uri, fallbackAfterTimeout: true) - let offset = snapshot.utf8Offset(of: range.lowerBound) - - let keys = self.keys - let primaryFile = snapshot.uri.primaryFile?.pseudoPath - let compilerArgs: [any SKDRequestValue] = compileCommand?.compilerArgs ?? [] - let skreq = sourcekitd.dictionary([ - keys.offset: offset, - keys.sourceFile: snapshot.uri.sourcekitdSourceFile, - keys.primaryFile: primaryFile, - keys.compilerArgs: compilerArgs - ]) - - do { - let dict = try await send(sourcekitdRequest: \.objcSelector, skreq, snapshot: snapshot) - if dict[keys.text] as String? != nil { - let copyCommand = CopyObjCSelectorCommand( - positionRange: range, - textDocument: textDocument - ).asCommand() - return CodeAction(title: "Copy Objective-C Selector", kind: .refactor, command: copyCommand) - } - } catch { - logger.debug("CopyObjCSelector: applicability check failed: \(error.forLogging)") - } - - return nil - } - func retrieveQuickFixCodeActions(_ params: CodeActionRequest) async throws -> [CodeAction] { let snapshot = try await self.latestSnapshot(for: params.textDocument.uri) let buildSettings = await self.compileCommand(for: params.textDocument.uri, fallbackAfterTimeout: true)