Skip to content

Commit 1415bcb

Browse files
committed
Add an EditRefactoringProvider which provides textual edits
This adds an additional text-based refactoring provider which the original syntax-based provider now implements. This allows refactorings that produce text rather than a full tree. (cherry picked from commit bcf0d1f)
1 parent 2688b03 commit 1415bcb

11 files changed

+292
-94
lines changed

Sources/SwiftRefactor/AddSeparatorsToIntegerLiteral.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import SwiftSyntax
3232
/// 0xF_FFFF_FFFF
3333
/// 0b1_010
3434
/// ```
35-
public struct AddSeparatorsToIntegerLiteral: RefactoringProvider {
35+
public struct AddSeparatorsToIntegerLiteral: SyntaxRefactoringProvider {
3636
public static func refactor(syntax lit: IntegerLiteralExprSyntax, in context: Void) -> IntegerLiteralExprSyntax? {
3737
if lit.digits.text.contains("_") {
3838
guard let strippedLiteral = RemoveSeparatorsFromIntegerLiteral.refactor(syntax: lit) else {

Sources/SwiftRefactor/FormatRawStringLiteral.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import SwiftSyntax
3030
/// ##"Hello \#(world)"##
3131
/// "Hello World"
3232
/// ```
33-
public struct FormatRawStringLiteral: RefactoringProvider {
33+
public struct FormatRawStringLiteral: SyntaxRefactoringProvider {
3434
public static func refactor(syntax lit: StringLiteralExprSyntax, in context: Void) -> StringLiteralExprSyntax? {
3535
var maximumHashes = 0
3636
for segment in lit.segments {

Sources/SwiftRefactor/MigrateToNewIfLetSyntax.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import SwiftParser
3333
/// if let foo {
3434
/// // ...
3535
/// }
36-
public struct MigrateToNewIfLetSyntax: RefactoringProvider {
36+
public struct MigrateToNewIfLetSyntax: SyntaxRefactoringProvider {
3737
public static func refactor(syntax node: IfExprSyntax, in context: ()) -> IfExprSyntax? {
3838
// Visit all conditions in the node.
3939
let newConditions = node.conditions.enumerated().map { (index, condition) -> ConditionElementListSyntax.Element in

Sources/SwiftRefactor/OpaqueParameterToGeneric.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ fileprivate class SomeParameterRewriter: SyntaxRewriter {
115115
/// ```swift
116116
/// func someFunction<T1: Value>(_ input: T1) {}
117117
/// ```
118-
public struct OpaqueParameterToGeneric: RefactoringProvider {
118+
public struct OpaqueParameterToGeneric: SyntaxRefactoringProvider {
119119
/// Replace all of the "some" parameters in the given parameter clause with
120120
/// freshly-created generic parameters.
121121
///

Sources/SwiftRefactor/RefactoringProvider.swift

Lines changed: 126 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,30 +12,43 @@
1212

1313
import SwiftSyntax
1414

15-
/// A type that transforms syntax to provide a (context-sensitive)
16-
/// refactoring.
17-
///
18-
/// A type conforming to the `RefactoringProvider` protocol defines
19-
/// a refactoring action against a family of Swift syntax trees.
20-
///
21-
/// Refactoring
22-
/// ===========
23-
///
24-
/// Refactoring is the act of transforming source code to be more effective.
25-
/// A refactoring does not affect the semantics of code it is transforming.
26-
/// Rather, it makes that code easier to read and reason about.
27-
///
28-
/// Code Transformation
29-
/// ===================
30-
///
31-
/// Refactoring is expressed as structural transformations of Swift
32-
/// syntax trees. The SwiftSyntax API provides a natural, easy-to-use,
33-
/// and compositional set of updates to the syntax tree. For example, a
34-
/// refactoring action that wishes to exchange the leading trivia of a node
35-
/// would call `with(\.leadingTrivia, _:)` against its input syntax and return
36-
/// the resulting syntax node. For compound syntax nodes, entire sub-trees
37-
/// can be added, exchanged, or removed by calling the corresponding `with`
38-
/// API.
15+
/// A refactoring expressed as textual edits on the original syntax tree. In
16+
/// general clients should prefer `SyntaxRefactoringProvider` where possible.
17+
public protocol EditRefactoringProvider {
18+
/// The type of syntax this refactoring action accepts.
19+
associatedtype Input: SyntaxProtocol
20+
/// Contextual information used by the refactoring action.
21+
associatedtype Context = Void
22+
23+
/// Perform the refactoring action on the provided syntax node.
24+
///
25+
/// - Parameters:
26+
/// - syntax: The syntax to transform.
27+
/// - context: Contextual information used by the refactoring action.
28+
/// - Returns: Textual edits that describe how to apply the result of the
29+
/// refactoring action on locations within the original tree. An
30+
/// empty array if the refactoring could not be performed.
31+
static func textRefactor(syntax: Input, in context: Context) -> [SourceEdit]
32+
}
33+
34+
extension EditRefactoringProvider where Context == Void {
35+
/// See `textRefactor(syntax:in:)`. This method provides a convenient way to
36+
/// invoke a refactoring action that requires no context.
37+
///
38+
/// - Parameters:
39+
/// - syntax: The syntax to transform.
40+
/// - Returns: Textual edits describing the refactoring to perform.
41+
public static func textRefactor(syntax: Input) -> [SourceEdit] {
42+
return self.textRefactor(syntax: syntax, in: ())
43+
}
44+
}
45+
46+
/// A refactoring expressed as a structural transformation of the original
47+
/// syntax node. For example, a refactoring action that wishes to exchange the
48+
/// leading trivia of a node could call call `with(\.leadingTrivia, _:)`
49+
/// against its input syntax and return the resulting syntax node. Or, for
50+
/// compound syntax nodes, entire sub-trees can be added, exchanged, or removed
51+
/// by calling the corresponding `with` API.
3952
///
4053
/// - Note: The syntax trees returned by SwiftSyntax are immutable: any
4154
/// transformation made against the tree results in a distinct tree.
@@ -44,43 +57,116 @@ import SwiftSyntax
4457
/// =========================
4558
///
4659
/// A refactoring provider cannot assume that the syntax it is given is
47-
/// neessarily well-formed. As the SwiftSyntax library is capable of recovering
60+
/// necessarily well-formed. As the SwiftSyntax library is capable of recovering
4861
/// from a variety of erroneous inputs, a refactoring provider has to be
4962
/// prepared to fail gracefully as well. Many refactoring providers follow a
5063
/// common validation pattern that "preflights" the refactoring by testing the
5164
/// structure of the provided syntax nodes. If the tests fail, the refactoring
52-
/// provider exits early by returning `nil`. It is recommended that refactoring
53-
/// actions fail as quickly as possible to give any associated tooling
54-
/// space to recover as well.
55-
public protocol RefactoringProvider {
56-
/// The type of syntax this refactoring action accepts.
57-
associatedtype Input: SyntaxProtocol = SourceFileSyntax
65+
/// provider exits early by returning an empty array. It is recommended that
66+
/// refactoring actions fail as quickly as possible to give any associated
67+
/// tooling space to recover as well.
68+
public protocol SyntaxRefactoringProvider: EditRefactoringProvider {
69+
// Should not be required, see https://github.com/apple/swift/issues/66004.
70+
// The default is a hack to workaround the warning that we'd hit otherwise.
71+
associatedtype Input: SyntaxProtocol = MissingSyntax
5872
/// The type of syntax this refactoring action returns.
59-
associatedtype Output: SyntaxProtocol = SourceFileSyntax
73+
associatedtype Output: SyntaxProtocol
6074
/// Contextual information used by the refactoring action.
6175
associatedtype Context = Void
6276

63-
/// Perform the refactoring action on the provided syntax node.
77+
/// Perform the refactoring action on the provided syntax node. It is assumed
78+
/// that the returned output completely replaces the input node.
6479
///
6580
/// - Parameters:
6681
/// - syntax: The syntax to transform.
6782
/// - context: Contextual information used by the refactoring action.
6883
/// - Returns: The result of applying the refactoring action, or `nil` if the
6984
/// action could not be performed.
70-
static func refactor(syntax: Self.Input, in context: Self.Context) -> Self.Output?
85+
static func refactor(syntax: Input, in context: Context) -> Output?
7186
}
7287

73-
extension RefactoringProvider where Context == Void {
74-
/// Perform the refactoring action on the provided syntax node.
75-
///
76-
/// This method provides a convenient way to invoke a refactoring action that
77-
/// requires no context.
88+
extension SyntaxRefactoringProvider where Context == Void {
89+
/// See `refactor(syntax:in:)`. This method provides a convenient way to
90+
/// invoke a refactoring action that requires no context.
7891
///
7992
/// - Parameters:
8093
/// - syntax: The syntax to transform.
8194
/// - Returns: The result of applying the refactoring action, or `nil` if the
8295
/// action could not be performed.
83-
public static func refactor(syntax: Self.Input) -> Self.Output? {
96+
public static func refactor(syntax: Input) -> Output? {
8497
return self.refactor(syntax: syntax, in: ())
8598
}
8699
}
100+
101+
extension SyntaxRefactoringProvider {
102+
/// Provides a default implementation for
103+
/// `EditRefactoringProvider.textRefactor(syntax:in:)` that produces an edit
104+
/// to replace the input of `refactor(syntax:in:)` with its returned output.
105+
public static func textRefactor(syntax: Input, in context: Context) -> [SourceEdit] {
106+
guard let output = refactor(syntax: syntax, in: context) else {
107+
return []
108+
}
109+
return [SourceEdit.replace(syntax, with: output.description)]
110+
}
111+
}
112+
113+
/// An textual edit to the original source represented by a range and a
114+
/// replacement.
115+
public struct SourceEdit: Equatable {
116+
/// The half-open range that this edit applies to.
117+
public let range: Range<AbsolutePosition>
118+
/// The text to replace the original range with. Empty for a deletion.
119+
public let replacement: String
120+
121+
/// Length of the original source range that this edit applies to. Zero if
122+
/// this is an addition.
123+
public var length: SourceLength {
124+
return SourceLength(utf8Length: range.lowerBound.utf8Offset - range.upperBound.utf8Offset)
125+
}
126+
127+
/// Create an edit to replace `range` in the original source with
128+
/// `replacement`.
129+
public init(range: Range<AbsolutePosition>, replacement: String) {
130+
self.range = range
131+
self.replacement = replacement
132+
}
133+
134+
/// Convenience function to create a textual addition after the given node
135+
/// and its trivia.
136+
public static func insert(_ newText: String, after node: some SyntaxProtocol) -> SourceEdit {
137+
return SourceEdit(range: node.endPosition..<node.endPosition, replacement: newText)
138+
}
139+
140+
/// Convenience function to create a textual addition before the given node
141+
/// and its trivia.
142+
public static func insert(_ newText: String, before node: some SyntaxProtocol) -> SourceEdit {
143+
return SourceEdit(range: node.position..<node.position, replacement: newText)
144+
}
145+
146+
/// Convenience function to create a textual replacement of the given node,
147+
/// including its trivia.
148+
public static func replace(_ node: some SyntaxProtocol, with replacement: String) -> SourceEdit {
149+
return SourceEdit(range: node.position..<node.endPosition, replacement: replacement)
150+
}
151+
152+
/// Convenience function to create a textual deletion the given node and its
153+
/// trivia.
154+
public static func remove(_ node: some SyntaxProtocol) -> SourceEdit {
155+
return SourceEdit(range: node.position..<node.endPosition, replacement: "")
156+
}
157+
}
158+
159+
extension SourceEdit: CustomDebugStringConvertible {
160+
public var debugDescription: String {
161+
let hasNewline = replacement.contains { $0.isNewline }
162+
if hasNewline {
163+
return #"""
164+
\#(range.lowerBound.utf8Offset)-\#(range.upperBound.utf8Offset)
165+
"""
166+
\#(replacement)
167+
"""
168+
"""#
169+
}
170+
return "\(range.lowerBound.utf8Offset)-\(range.upperBound.utf8Offset) \"\(replacement)\""
171+
}
172+
}

Sources/SwiftRefactor/RemoveSeparatorsFromIntegerLiteral.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import SwiftSyntax
2626
/// 123456789
2727
/// 0xFFFFFFFFF
2828
/// ```
29-
public struct RemoveSeparatorsFromIntegerLiteral: RefactoringProvider {
29+
public struct RemoveSeparatorsFromIntegerLiteral: SyntaxRefactoringProvider {
3030
public static func refactor(syntax lit: IntegerLiteralExprSyntax, in context: Void) -> IntegerLiteralExprSyntax? {
3131
guard lit.digits.text.contains("_") else {
3232
return lit

Tests/SwiftRefactorTest/FormatRawStringLiteral.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ final class FormatRawStringLiteralTest: XCTestCase {
3434
for (line, literal, expectation) in tests {
3535
let literal = try XCTUnwrap(StringLiteralExprSyntax.parseWithoutDiagnostics(from: literal))
3636
let expectation = try XCTUnwrap(StringLiteralExprSyntax.parseWithoutDiagnostics(from: expectation))
37-
let refactored = try XCTUnwrap(FormatRawStringLiteral.refactor(syntax: literal))
38-
assertStringsEqualWithDiff(refactored.description, expectation.description, line: UInt(line))
37+
try assertRefactor(literal, context: (), provider: FormatRawStringLiteral.self, expected: expectation, line: UInt(line))
3938
}
4039
}
4140
}

Tests/SwiftRefactorTest/MigrateToNewIfLetSyntax.swift

Lines changed: 7 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,32 +17,6 @@ import SwiftSyntaxBuilder
1717
import XCTest
1818
import _SwiftSyntaxTestSupport
1919

20-
func assertRefactorIfLet(
21-
_ syntax: ExprSyntax,
22-
expected: ExprSyntax,
23-
file: StaticString = #file,
24-
line: UInt = #line
25-
) throws {
26-
let ifExpr = try XCTUnwrap(
27-
syntax.as(IfExprSyntax.self),
28-
file: file,
29-
line: line
30-
)
31-
32-
let refactored = try XCTUnwrap(
33-
MigrateToNewIfLetSyntax.refactor(syntax: ifExpr),
34-
file: file,
35-
line: line
36-
)
37-
38-
assertStringsEqualWithDiff(
39-
expected.description,
40-
refactored.description,
41-
file: file,
42-
line: line
43-
)
44-
}
45-
4620
final class MigrateToNewIfLetSyntaxTest: XCTestCase {
4721
func testRefactoring() throws {
4822
let baselineSyntax: ExprSyntax = """
@@ -53,7 +27,7 @@ final class MigrateToNewIfLetSyntaxTest: XCTestCase {
5327
if let x {}
5428
"""
5529

56-
try assertRefactorIfLet(baselineSyntax, expected: expectedSyntax)
30+
try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax)
5731
}
5832

5933
func testIdempotence() throws {
@@ -65,8 +39,7 @@ final class MigrateToNewIfLetSyntaxTest: XCTestCase {
6539
if let x {}
6640
"""
6741

68-
try assertRefactorIfLet(baselineSyntax, expected: expectedSyntax)
69-
try assertRefactorIfLet(expectedSyntax, expected: expectedSyntax)
42+
try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax)
7043
}
7144

7245
func testMultiBinding() throws {
@@ -78,7 +51,7 @@ final class MigrateToNewIfLetSyntaxTest: XCTestCase {
7851
if let x, var y, let z {}
7952
"""
8053

81-
try assertRefactorIfLet(baselineSyntax, expected: expectedSyntax)
54+
try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax)
8255
}
8356

8457
func testMixedBinding() throws {
@@ -90,7 +63,7 @@ final class MigrateToNewIfLetSyntaxTest: XCTestCase {
9063
if let x, var y = x, let z = y.w {}
9164
"""
9265

93-
try assertRefactorIfLet(baselineSyntax, expected: expectedSyntax)
66+
try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax)
9467
}
9568

9669
func testConditions() throws {
@@ -102,7 +75,7 @@ final class MigrateToNewIfLetSyntaxTest: XCTestCase {
10275
if let x = x + 1, x == x, !x {}
10376
"""
10477

105-
try assertRefactorIfLet(baselineSyntax, expected: expectedSyntax)
78+
try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax)
10679
}
10780

10881
func testWhitespaceNormalization() throws {
@@ -114,7 +87,7 @@ final class MigrateToNewIfLetSyntaxTest: XCTestCase {
11487
if let x, let y {}
11588
"""
11689

117-
try assertRefactorIfLet(baselineSyntax, expected: expectedSyntax)
90+
try assertRefactor(baselineSyntax, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax)
11891
}
11992

12093
func testIfStmt() throws {
@@ -127,6 +100,6 @@ final class MigrateToNewIfLetSyntaxTest: XCTestCase {
127100
"""
128101

129102
let exprStmt = try XCTUnwrap(baselineSyntax.as(ExpressionStmtSyntax.self))
130-
try assertRefactorIfLet(exprStmt.expression, expected: expectedSyntax)
103+
try assertRefactor(exprStmt.expression, context: (), provider: MigrateToNewIfLetSyntax.self, expected: expectedSyntax)
131104
}
132105
}

Tests/SwiftRefactorTest/OpaqueParameterToGeneric.swift

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,7 @@ final class OpaqueParameterToGenericTest: XCTestCase {
3333
) -> some Equatable { }
3434
"""
3535

36-
let refactored = try XCTUnwrap(OpaqueParameterToGeneric.refactor(syntax: baseline))
37-
38-
assertStringsEqualWithDiff(expected.description, refactored.description)
36+
try assertRefactor(baseline, context: (), provider: OpaqueParameterToGeneric.self, expected: expected)
3937
}
4038

4139
func testRefactoringInit() throws {
@@ -53,9 +51,7 @@ final class OpaqueParameterToGenericTest: XCTestCase {
5351
) { }
5452
"""
5553

56-
let refactored = try XCTUnwrap(OpaqueParameterToGeneric.refactor(syntax: baseline))
57-
58-
assertStringsEqualWithDiff(expected.description, refactored.description)
54+
try assertRefactor(baseline, context: (), provider: OpaqueParameterToGeneric.self, expected: expected)
5955
}
6056

6157
func testRefactoringSubscript() throws {
@@ -67,8 +63,6 @@ final class OpaqueParameterToGenericTest: XCTestCase {
6763
subscript<T1: Hashable>(index: T1) -> String
6864
"""
6965

70-
let refactored = try XCTUnwrap(OpaqueParameterToGeneric.refactor(syntax: baseline))
71-
72-
assertStringsEqualWithDiff(expected.description, refactored.description)
66+
try assertRefactor(baseline, context: (), provider: OpaqueParameterToGeneric.self, expected: expected)
7367
}
7468
}

0 commit comments

Comments
 (0)