Skip to content

Commit e13ce13

Browse files
authored
Merge pull request #1179 from DougGregor/syntax-code-action-refactorings
Introduce new refactoring code actions based on the Swift syntax tree.
2 parents 5a5b501 + a8b61a5 commit e13ce13

7 files changed

+286
-5
lines changed

Sources/SourceKitLSP/CMakeLists.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ target_sources(SourceKitLSP PRIVATE
2020
Clang/ClangLanguageService.swift)
2121
target_sources(SourceKitLSP PRIVATE
2222
Swift/AdjustPositionToStartOfIdentifier.swift
23+
Swift/CodeActions/ConvertIntegerLiteral.swift
24+
Swift/CodeActions/SyntaxCodeActionProvider.swift
25+
Swift/CodeActions/SyntaxCodeActions.swift
26+
Swift/CodeActions/SyntaxRefactoringCodeActionProvider.swift
2327
Swift/CodeCompletion.swift
2428
Swift/CodeCompletionSession.swift
2529
Swift/CommentXML.swift
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import LanguageServerProtocol
14+
import SwiftRefactor
15+
import SwiftSyntax
16+
17+
// TODO: Make the type IntegerLiteralExprSyntax.Radix conform to CaseEnumerable
18+
// in swift-syntax.
19+
20+
extension IntegerLiteralExprSyntax.Radix {
21+
static var allCases: [Self] = [.binary, .octal, .decimal, .hex]
22+
}
23+
24+
/// Syntactic code action provider to convert integer literals between
25+
/// different bases.
26+
struct ConvertIntegerLiteral: SyntaxCodeActionProvider {
27+
static func codeActions(in scope: SyntaxCodeActionScope) -> [CodeAction] {
28+
guard
29+
let token = scope.firstToken,
30+
let integerExpr = token.parent?.as(IntegerLiteralExprSyntax.self),
31+
let integerValue = Int(
32+
integerExpr.split().value.filter { $0 != "_" },
33+
radix: integerExpr.radix.size
34+
)
35+
else {
36+
return []
37+
}
38+
39+
var actions = [CodeAction]()
40+
let currentRadix = integerExpr.radix
41+
for radix in IntegerLiteralExprSyntax.Radix.allCases {
42+
guard radix != currentRadix else {
43+
continue
44+
}
45+
46+
//TODO: Add this to swift-syntax?
47+
let prefix: String
48+
switch radix {
49+
case .binary:
50+
prefix = "0b"
51+
case .octal:
52+
prefix = "0o"
53+
case .hex:
54+
prefix = "0x"
55+
case .decimal:
56+
prefix = ""
57+
}
58+
59+
let convertedValue: ExprSyntax =
60+
"\(raw: prefix)\(raw: String(integerValue, radix: radix.size))"
61+
let edit = TextEdit(
62+
range: scope.snapshot.range(of: integerExpr),
63+
newText: convertedValue.description
64+
)
65+
actions.append(
66+
CodeAction(
67+
title: "Convert \(integerExpr) to \(convertedValue)",
68+
kind: .refactorInline,
69+
edit: WorkspaceEdit(changes: [scope.snapshot.uri: [edit]])
70+
)
71+
)
72+
}
73+
74+
return actions
75+
}
76+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import LSPLogging
14+
import LanguageServerProtocol
15+
import SwiftRefactor
16+
import SwiftSyntax
17+
18+
/// Describes types that provide one or more code actions based on purely
19+
/// syntactic information.
20+
protocol SyntaxCodeActionProvider {
21+
/// Produce code actions within the given scope. Each code action
22+
/// corresponds to one syntactic transformation that can be performed, such
23+
/// as adding or removing separators from an integer literal.
24+
static func codeActions(in scope: SyntaxCodeActionScope) -> [CodeAction]
25+
}
26+
27+
/// Defines the scope in which a syntactic code action occurs.
28+
struct SyntaxCodeActionScope {
29+
/// The snapshot of the document on which the code actions will be evaluated.
30+
var snapshot: DocumentSnapshot
31+
32+
/// The actual code action request, which can specify additional parameters
33+
/// to guide the code actions.
34+
var request: CodeActionRequest
35+
36+
/// The source file in which the syntactic code action will operate.
37+
var file: SourceFileSyntax
38+
39+
/// The UTF-8 byte range in the source file in which code actions should be
40+
/// considered, i.e., where the cursor or selection is.
41+
var range: Range<AbsolutePosition>
42+
43+
init(
44+
snapshot: DocumentSnapshot,
45+
syntaxTree tree: SourceFileSyntax,
46+
request: CodeActionRequest
47+
) throws {
48+
self.snapshot = snapshot
49+
self.request = request
50+
self.file = tree
51+
52+
let start = snapshot.absolutePosition(of: request.range.lowerBound)
53+
let end = snapshot.absolutePosition(of: request.range.upperBound)
54+
let left = file.token(at: start)
55+
let right = file.token(at: end)
56+
let leftOff = left?.position ?? AbsolutePosition(utf8Offset: 0)
57+
let rightOff = right?.endPosition ?? leftOff
58+
self.range = leftOff..<rightOff
59+
}
60+
61+
/// The first token in the
62+
var firstToken: TokenSyntax? {
63+
file.token(at: range.lowerBound)
64+
}
65+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import SwiftRefactor
14+
15+
/// List of all of the syntactic code action providers, which can be used
16+
/// to produce code actions using only the swift-syntax tree of a file.
17+
let allSyntaxCodeActions: [SyntaxCodeActionProvider.Type] = [
18+
AddSeparatorsToIntegerLiteral.self,
19+
ConvertIntegerLiteral.self,
20+
FormatRawStringLiteral.self,
21+
MigrateToNewIfLetSyntax.self,
22+
OpaqueParameterToGeneric.self,
23+
RemoveSeparatorsFromIntegerLiteral.self,
24+
]
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import LanguageServerProtocol
14+
import SwiftRefactor
15+
import SwiftSyntax
16+
17+
/// Protocol that adapts a SyntaxRefactoringProvider (that comes from
18+
/// swift-syntax) into a SyntaxCodeActionProvider.
19+
protocol SyntaxRefactoringCodeActionProvider: SyntaxCodeActionProvider, SyntaxRefactoringProvider {
20+
static var title: String { get }
21+
}
22+
23+
/// SyntaxCodeActionProviders with a \c Void context can automatically be
24+
/// adapted provide a code action based on their refactoring operation.
25+
extension SyntaxRefactoringCodeActionProvider where Self.Context == Void {
26+
static func codeActions(in scope: SyntaxCodeActionScope) -> [CodeAction] {
27+
guard
28+
let token = scope.firstToken,
29+
let node = token.parent?.as(Input.self)
30+
else {
31+
return []
32+
}
33+
34+
guard let refactored = Self.refactor(syntax: node) else {
35+
return []
36+
}
37+
38+
let edit = TextEdit(
39+
range: scope.snapshot.range(of: node),
40+
newText: refactored.description
41+
)
42+
43+
return [
44+
CodeAction(
45+
title: Self.title,
46+
kind: .refactorInline,
47+
edit: WorkspaceEdit(changes: [scope.snapshot.uri: [edit]])
48+
)
49+
]
50+
}
51+
}
52+
53+
// Adapters for specific refactoring provides in swift-syntax.
54+
55+
extension AddSeparatorsToIntegerLiteral: SyntaxRefactoringCodeActionProvider {
56+
public static var title: String { "Add digit separators" }
57+
}
58+
59+
extension FormatRawStringLiteral: SyntaxRefactoringCodeActionProvider {
60+
public static var title: String {
61+
"Convert string literal to minimal number of '#'s"
62+
}
63+
}
64+
65+
extension MigrateToNewIfLetSyntax: SyntaxRefactoringCodeActionProvider {
66+
public static var title: String { "Migrate to shorthand 'if let' syntax" }
67+
}
68+
69+
extension OpaqueParameterToGeneric: SyntaxRefactoringCodeActionProvider {
70+
public static var title: String { "Expand 'some' parameters to generic parameters" }
71+
}
72+
73+
extension RemoveSeparatorsFromIntegerLiteral: SyntaxRefactoringCodeActionProvider {
74+
public static var title: String { "Remove digit separators" }
75+
}

Sources/SourceKitLSP/Swift/SwiftLanguageService.swift

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -699,22 +699,31 @@ extension SwiftLanguageService {
699699
}
700700

701701
public func codeAction(_ req: CodeActionRequest) async throws -> CodeActionRequestResponse? {
702-
let providersAndKinds: [(provider: CodeActionProvider, kind: CodeActionKind)] = [
702+
let providersAndKinds: [(provider: CodeActionProvider, kind: CodeActionKind?)] = [
703+
(retrieveSyntaxCodeActions, nil),
703704
(retrieveRefactorCodeActions, .refactor),
704705
(retrieveQuickFixCodeActions, .quickFix),
705706
]
706707
let wantedActionKinds = req.context.only
707-
let providers = providersAndKinds.filter { wantedActionKinds?.contains($0.1) != false }
708+
let providers: [CodeActionProvider] = providersAndKinds.compactMap {
709+
if let wantedActionKinds, let kind = $0.1, !wantedActionKinds.contains(kind) {
710+
return nil
711+
}
712+
713+
return $0.provider
714+
}
708715
let codeActionCapabilities = capabilityRegistry.clientCapabilities.textDocument?.codeAction
709-
let codeActions = try await retrieveCodeActions(req, providers: providers.map { $0.provider })
716+
let codeActions = try await retrieveCodeActions(req, providers: providers)
710717
let response = CodeActionRequestResponse(
711718
codeActions: codeActions,
712719
clientCapabilities: codeActionCapabilities
713720
)
714721
return response
715722
}
716723

717-
func retrieveCodeActions(_ req: CodeActionRequest, providers: [CodeActionProvider]) async throws -> [CodeAction] {
724+
func retrieveCodeActions(_ req: CodeActionRequest, providers: [CodeActionProvider]) async throws
725+
-> [CodeAction]
726+
{
718727
guard providers.isEmpty == false else {
719728
return []
720729
}
@@ -725,6 +734,17 @@ extension SwiftLanguageService {
725734
// Ignore any providers that failed to provide refactoring actions.
726735
return []
727736
}
737+
}.flatMap { $0 }.sorted { $0.title < $1.title }
738+
}
739+
740+
func retrieveSyntaxCodeActions(_ request: CodeActionRequest) async throws -> [CodeAction] {
741+
let uri = request.textDocument.uri
742+
let snapshot = try documentManager.latestSnapshot(uri)
743+
744+
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
745+
let scope = try SyntaxCodeActionScope(snapshot: snapshot, syntaxTree: syntaxTree, request: request)
746+
return await allSyntaxCodeActions.concurrentMap { provider in
747+
return provider.codeActions(in: scope)
728748
}.flatMap { $0 }
729749
}
730750

@@ -1091,6 +1111,18 @@ extension DocumentSnapshot {
10911111
return lowerBound..<upperBound
10921112
}
10931113

1114+
/// Extracts the range of the given syntax node in terms of positions within
1115+
/// this source file.
1116+
func range(
1117+
of node: some SyntaxProtocol,
1118+
callerFile: StaticString = #fileID,
1119+
callerLine: UInt = #line
1120+
) -> Range<Position> {
1121+
let lowerBound = self.position(of: node.position)
1122+
let upperBound = self.position(of: node.endPosition)
1123+
return lowerBound..<upperBound
1124+
}
1125+
10941126
/// Converts the given UTF-16-based line:column range to a UTF-8-offset-based `ByteSourceRange`.
10951127
///
10961128
/// If the bounds of the range do not refer to a valid positions with in the snapshot, this function adjusts them to

Tests/SourceKitLSPTests/CodeActionTests.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,12 @@ final class CodeActionTests: XCTestCase {
284284
command: expectedCommand
285285
)
286286

287-
XCTAssertEqual(result, .codeActions([expectedCodeAction]))
287+
guard case .codeActions(let codeActions) = result else {
288+
XCTFail("Expected code actions")
289+
return
290+
}
291+
292+
XCTAssertTrue(codeActions.contains(expectedCodeAction))
288293
}
289294

290295
func testSemanticRefactorRangeCodeActionResult() async throws {

0 commit comments

Comments
 (0)