Skip to content

Commit 638901a

Browse files
Merge pull request #3121 from swiftlang/jepa2
FixItApplier: Misc improvements
2 parents b73055f + a00cbca commit 638901a

File tree

3 files changed

+512
-32
lines changed

3 files changed

+512
-32
lines changed

Sources/SwiftIDEUtils/FixItApplier.swift

Lines changed: 85 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,15 @@ public enum FixItApplier {
2727
/// - filterByMessages: An optional array of message strings to filter which Fix-Its to apply.
2828
/// If `nil`, the first Fix-It from each diagnostic is applied.
2929
/// - tree: The syntax tree to which the Fix-Its will be applied.
30+
/// - allowDuplicateInsertions: Whether to apply duplicate insertions.
31+
/// Defaults to `true`.
3032
///
3133
/// - Returns: A `String` representation of the modified syntax tree after applying the Fix-Its.
3234
public static func applyFixes(
3335
from diagnostics: [Diagnostic],
3436
filterByMessages messages: [String]?,
35-
to tree: any SyntaxProtocol
37+
to tree: some SyntaxProtocol,
38+
allowDuplicateInsertions: Bool = true
3639
) -> String {
3740
let messages = messages ?? diagnostics.compactMap { $0.fixIts.first?.message.message }
3841

@@ -43,58 +46,103 @@ public enum FixItApplier {
4346
.filter { messages.contains($0.message.message) }
4447
.flatMap(\.edits)
4548

46-
return self.apply(edits: edits, to: tree)
49+
return self.apply(edits: edits, to: tree, allowDuplicateInsertions: allowDuplicateInsertions)
4750
}
4851

49-
/// Apply the given edits to the syntax tree.
52+
/// Applies the given edits to the given syntax tree.
5053
///
5154
/// - Parameters:
52-
/// - edits: The edits to apply to the syntax tree
53-
/// - tree: he syntax tree to which the edits should be applied.
54-
/// - Returns: A `String` representation of the modified syntax tree after applying the edits.
55+
/// - edits: The edits to apply.
56+
/// - tree: The syntax tree to which the edits should be applied.
57+
/// - allowDuplicateInsertions: Whether to apply duplicate insertions.
58+
/// Defaults to `true`.
59+
///
60+
/// - Returns: A `String` representation of the modified syntax tree.
5561
public static func apply(
5662
edits: [SourceEdit],
57-
to tree: any SyntaxProtocol
63+
to tree: some SyntaxProtocol,
64+
allowDuplicateInsertions: Bool = true
5865
) -> String {
5966
var edits = edits
6067
var source = tree.description
6168

62-
while let edit = edits.first {
63-
edits = Array(edits.dropFirst())
69+
for var editIndex in edits.indices {
70+
let edit = edits[editIndex]
71+
72+
// Empty edits do nothing.
73+
guard !edit.isEmpty else {
74+
continue
75+
}
76+
77+
do {
78+
let utf8 = source.utf8
79+
let startIndex = utf8.index(utf8.startIndex, offsetBy: edit.startUtf8Offset)
80+
let endIndex = utf8.index(utf8.startIndex, offsetBy: edit.endUtf8Offset)
81+
82+
source.replaceSubrange(startIndex..<endIndex, with: edit.replacement)
83+
}
84+
85+
// Drop any subsequent edits that conflict with one we just applied, and
86+
// adjust the range of the rest.
87+
while edits.formIndex(after: &editIndex) != edits.endIndex {
88+
let remainingEdit = edits[editIndex]
89+
90+
// Empty edits do nothing.
91+
guard !remainingEdit.isEmpty else {
92+
continue
93+
}
94+
95+
func shouldDropRemainingEdit() -> Bool {
96+
// Insertions never conflict between themselves, unless we were asked
97+
// to drop duplicate insertions.
98+
if edit.range.isEmpty && remainingEdit.range.isEmpty {
99+
if allowDuplicateInsertions {
100+
return false
101+
}
64102

65-
let startIndex = source.utf8.index(source.utf8.startIndex, offsetBy: edit.startUtf8Offset)
66-
let endIndex = source.utf8.index(source.utf8.startIndex, offsetBy: edit.endUtf8Offset)
103+
return edit == remainingEdit
104+
}
67105

68-
source.replaceSubrange(startIndex..<endIndex, with: edit.replacement)
106+
// Edits conflict in the following cases:
107+
//
108+
// - Their ranges have a common element.
109+
// - One's range is empty and its lower bound is strictly within the
110+
// other's range. So 0..<2 also conflicts with 1..<1, but not with
111+
// 0..<0 or 2..<2.
112+
//
113+
return edit.endUtf8Offset > remainingEdit.startUtf8Offset
114+
&& edit.startUtf8Offset < remainingEdit.endUtf8Offset
115+
}
69116

70-
edits = edits.compactMap { remainingEdit -> SourceEdit? in
71-
if remainingEdit.replacementRange.overlaps(edit.replacementRange) {
72-
// The edit overlaps with the previous edit. We can't apply both
73-
// without conflicts. Apply the one that's listed first and drop the
74-
// later edit.
75-
return nil
117+
guard !shouldDropRemainingEdit() else {
118+
// Drop the edit by swapping it for an empty one.
119+
edits[editIndex] = SourceEdit()
120+
continue
76121
}
77122

78123
// If the remaining edit starts after or at the end of the edit that we just applied,
79124
// shift it by the current edit's difference in length.
80125
if edit.endUtf8Offset <= remainingEdit.startUtf8Offset {
81-
let startPosition = AbsolutePosition(
82-
utf8Offset: remainingEdit.startUtf8Offset - edit.replacementRange.count + edit.replacementLength.utf8Length
83-
)
84-
let endPosition = AbsolutePosition(
85-
utf8Offset: remainingEdit.endUtf8Offset - edit.replacementRange.count + edit.replacementLength.utf8Length
86-
)
87-
return SourceEdit(range: startPosition..<endPosition, replacement: remainingEdit.replacement)
88-
}
126+
let shift = edit.replacementLength.utf8Length - edit.range.count
127+
let startPosition = AbsolutePosition(utf8Offset: remainingEdit.startUtf8Offset + shift)
128+
let endPosition = AbsolutePosition(utf8Offset: remainingEdit.endUtf8Offset + shift)
89129

90-
return remainingEdit
130+
edits[editIndex] = SourceEdit(range: startPosition..<endPosition, replacement: remainingEdit.replacement)
131+
}
91132
}
92133
}
93134

94135
return source
95136
}
96137
}
97138

139+
private extension Collection {
140+
func formIndex(after index: inout Index) -> Index {
141+
self.formIndex(after: &index) as Void
142+
return index
143+
}
144+
}
145+
98146
private extension SourceEdit {
99147
var startUtf8Offset: Int {
100148
return range.lowerBound.utf8Offset
@@ -104,7 +152,15 @@ private extension SourceEdit {
104152
return range.upperBound.utf8Offset
105153
}
106154

107-
var replacementRange: Range<Int> {
108-
return startUtf8Offset..<endUtf8Offset
155+
var isEmpty: Bool {
156+
self.range.isEmpty && self.replacement.isEmpty
157+
}
158+
159+
init() {
160+
self = SourceEdit(
161+
range: AbsolutePosition(utf8Offset: 0)..<AbsolutePosition(utf8Offset: 0),
162+
replacement: []
163+
)
164+
precondition(self.isEmpty)
109165
}
110166
}

Sources/SwiftSyntax/AbsolutePosition.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,17 @@ public struct AbsolutePosition: Comparable, Hashable, Sendable {
2121
self.utf8Offset = utf8Offset
2222
}
2323

24+
public static func < (lhs: AbsolutePosition, rhs: AbsolutePosition) -> Bool {
25+
return lhs.utf8Offset < rhs.utf8Offset
26+
}
27+
}
28+
29+
extension AbsolutePosition: Strideable {
2430
public func advanced(by offset: Int) -> AbsolutePosition {
25-
return AbsolutePosition(utf8Offset: self.utf8Offset + offset)
31+
AbsolutePosition(utf8Offset: self.utf8Offset + offset)
2632
}
2733

28-
public static func < (lhs: AbsolutePosition, rhs: AbsolutePosition) -> Bool {
29-
return lhs.utf8Offset < rhs.utf8Offset
34+
public func distance(to other: AbsolutePosition) -> Int {
35+
self.utf8Offset.distance(to: other.utf8Offset)
3036
}
3137
}

0 commit comments

Comments
 (0)