diff --git a/Sources/SwiftLanguageService/CodeActions/ConvertCommentToDocComment.swift b/Sources/SwiftLanguageService/CodeActions/ConvertCommentToDocComment.swift index 2464d6d3a..6a19556af 100644 --- a/Sources/SwiftLanguageService/CodeActions/ConvertCommentToDocComment.swift +++ b/Sources/SwiftLanguageService/CodeActions/ConvertCommentToDocComment.swift @@ -39,7 +39,7 @@ extension ConvertCommentToDocComment: SyntaxRefactoringCodeActionProvider { static let title = "Convert Comment to Doc Comment" static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> DeclSyntax? { - let cursorPosition = scope.snapshot.absolutePosition(of: scope.request.range.lowerBound) + let cursorPosition = scope.snapshot.absolutePosition(of: scope.requestedRange.lowerBound) guard let token = scope.file.token(at: cursorPosition) else { return nil } diff --git a/Sources/SwiftLanguageService/CodeActions/ConvertStoredPropertyToComputed.swift b/Sources/SwiftLanguageService/CodeActions/ConvertStoredPropertyToComputed.swift new file mode 100644 index 000000000..cb92eae51 --- /dev/null +++ b/Sources/SwiftLanguageService/CodeActions/ConvertStoredPropertyToComputed.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import SourceKitLSP +import SwiftExtensions +import SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder + +extension ConvertStoredPropertyToComputed: SyntaxRefactoringCodeActionProvider { + static let title: String = "Convert Stored Property to Computed Property" + + static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> VariableDeclSyntax? { + return scope.innermostNodeContainingRange?.as(VariableDeclSyntax.self) + ?? scope.innermostNodeContainingRange?.parent?.as(VariableDeclSyntax.self) + } + + static func refactoringContext( + for node: VariableDeclSyntax, + in scope: SyntaxCodeActionScope + ) async -> SyntaxCodeActionContextResult { + guard node.bindings.contains(where: { $0.typeAnnotation?.type == nil }) else { + // All types are syntactically specified, we don't need to resolve the semantic type + return .context(Context()) + } + guard let binding = node.bindings.only, + let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier + else { + // We can only resolve type information for a single variable binding at the moment. If this is variable decl with multiple bindings, still + // offer the refactoring action and introduce placeholders for the type annotation. + return .context(Context()) + } + if scope.resolveSupport?.canResolveEdit ?? false { + return .resolveEditLazily + } + // Cursor info reports type as `_` if it cannot determine the type. + if let type = try? await scope.cursorInfo(at: scope.snapshot.position(of: identifier.position)).only?.typeName, + type != "_" + { + return .context(Context(type: "\(raw: type)")) + } + return .context(Context()) + } +} diff --git a/Sources/SwiftLanguageService/CodeActions/RemoveUnusedImports.swift b/Sources/SwiftLanguageService/CodeActions/RemoveUnusedImports.swift index 294b233f3..b7ad41e60 100644 --- a/Sources/SwiftLanguageService/CodeActions/RemoveUnusedImports.swift +++ b/Sources/SwiftLanguageService/CodeActions/RemoveUnusedImports.swift @@ -72,8 +72,14 @@ extension SwiftLanguageService { let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) guard - let node = SyntaxCodeActionScope(snapshot: snapshot, syntaxTree: syntaxTree, request: request)? - .innermostNodeContainingRange, + let node = SyntaxCodeActionScope( + resolveSupport: nil, + snapshot: snapshot, + syntaxTree: syntaxTree, + requestedRange: request.range, + swiftLanguageService: self + )? + .innermostNodeContainingRange, node.findParentOfSelf(ofType: ImportDeclSyntax.self, stoppingIf: { _ in false }) != nil else { // Only offer the remove unused imports code action on an import statement. diff --git a/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActionProvider.swift b/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActionProvider.swift index 4379a503c..1e5d93234 100644 --- a/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActionProvider.swift +++ b/Sources/SwiftLanguageService/CodeActions/SyntaxCodeActionProvider.swift @@ -16,27 +16,39 @@ import SourceKitLSP import SwiftRefactor import SwiftSyntax +extension TextDocumentClientCapabilities.CodeAction.ResolveSupportProperties { + var canResolveEdit: Bool { + return self.properties.contains("edit") + } +} + /// Describes types that provide one or more code actions based on purely /// syntactic information. protocol SyntaxCodeActionProvider: SendableMetatype { /// Produce code actions within the given scope. Each code action /// corresponds to one syntactic transformation that can be performed, such /// as adding or removing separators from an integer literal. - static func codeActions(in scope: SyntaxCodeActionScope) -> [CodeAction] + static func codeActions(in scope: SyntaxCodeActionScope) async -> [CodeAction] } /// Defines the scope in which a syntactic code action occurs. struct SyntaxCodeActionScope { + /// Whether the client supports the codeAction/resolve request. + /// + /// This is set to `nil` during the `codeAction/resolve` request. + var resolveSupport: TextDocumentClientCapabilities.CodeAction.ResolveSupportProperties? + /// The snapshot of the document on which the code actions will be evaluated. var snapshot: DocumentSnapshot - /// The actual code action request, which can specify additional parameters - /// to guide the code actions. - var request: CodeActionRequest - /// The source file in which the syntactic code action will operate. var file: SourceFileSyntax + /// The originally requested range in the original code action request. + /// + /// Generally, `range` should be preferred because it performs useful adjustments to extend the range to the start and end of tokens. + var requestedRange: Range + /// The UTF-8 byte range in the source file in which code actions should be /// considered, i.e., where the cursor or selection is. var range: Range @@ -44,22 +56,48 @@ struct SyntaxCodeActionScope { /// The innermost node that contains the entire selected source range var innermostNodeContainingRange: Syntax? + /// The language service from which this code action is going to be resolved. + /// + /// Used to to retrieve cursor info if necessary. + private let swiftLanguageService: SwiftLanguageService + init?( + resolveSupport: TextDocumentClientCapabilities.CodeAction.ResolveSupportProperties?, snapshot: DocumentSnapshot, syntaxTree file: SourceFileSyntax, - request: CodeActionRequest + requestedRange: Range, + swiftLanguageService: SwiftLanguageService ) { + self.resolveSupport = resolveSupport self.snapshot = snapshot - self.request = request + self.requestedRange = requestedRange self.file = file - guard let left = tokenForRefactoring(at: request.range.lowerBound, snapshot: snapshot, syntaxTree: file), - let right = tokenForRefactoring(at: request.range.upperBound, snapshot: snapshot, syntaxTree: file) + guard let left = tokenForRefactoring(at: requestedRange.lowerBound, snapshot: snapshot, syntaxTree: file), + let right = tokenForRefactoring(at: requestedRange.upperBound, snapshot: snapshot, syntaxTree: file) else { return nil } self.range = left.position.. [CursorInfo] { + let compileCommand = await swiftLanguageService.compileCommand( + for: snapshot.uri, + fallbackAfterTimeout: true + ) + + return try await swiftLanguageService.cursorInfo( + snapshot, + compileCommand: compileCommand, + position.. + + init( + actionType: Metatype.Type, + document: VersionedTextDocumentIdentifier, + range: Range, + ) { + self.action = "\(Metatype.self)" + self.document = document + self.range = range + } +} + +enum SyntaxCodeActionContextResult { + /// The cont + case context(Context) + /// Report a code action without the `edit` properties. The client is expected to send a `codeAction/resolve` request to resolve the edit. + /// + /// Must only be returned if the the client can resolve edits. + case resolveEditLazily +} + /// Protocol that adapts a SyntaxRefactoringProvider (that comes from /// swift-syntax) into a SyntaxCodeActionProvider. protocol SyntaxRefactoringCodeActionProvider: SyntaxCodeActionProvider, EditRefactoringProvider { @@ -24,18 +58,45 @@ protocol SyntaxRefactoringCodeActionProvider: SyntaxCodeActionProvider, EditRefa /// scope. static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> Input? - static func refactoringContext(for scope: SyntaxCodeActionScope) -> Context + /// Retrieve the refactoring context to refactor the given node in the given scope. + /// + /// Throwing an error from this method causes the code action to be reported without any workspace edits. The client is expected to send a + /// `codeAction/resolve` request when the user selects the code action in order to retrieve the semantic information and compute the actual edits. + static func refactoringContext( + for node: Input, + in scope: SyntaxCodeActionScope + ) async -> SyntaxCodeActionContextResult } -/// SyntaxCodeActionProviders with a \c Void context can automatically be -/// adapted provide a code action based on their refactoring operation. extension SyntaxRefactoringCodeActionProvider { - static func codeActions(in scope: SyntaxCodeActionScope) -> [CodeAction] { + static func codeActions(in scope: SyntaxCodeActionScope) async -> [CodeAction] { guard let node = nodeToRefactor(in: scope) else { return [] } - guard let sourceEdits = try? Self.textRefactor(syntax: node, in: refactoringContext(for: scope)) else { + let context: Context + switch await refactoringContext(for: node, in: scope) { + case .context(let c): context = c + case .resolveEditLazily: + guard scope.resolveSupport?.canResolveEdit ?? false else { + logger.fault( + "Refactoring action \(Self.self) requested lazy resolution of edits but client cannot resolve edits" + ) + return [] + } + return [ + CodeAction( + title: Self.title, + kind: .refactorInline, + data: UnresolvedCodeActionData( + actionType: Self.self, + document: VersionedTextDocumentIdentifier(scope.snapshot.uri, version: scope.snapshot.version), + range: scope.requestedRange + ).encodeToLSPAny() + ) + ] + } + guard let sourceEdits = try? Self.textRefactor(syntax: node, in: context) else { return [] } @@ -53,9 +114,14 @@ extension SyntaxRefactoringCodeActionProvider { } } +/// SyntaxCodeActionProviders with a `Void` context can automatically be adapted provide a code action based on their +/// refactoring operation. extension SyntaxRefactoringCodeActionProvider where Context == Void { - static func refactoringContext(for scope: SyntaxCodeActionScope) -> Context { - return () + static func refactoringContext( + for node: Input, + in scope: SyntaxCodeActionScope + ) -> SyntaxCodeActionContextResult { + return .context(()) } } diff --git a/Sources/SwiftLanguageService/CursorInfo.swift b/Sources/SwiftLanguageService/CursorInfo.swift index f75304e54..4b0cec91d 100644 --- a/Sources/SwiftLanguageService/CursorInfo.swift +++ b/Sources/SwiftLanguageService/CursorInfo.swift @@ -28,6 +28,9 @@ struct CursorInfo { /// name and USR. var symbolInfo: SymbolDetails + /// A human-readable string representation of the symbol at the given location, eg. a variable's type. + var typeName: String? + /// The annotated declaration XML string. var annotatedDeclaration: String? @@ -39,10 +42,12 @@ struct CursorInfo { init( _ symbolInfo: SymbolDetails, + typeName: String?, annotatedDeclaration: String?, documentation: String? ) { self.symbolInfo = symbolInfo + self.typeName = typeName self.annotatedDeclaration = annotatedDeclaration self.documentation = documentation } @@ -107,6 +112,7 @@ struct CursorInfo { receiverUsrs: dict[keys.receivers]?.compactMap { $0[keys.usr] as String? } ?? [], systemModule: module ), + typeName: dict[keys.typeName], annotatedDeclaration: dict[keys.annotatedDecl], documentation: dict[keys.docComment] ) diff --git a/Sources/SwiftLanguageService/SwiftLanguageService.swift b/Sources/SwiftLanguageService/SwiftLanguageService.swift index c54bffe47..d5ff6e63a 100644 --- a/Sources/SwiftLanguageService/SwiftLanguageService.swift +++ b/Sources/SwiftLanguageService/SwiftLanguageService.swift @@ -25,7 +25,7 @@ import SemanticIndex package import SourceKitD package import SourceKitLSP import SwiftExtensions -@_spi(ExperimentalLanguageFeatures) public import SwiftParser +@_spi(ExperimentalLanguageFeatures) package import SwiftParser import SwiftParserDiagnostics package import SwiftSyntax package import ToolchainRegistry @@ -973,16 +973,59 @@ extension SwiftLanguageService { let snapshot = try documentManager.latestSnapshot(uri) let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) - guard let scope = SyntaxCodeActionScope(snapshot: snapshot, syntaxTree: syntaxTree, request: request) else { + guard + let scope = SyntaxCodeActionScope( + resolveSupport: capabilityRegistry.clientCapabilities.textDocument?.codeAction?.resolveSupport, + snapshot: snapshot, + syntaxTree: syntaxTree, + requestedRange: request.range, + swiftLanguageService: self + ) + else { return [] } return await allSyntaxCodeActions.concurrentMap { provider in - return provider.codeActions(in: scope) + return await provider.codeActions(in: scope) }.flatMap { $0 } } package func codeActionResolve(_ req: CodeActionResolveRequest) async throws -> CodeAction { - return req.codeAction + guard let data = UnresolvedCodeActionData(fromLSPAny: req.codeAction.data) else { + // We don't have any data to resolve the code action. + return req.codeAction + } + + guard let action = allSyntaxCodeActions.filter({ "\($0)" == data.action }).only else { + throw ResponseError.unknown("Could not find syntax action '\(data.action)' to resolve code action") + } + let snapshot = try documentManager.latestSnapshot(data.document.uri) + guard snapshot.version == data.document.version else { + throw ResponseError.unknown("Document was modified since between code action and resolve request") + } + let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) + + // Replay the code action without resolve support. This should now resolve all properties. + guard + let scope = SyntaxCodeActionScope( + resolveSupport: nil, + snapshot: snapshot, + syntaxTree: syntaxTree, + requestedRange: data.range, + swiftLanguageService: self + ) + else { + throw ResponseError.unknown("Unable to re-create code action scope") + } + + let actions = await action.codeActions(in: scope) + switch actions.count { + case 0: + throw ResponseError.unknown("Code Action is not applicable") + case 1: + return actions.only! + default: + throw ResponseError.unknown("Ambiguous code actions returned during resolve") + } } func retrieveRefactorCodeActions(_ params: CodeActionRequest) async throws -> [CodeAction] { diff --git a/Tests/SourceKitLSPTests/CodeActionTests.swift b/Tests/SourceKitLSPTests/CodeActionTests.swift index c103ce9f2..32364b3b3 100644 --- a/Tests/SourceKitLSPTests/CodeActionTests.swift +++ b/Tests/SourceKitLSPTests/CodeActionTests.swift @@ -1276,6 +1276,139 @@ final class CodeActionTests: SourceKitLSPTestCase { } } + func testConvertStoredPropertyToComputedWithTypeAnnotation() async throws { + try await assertCodeActions( + """ + struct S { + 1️⃣var x: Int = 252️⃣ + } + """, + markers: ["1️⃣"], + ranges: [("1️⃣", "2️⃣")], + exhaustive: false + ) { uri, positions in + [ + CodeAction( + title: "Convert Stored Property to Computed Property", + kind: .refactorInline, + edit: WorkspaceEdit( + changes: [ + uri: [ + TextEdit( + range: Position(line: 0, utf16index: 10)..{ invalid }") + ] + ], + ) + ) + } + func testApplyDeMorganLawNegatedAnd() async throws { try await assertCodeActions( """