diff --git a/Sources/LanguageServerProtocol/Requests/CodeActionRequest.swift b/Sources/LanguageServerProtocol/Requests/CodeActionRequest.swift index fe2357478..f4e0924b6 100644 --- a/Sources/LanguageServerProtocol/Requests/CodeActionRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/CodeActionRequest.swift @@ -10,7 +10,7 @@ // //===----------------------------------------------------------------------===// -public typealias CodeActionProvider = (CodeActionRequest) async throws -> [CodeAction] +public typealias CodeActionRequestProvider = (CodeActionRequest) async throws -> [CodeAction] /// Request for returning all possible code actions for a given text document and range. /// diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index 270baa908..67e05b0c9 100644 --- a/Sources/SourceKitLSP/CMakeLists.txt +++ b/Sources/SourceKitLSP/CMakeLists.txt @@ -20,6 +20,13 @@ target_sources(SourceKitLSP PRIVATE Clang/ClangLanguageService.swift) target_sources(SourceKitLSP PRIVATE Swift/AdjustPositionToStartOfIdentifier.swift + Swift/CodeActions/CodeActionProvider.swift + Swift/CodeActions/CodeActions.swift + Swift/CodeActions/ConvertIntegerLiteral.swift + Swift/CodeActions/ConvertJSONToCodableStruct.swift + Swift/CodeActions/Demorgan.swift + Swift/CodeActions/EditBuilder.swift + Swift/CodeActions/SyntaxRefactoringCodeActionProvider.swift Swift/CodeCompletion.swift Swift/CodeCompletionSession.swift Swift/CommentXML.swift diff --git a/Sources/SourceKitLSP/Swift/CodeActions/CodeActionProvider.swift b/Sources/SourceKitLSP/Swift/CodeActions/CodeActionProvider.swift new file mode 100644 index 000000000..c0d8885e6 --- /dev/null +++ b/Sources/SourceKitLSP/Swift/CodeActions/CodeActionProvider.swift @@ -0,0 +1,75 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +import SwiftSyntax + +public protocol CodeActionProvider { + static var kind: CodeActionKind { get } + static func provideAssistance(in scope: CodeActionScope) -> [ProvidedAction] +} + +public struct CodeActionScope { + public var snapshot: DocumentSnapshot + public var parameters: CodeActionRequest + public var file: SourceFileSyntax + public var range: ByteSourceRange + + init(snapshot: DocumentSnapshot, syntaxTree tree: SourceFileSyntax, parameters: CodeActionRequest) throws { + self.snapshot = snapshot + self.parameters = parameters + self.file = tree + + let start = self.snapshot.utf8Offset(of: self.parameters.range.lowerBound) ?? 0 + let end = self.snapshot.utf8Offset(of: self.parameters.range.upperBound) ?? start + let left = self.file.token(at: start) + let right = self.file.token(at: end) + + let leftOff = left?.positionAfterSkippingLeadingTrivia.utf8Offset ?? 0 + let rightOff = right?.endPositionBeforeTrailingTrivia.utf8Offset ?? leftOff + assert(leftOff <= rightOff) + self.range = ByteSourceRange(offset: leftOff, length: rightOff - leftOff) + } + + public func starts(with tokenKind: TokenKind) -> TokenSyntax? { + guard + let token = self.file.token(at: self.range.offset), + token.tokenKind == tokenKind + else { + return nil + } + return token + } +} + +extension SyntaxProtocol { + func token(at utf8Offset: Int) -> TokenSyntax? { + return token(at: AbsolutePosition(utf8Offset: utf8Offset)) + } +} + +extension ByteSourceRange { + fileprivate func contains(_ other: Int) -> Bool { + return self.offset <= other && other <= self.endOffset + } +} + +extension SyntaxProtocol { + var textRange: ByteSourceRange { + return ByteSourceRange( + offset: self.positionAfterSkippingLeadingTrivia.utf8Offset, + length: self.trimmedLength.utf8Length + ) + } +} diff --git a/Sources/SourceKitLSP/Swift/CodeActions/CodeActions.swift b/Sources/SourceKitLSP/Swift/CodeActions/CodeActions.swift new file mode 100644 index 000000000..64ba9dc11 --- /dev/null +++ b/Sources/SourceKitLSP/Swift/CodeActions/CodeActions.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor + +/// List of all of the syntax-local code actions. +public let allLocalCodeActions: [CodeActionProvider.Type] = [ + AddSeparatorsToIntegerLiteral.self, + ConvertIntegerLiteral.self, + ConvertJSONToCodableStruct.self, + Demorgan.self, + FormatRawStringLiteral.self, + MigrateToNewIfLetSyntax.self, + OpaqueParameterToGeneric.self, + RemoveSeparatorsFromIntegerLiteral.self, +] diff --git a/Sources/SourceKitLSP/Swift/CodeActions/ConvertIntegerLiteral.swift b/Sources/SourceKitLSP/Swift/CodeActions/ConvertIntegerLiteral.swift new file mode 100644 index 000000000..fe7e1f8f2 --- /dev/null +++ b/Sources/SourceKitLSP/Swift/CodeActions/ConvertIntegerLiteral.swift @@ -0,0 +1,67 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +import SwiftSyntax + +// TODO: Make the type IntegerLiteralExprSyntax.Radix conform to CaseEnumerable +// in swift-syntax. + +extension IntegerLiteralExprSyntax.Radix { + static var allCases: [Self] = [.binary, .octal, .decimal, .hex] +} + +public struct ConvertIntegerLiteral: CodeActionProvider { + public static var kind: CodeActionKind { .refactorInline } + + public static func provideAssistance(in scope: CodeActionScope) -> [ProvidedAction] { + guard + let token = scope.file.token(at: scope.range.offset), + let lit = token.parent?.as(IntegerLiteralExprSyntax.self), + let integerValue = Int(lit.split().value, radix: lit.radix.size) + else { + return [] + } + + var actions = [ProvidedAction]() + let currentRadix = lit.radix + for radix in IntegerLiteralExprSyntax.Radix.allCases { + guard radix != currentRadix else { + continue + } + + //TODO: Add this to swift-syntax? + let prefix: String + switch radix { + case .binary: + prefix = "0b" + case .octal: + prefix = "0o" + case .hex: + prefix = "0x" + case .decimal: + prefix = "" + } + + let convertedValue: ExprSyntax = + "\(raw: prefix)\(raw: String(integerValue, radix: radix.size))" + actions.append( + ProvidedAction(title: "Convert \(lit) to \(convertedValue)") { + Replace(lit, with: convertedValue) + } + ) + } + + return actions + } +} diff --git a/Sources/SourceKitLSP/Swift/CodeActions/ConvertJSONToCodableStruct.swift b/Sources/SourceKitLSP/Swift/CodeActions/ConvertJSONToCodableStruct.swift new file mode 100644 index 000000000..ca9e0711a --- /dev/null +++ b/Sources/SourceKitLSP/Swift/CodeActions/ConvertJSONToCodableStruct.swift @@ -0,0 +1,287 @@ +//===----------------------------------------------------------------------===// +// +// 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 LanguageServerProtocol +import SwiftRefactor +import SwiftSyntax + +/// Convert JSON literals into corresponding Swift structs that conform to the +/// `Codable` protocol. +/// +/// ## Before +/// +/// ```javascript +/// { +/// "name": "Produce", +/// "shelves": [ +/// { +/// "name": "Discount Produce", +/// "product": { +/// "name": "Banana", +/// "points": 200, +/// "description": "A banana that's perfectly ripe." +/// } +/// } +/// ] +/// } +/// ``` +/// +/// ## After +/// +/// ```swift +/// struct JSONValue: Codable { +/// var name: String +/// var shelves: [Shelves] +/// +/// struct Shelves: Codable { +/// var name: String +/// var product: Product +/// +/// struct Product: Codable { +/// var description: String +/// var name: String +/// var points: Double +/// } +/// } +/// } +/// ``` +public struct ConvertJSONToCodableStructRefactor: SyntaxRefactoringProvider { + public static func refactor(syntax closure: ClosureExprSyntax, in context: Void) -> DeclSyntax? { + guard let unexpected = self.preflightRefactoring(closure) else { + return nil + } + + var text = "" + switch unexpected { + case let .closure(closure): + closure.trimmed.write(to: &text) + case let .tail(closure, unexpected): + closure.trimmed.write(to: &text) + unexpected.write(to: &text) + } + guard + let object = try? JSONSerialization.jsonObject(with: text.data(using: .utf8)!), + let serial = object as? Dictionary + else { + return nil + } + + return self.build(from: serial) + } +} + +extension ConvertJSONToCodableStructRefactor { + public enum Preflight { + case closure(ClosureExprSyntax) + case tail(ClosureExprSyntax, UnexpectedNodesSyntax) + } + public static func preflightRefactoring(_ closure: ClosureExprSyntax) -> Preflight? { + if let file = closure.parent?.parent?.parent?.as(SourceFileSyntax.self), + let unexpected = file.unexpectedBetweenStatementsAndEndOfFileToken + { + return .tail(closure, unexpected) + } + + if closure.hasError, + closure.unexpectedBetweenStatementsAndRightBrace != nil + { + return .closure(closure) + } + return nil + } +} + +extension ConvertJSONToCodableStructRefactor { + private static func build(from jsonDictionary: Dictionary) -> DeclSyntax { + return + """ + \(raw: self.buildStruct(from: jsonDictionary)) + """ + } + + private static func buildStruct( + named name: String = "JSONValue", + at depth: Int = 0, + from jsonDictionary: Dictionary + ) -> String { + let members = self.buildStructMembers(at: depth + 1, from: jsonDictionary) + return + """ + \(String(repeating: " ", count: depth * 2))struct \(name): Codable { + \(members.joined(separator: "\n")) + \(String(repeating: " ", count: depth * 2))} + """ + } + + private static func buildStructMembers( + at depth: Int, + from jsonDictionary: Dictionary + ) -> [String] { + var members = [String]() + + var uniquer = Set() + var nestedTypes = [(String, Dictionary)]() + func addNestedType(_ name: String, object: Dictionary) { + guard uniquer.insert(name).inserted else { + return + } + nestedTypes.append((name, object)) + } + + for key in jsonDictionary.keys.sorted() { + guard let value = jsonDictionary[key] else { + continue + } + + let type = self.jsonType(of: value, suggestion: key) + if case let .object(name) = type, let subObject = value as? Dictionary { + addNestedType(name, object: subObject) + } else if case let .array(innerType) = type, + let array = value as? [Any], + let (nesting, elementType) = innerType.outermostObject + { + if let subObject = array.unwrap(nesting) { + addNestedType(elementType, object: subObject) + } + } + + let member = "\(String(repeating: " ", count: depth * 2))var \(key): \(type.stringValue)" + members.append(member) + } + + if !nestedTypes.isEmpty { + members.append("") + for (name, nestedType) in nestedTypes { + members.append(self.buildStruct(named: name, at: depth, from: nestedType)) + } + } + return members + } + + indirect enum JSONType { + case string + case double + case array(JSONType) + case null + case object(String) + + var outermostObject: (Int, String)? { + var unwraps = 1 + var value = self + while case let .array(inner) = value { + value = inner + unwraps += 1 + } + + if case let .object(typeName) = value { + return (unwraps, typeName) + } else { + return nil + } + } + + var stringValue: String { + switch self { + case .string: + return "String" + case .double: + return "Double" + case .array(let ty): + return "[\(ty.stringValue)]" + case .null: + return "Void?" + case let .object(name): + return name + } + } + } + + private static func jsonType(of value: Any, suggestion name: String) -> JSONType { + switch Swift.type(of: value) { + case is NSString.Type: + return .string + case is NSNumber.Type: + return .double + case is NSArray.Type: + guard let firstValue = (value as! [Any]).first else { + return .array(.null) + } + let innerType = self.jsonType(of: firstValue, suggestion: name) + return .array(innerType) + case is NSNull.Type: + return .null + case is NSDictionary.Type: + return .object(name.capitalized) + default: + return .string + } + } +} + +extension Array where Element == Any { + fileprivate func unwrap(_ depth: Int) -> Dictionary? { + var values: [Any] = self + for i in 0.. + } + + guard let moreValues = values[0] as? [Any] else { + return nil + } + + values = moreValues + } + return nil + } +} + +public struct ConvertJSONToCodableStruct: CodeActionProvider { + public static var kind: CodeActionKind { .refactorRewrite } + + public static func provideAssistance(in scope: CodeActionScope) -> [ProvidedAction] { + guard + let token = scope.file.token(at: scope.range.offset), + let closure = token.parent?.as(ClosureExprSyntax.self), + closure.hasError + else { + return [] + } + + guard let preflight = ConvertJSONToCodableStructRefactor.preflightRefactoring(closure) else { + return [] + } + + guard + let decl = ConvertJSONToCodableStructRefactor.refactor(syntax: closure) + else { + return [] + } + + switch preflight { + case .closure(let closure): + return [ + ProvidedAction(title: "Convert to Codable struct") { + Replace(closure, with: decl) + } + ] + case .tail(let closure, let unexpected): + return [ + ProvidedAction(title: "Convert to Codable struct") { + Replace(closure, with: decl) + Remove(unexpected) + } + ] + } + } +} diff --git a/Sources/SourceKitLSP/Swift/CodeActions/Demorgan.swift b/Sources/SourceKitLSP/Swift/CodeActions/Demorgan.swift new file mode 100644 index 000000000..5e82495e0 --- /dev/null +++ b/Sources/SourceKitLSP/Swift/CodeActions/Demorgan.swift @@ -0,0 +1,207 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +import SwiftSyntax + +/// +/// +/// ## Before +/// ```swift +/// +/// ``` +/// +/// ## After +/// ```swift +/// +/// ``` +struct Demorgan: CodeActionProvider { + static var kind: CodeActionKind { .refactorRewrite } + + static func provideAssistance(in scope: CodeActionScope) -> [ProvidedAction] { + guard + let token = scope.file.token(at: scope.range.offset), + let op = token.parent?.as(InfixOperatorExprSyntax.self), + token.totalByteRange.intersectsOrTouches(scope.range) + else { + return [] + } + + guard + let opOp = op.operator.as(BinaryOperatorExprSyntax.self), + let logicalOp = InfixOperatorExprSyntax.Logical(rawValue: opOp.operator.text) + else { + return [] + } + + var workList = [op] + var terms = [ExprSyntax]() + var operatorRanges = [ByteSourceRange]() + + // Find all the children with the same binary operator + while let expr = workList.popLast() { + func addTerm(_ expr: ExprSyntax) { + guard let binOp = expr.as(InfixOperatorExprSyntax.self) else { + terms.append(expr) + return + } + + guard + let opOp = binOp.operator.as(BinaryOperatorExprSyntax.self), + let childLogicalOp = InfixOperatorExprSyntax.Logical(rawValue: opOp.operator.text) + else { + terms.append(expr) + return + } + + if logicalOp == childLogicalOp { + workList.append(binOp) + } else { + terms.append(ExprSyntax(binOp)) + } + } + + operatorRanges.append(expr.operator.totalByteRange) + + addTerm(expr.leftOperand) + addTerm(expr.rightOperand) + } + + terms.sort(by: { $0.totalByteRange.offset < $1.totalByteRange.offset }) + var edits = [BuildableWorkspaceEdit]() + if !terms.isEmpty { + let lhs = terms.removeFirst() + edits.append(Replace(lhs, with: lhs.inverted())) + } + + if !terms.isEmpty { + let rhs = terms.removeFirst() + edits.append(Replace(rhs, with: rhs.inverted())) + } + + for term in terms { + edits.append(Replace(term, with: term.inverted())) + } + + return [ + ProvidedAction(title: "Apply De Morgan's Law", edits: edits) + ] + } +} + +extension ExprSyntax { + func inverted() -> ExprSyntax { + if let booleanLit = self.as(BooleanLiteralExprSyntax.self) { + switch booleanLit.literal.text { + case "true": + return ExprSyntax(booleanLit.with(\.literal, .keyword(.false))) + case "false": + return ExprSyntax(booleanLit.with(\.literal, .keyword(.true))) + default: + return self + } + } else if let prefixOp = self.as(PrefixOperatorExprSyntax.self) { + if prefixOp.operator.text == "!" { + if let parens = prefixOp.expression.as(TupleExprSyntax.self), + parens.elements.count == 1, + let first = parens.elements.first, + first.label == nil + { + // Unwrap !(...) to ... + return first.expression + } else { + // Unwrap !... to ... + return prefixOp.expression + } + } else { + // Don't know what this is, leave it alone + return self + } + } else if let infixOp = self.as(InfixOperatorExprSyntax.self), + let binaryOp = infixOp.operator.as(BinaryOperatorExprSyntax.self) + { + if let comparison = InfixOperatorExprSyntax.Comparison(rawValue: binaryOp.operator.text) { + // Replace x < y with x >= y + return ExprSyntax( + infixOp + .with( + \.operator, + ExprSyntax( + binaryOp + .with(\.operator, .identifier(comparison.inverted.rawValue)) + ) + ) + ) + } else { + // Replace x y with !(x y) + return ExprSyntax( + PrefixOperatorExprSyntax( + operator: .exclamationMarkToken(), + expression: TupleExprSyntax( + leftParen: .leftParenToken(), + elementList: LabeledExprListSyntax([ + .init(expression: infixOp) + ]), + rightParen: .rightParenToken() + ) + ) + ) + } + } else { + // Fallback + return self + } + } +} + +extension InfixOperatorExprSyntax { + enum Logical: String { + case and = "&&" + case or = "||" + + var inverted: Logical { + switch self { + case .and: + return .or + case .or: + return .and + } + } + } + + enum Comparison: String { + case equal = "==" + case unequal = "!=" + case lessThan = "<" + case lessThanOrEqual = "<=" + case greaterThan = ">" + case greaterThanOrEqual = ">=" + + var inverted: Comparison { + switch self { + case .equal: + return .unequal + case .unequal: + return .equal + case .lessThan: + return .greaterThanOrEqual + case .lessThanOrEqual: + return .greaterThan + case .greaterThan: + return .lessThanOrEqual + case .greaterThanOrEqual: + return .lessThan + } + } + } +} diff --git a/Sources/SourceKitLSP/Swift/CodeActions/EditBuilder.swift b/Sources/SourceKitLSP/Swift/CodeActions/EditBuilder.swift new file mode 100644 index 000000000..bc413f60d --- /dev/null +++ b/Sources/SourceKitLSP/Swift/CodeActions/EditBuilder.swift @@ -0,0 +1,76 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftSyntax + +@resultBuilder +struct WorkspaceEditBuilder { + static func buildBlock(_ components: BuildableWorkspaceEdit...) -> [BuildableWorkspaceEdit] { + return components + } +} + +public protocol BuildableWorkspaceEdit { + func asEdit(in scope: CodeActionScope) -> TextEdit +} + +public struct Replace: BuildableWorkspaceEdit { + public var old: OldTree + public var new: NewTree + + public init(_ old: OldTree, with new: NewTree) { + self.old = old + self.new = new + } + + public func asEdit(in scope: CodeActionScope) -> TextEdit { + let oldRange = self.old.totalByteRange + let lower = + scope.snapshot.positionOf(utf8Offset: self.old.positionAfterSkippingLeadingTrivia.utf8Offset) + ?? Position(line: 0, utf16index: 0) + let upper = scope.snapshot.positionOf(utf8Offset: oldRange.endOffset) ?? Position(line: 0, utf16index: 0) + return TextEdit(range: Range(uncheckedBounds: (lower: lower, upper: upper)), newText: "\(self.new)") + } +} + +public struct Remove: BuildableWorkspaceEdit { + public var tree: Tree + + public init(_ tree: Tree) { + self.tree = tree + } + + public func asEdit(in scope: CodeActionScope) -> TextEdit { + let oldRange = self.tree.totalByteRange + let lower = + scope.snapshot.positionOf(utf8Offset: self.tree.positionAfterSkippingLeadingTrivia.utf8Offset) + ?? Position(line: 0, utf16index: 0) + let upper = scope.snapshot.positionOf(utf8Offset: oldRange.endOffset) ?? Position(line: 0, utf16index: 0) + return TextEdit(range: Range(uncheckedBounds: (lower: lower, upper: upper)), newText: "") + } +} + +public struct ProvidedAction { + public var title: String + public var edits: [BuildableWorkspaceEdit] + + public init(title: String, edits: [BuildableWorkspaceEdit]) { + self.title = title + self.edits = edits + } + + public init(title: String, @WorkspaceEditBuilder edit: () -> [BuildableWorkspaceEdit]) { + self.title = title + self.edits = edit() + } +} diff --git a/Sources/SourceKitLSP/Swift/CodeActions/SyntaxRefactoringCodeActionProvider.swift b/Sources/SourceKitLSP/Swift/CodeActions/SyntaxRefactoringCodeActionProvider.swift new file mode 100644 index 000000000..5a6142bee --- /dev/null +++ b/Sources/SourceKitLSP/Swift/CodeActions/SyntaxRefactoringCodeActionProvider.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftRefactor +import SwiftSyntax + +/// Protocol that adapts a SyntaxRefactoringProvider (that comes from swift-syntax) +/// into a CodeActionProvider. +public protocol SyntaxRefactoringCodeActionProvider: CodeActionProvider, SyntaxRefactoringProvider { + static var title: String { get } +} + +extension SyntaxRefactoringCodeActionProvider where Self.Context == Void { + public static var kind: CodeActionKind { .refactorRewrite } + + public static func provideAssistance(in scope: CodeActionScope) -> [ProvidedAction] { + guard + let token = scope.file.token(at: scope.range.offset), + let binding = token.parent?.as(Input.self) + else { + return [] + } + + guard let refactored = Self.refactor(syntax: binding) else { + return [] + } + + return [ + ProvidedAction(title: Self.title) { + Replace(binding, with: refactored) + } + ] + } +} + +// Adapters for specific refactoring provides in swift-syntax. + +extension AddSeparatorsToIntegerLiteral: SyntaxRefactoringCodeActionProvider { + public static var title: String { "Add digit separators" } +} + +extension FormatRawStringLiteral: SyntaxRefactoringCodeActionProvider { + public static var title: String { "Format raw string literal" } +} + +extension MigrateToNewIfLetSyntax: SyntaxRefactoringCodeActionProvider { + public static var title: String { "Migrate to 'if-let' Syntax" } +} + +extension OpaqueParameterToGeneric: SyntaxRefactoringCodeActionProvider { + public static var title: String { "Expand opaque parameters to generic parameters" } +} + +extension RemoveSeparatorsFromIntegerLiteral: SyntaxRefactoringCodeActionProvider { + public static var title: String { "Remove digit separators" } +} diff --git a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift index 34e35579c..c42dae539 100644 --- a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift +++ b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift @@ -700,7 +700,8 @@ extension SwiftLanguageService { } public func codeAction(_ req: CodeActionRequest) async throws -> CodeActionRequestResponse? { - let providersAndKinds: [(provider: CodeActionProvider, kind: CodeActionKind)] = [ + let providersAndKinds: [(provider: CodeActionRequestProvider, kind: CodeActionKind)] = [ + (retrieveLocalCodeActions, .refactorRewrite), (retrieveRefactorCodeActions, .refactor), (retrieveQuickFixCodeActions, .quickFix), ] @@ -715,7 +716,9 @@ extension SwiftLanguageService { return response } - func retrieveCodeActions(_ req: CodeActionRequest, providers: [CodeActionProvider]) async throws -> [CodeAction] { + func retrieveCodeActions(_ req: CodeActionRequest, providers: [CodeActionRequestProvider]) async throws + -> [CodeAction] + { guard providers.isEmpty == false else { return [] } @@ -729,6 +732,22 @@ extension SwiftLanguageService { }.flatMap { $0 } } + func retrieveLocalCodeActions(_ params: CodeActionRequest) async throws -> [CodeAction] { + let uri = params.textDocument.uri + let snapshot = try documentManager.latestSnapshot(uri) + + let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) + let scope = try CodeActionScope(snapshot: snapshot, syntaxTree: syntaxTree, parameters: params) + return await allLocalCodeActions.concurrentMap { provider in + return provider.provideAssistance(in: scope).map { + let edit = WorkspaceEdit(changes: [ + uri: $0.edits.map { $0.asEdit(in: scope) } + ]) + return CodeAction(title: $0.title, kind: provider.kind, edit: edit) + } + }.flatMap { $0 } + } + func retrieveRefactorCodeActions(_ params: CodeActionRequest) async throws -> [CodeAction] { let additionalCursorInfoParameters: ((SKDRequestDictionary) -> Void) = { skreq in skreq.set(self.keys.retrieveRefactorActions, to: 1) diff --git a/Tests/SourceKitLSPTests/CodeActionTests.swift b/Tests/SourceKitLSPTests/CodeActionTests.swift index 5cee42ec5..e8057bebd 100644 --- a/Tests/SourceKitLSPTests/CodeActionTests.swift +++ b/Tests/SourceKitLSPTests/CodeActionTests.swift @@ -284,7 +284,12 @@ final class CodeActionTests: XCTestCase { command: expectedCommand ) - XCTAssertEqual(result, .codeActions([expectedCodeAction])) + guard case .codeActions(let codeActions) = result else { + XCTFail("Expected code actions") + return + } + + XCTAssertTrue(codeActions.contains(expectedCodeAction)) } func testSemanticRefactorRangeCodeActionResult() async throws {