Skip to content

Commit 4e1953d

Browse files
committed
SwiftIDEUtilsTest: Add tests for FixItApplier
(cherry picked from commit fdaf1be)
1 parent ab38925 commit 4e1953d

File tree

1 file changed

+341
-0
lines changed

1 file changed

+341
-0
lines changed
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2025 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+
@_spi(FixItApplier) import SwiftIDEUtils
14+
import SwiftSyntax
15+
import XCTest
16+
17+
private extension SourceEdit {
18+
init(range: Range<Int>, replacement: String) {
19+
self.init(
20+
range: AbsolutePosition(utf8Offset: range.lowerBound)..<AbsolutePosition(utf8Offset: range.upperBound),
21+
replacement: replacement
22+
)
23+
}
24+
}
25+
26+
class FixItApplierApplyEditsTests: XCTestCase {
27+
func testNoEdits() {
28+
assertAppliedEdits(
29+
to: "var x = 1",
30+
edits: [],
31+
output: "var x = 1"
32+
)
33+
}
34+
35+
func testSingleEdit() {
36+
assertAppliedEdits(
37+
to: "var x = 1",
38+
edits: [
39+
.init(range: 0..<4, replacement: "let")
40+
],
41+
output: "let x = 1"
42+
)
43+
}
44+
45+
func testNonOverlappingInsertions() {
46+
assertAppliedEdits(
47+
to: "x = 1",
48+
edits: [
49+
.init(range: 0..<0, replacement: "var "),
50+
.init(range: 1..<1, replacement: "var "),
51+
.init(range: 2..<2, replacement: "var "),
52+
],
53+
output: "var xvar var = 1"
54+
)
55+
}
56+
57+
func testAdjacentReplacements() {
58+
assertAppliedEdits(
59+
to: "let x = 1",
60+
edits: [
61+
.init(range: 0..<5, replacement: "_"),
62+
.init(range: 5..<8, replacement: " == "),
63+
.init(range: 8..<9, replacement: "2"),
64+
],
65+
output: "_ == 2"
66+
)
67+
}
68+
69+
func testNonOverlappingReplacementAndInsertion() {
70+
assertAppliedEdits(
71+
to: "var x = 1",
72+
edits: [
73+
.init(range: 0..<3, replacement: "let"), // Replacement
74+
.init(range: 0..<0, replacement: "a"), // Insertion
75+
],
76+
output: "alet x = 1"
77+
)
78+
79+
assertAppliedEdits(
80+
to: "var x = 1",
81+
edits: [
82+
.init(range: 0..<5, replacement: "a"), // Replacement
83+
.init(range: 5..<5, replacement: "b"), // Insertion
84+
],
85+
output: "ab = 1"
86+
)
87+
}
88+
89+
func testNonOverlappingEditsSingleLine() {
90+
assertAppliedEdits(
91+
to: "var x = foo(1, 2)",
92+
edits: [
93+
.init(range: 0..<5, replacement: "_"), // Replacement
94+
.init(range: 6..<7, replacement: "="), // Replacement
95+
.init(range: 12..<12, replacement: "331"), // Insertion
96+
.init(range: 8..<11, replacement: ""), // Deletion
97+
// Adjacent, not overlapping.
98+
.init(range: 16..<16, replacement: "33"), // Insertion
99+
.init(range: 15..<16, replacement: "11"), // Replacement
100+
],
101+
output: "_ = (3311, 1133)"
102+
)
103+
}
104+
105+
func testNonOverlappingEditsMultiline() {
106+
assertAppliedEdits(
107+
to: """
108+
var x = 1
109+
var y = 2
110+
var z = 3
111+
var w = foo(1, 2)
112+
""",
113+
edits: [
114+
.init(range: 0..<3, replacement: "let"), // Replacement
115+
.init(range: 19..<19, replacement: "44"), // Insertion
116+
.init(range: 20..<24, replacement: ""), // Deletion
117+
.init(range: 38..<41, replacement: "fooo"), // Replacement
118+
.init(range: 46..<46, replacement: "33"), // Insertion
119+
.init(range: 30..<34, replacement: ""), // Deletion
120+
],
121+
output: """
122+
let x = 1
123+
var y = 244
124+
z = 3
125+
w = fooo(1, 233)
126+
"""
127+
)
128+
}
129+
130+
func testNonOverlappingEditsCrossLine() {
131+
assertAppliedEdits(
132+
to: """
133+
var x = 1
134+
let y = 2
135+
var w = 3
136+
let z = 4
137+
""",
138+
edits: [
139+
.init(range: 6..<17, replacement: ""),
140+
.init(range: 17..<28, replacement: "= 5"),
141+
],
142+
output: """
143+
var x = 53
144+
let z = 4
145+
"""
146+
)
147+
}
148+
149+
func testDuplicateReplacements() {
150+
assertAppliedEdits(
151+
to: "var x = 1",
152+
edits: [
153+
.init(range: 0..<3, replacement: "let"),
154+
.init(range: 0..<3, replacement: "let"),
155+
.init(range: 0..<3, replacement: "let"),
156+
],
157+
output: "let x = 1"
158+
)
159+
}
160+
161+
func testOverlappingEdits1() {
162+
assertAppliedEdits(
163+
to: "var foo = 1",
164+
edits: [
165+
.init(range: 0..<5, replacement: "ab"),
166+
.init(range: 3..<7, replacement: "cd"),
167+
],
168+
// The second edit is skipped.
169+
possibleOutputs: ["aboo = 1", "varcd = 1"]
170+
)
171+
}
172+
173+
func testOverlappingEdits2() {
174+
assertAppliedEdits(
175+
to: "var x = 1",
176+
edits: [
177+
.init(range: 0..<5, replacement: "_"),
178+
.init(range: 0..<5, replacement: "_"),
179+
.init(range: 8..<8, replacement: "1"),
180+
.init(range: 0..<5, replacement: "_"),
181+
.init(range: 0..<3, replacement: "let"),
182+
],
183+
possibleOutputs: ["_ = 11", "let x = 11"]
184+
)
185+
}
186+
187+
func testOverlappingInsertions() {
188+
assertAppliedEdits(
189+
to: "x = 1",
190+
edits: [
191+
.init(range: 0..<0, replacement: "var "),
192+
.init(range: 0..<0, replacement: "var "),
193+
.init(range: 0..<0, replacement: "var "),
194+
],
195+
output: "var var var x = 1"
196+
)
197+
}
198+
199+
func testOverlappingReplacementAndInsertion() {
200+
assertAppliedEdits(
201+
to: "var x = 1",
202+
edits: [
203+
.init(range: 0..<5, replacement: "_"), // Replacement
204+
.init(range: 2..<2, replacement: ""), // Empty edit
205+
],
206+
// Empty edit never overlaps with anything.
207+
output: "_ = 1"
208+
)
209+
210+
assertAppliedEdits(
211+
to: "var x = 1",
212+
edits: [
213+
.init(range: 0..<5, replacement: "_"), // Replacement
214+
.init(range: 2..<2, replacement: "a"), // Insertion
215+
],
216+
// FIXME: This behavior where these edits are not considered overlapping doesn't feel desirable
217+
possibleOutputs: ["_x = 1", "_ a= 1"]
218+
)
219+
}
220+
}
221+
222+
/// Asserts that at least one element in `possibleOutputs` matches the result
223+
/// of applying an array of edits to `input`, for all permutations of `edits`.
224+
private func assertAppliedEdits(
225+
to tree: SourceFileSyntax,
226+
edits: [SourceEdit],
227+
possibleOutputs: [String],
228+
line: UInt = #line
229+
) {
230+
precondition(!possibleOutputs.isEmpty)
231+
232+
var indices = Array(edits.indices)
233+
while true {
234+
let editsPermutation = indices.map { edits[$0] }
235+
236+
let actualOutput = FixItApplier.apply(edits: editsPermutation, to: tree)
237+
guard possibleOutputs.contains(actualOutput) else {
238+
XCTFail(
239+
"""
240+
Actual output \"\(actualOutput)\" does not match one of \(possibleOutputs)
241+
Edits:
242+
\(editsPermutation)
243+
""",
244+
line: line
245+
)
246+
return
247+
}
248+
249+
let keepGoing = indices.nextPermutation()
250+
guard keepGoing else {
251+
break
252+
}
253+
}
254+
}
255+
256+
/// Asserts that `output` matches the result of applying an array of edits to
257+
/// `input`, for all permutations of `edits`.
258+
private func assertAppliedEdits(
259+
to tree: SourceFileSyntax,
260+
edits: [SourceEdit],
261+
output: String,
262+
line: UInt = #line
263+
) {
264+
assertAppliedEdits(
265+
to: tree,
266+
edits: edits,
267+
possibleOutputs: [output],
268+
line: line
269+
)
270+
}
271+
272+
// Grabbed from https://github.com/apple/swift-algorithms/blob/main/Sources/Algorithms/Permutations.swift
273+
274+
private extension MutableCollection where Self: BidirectionalCollection {
275+
mutating func reverse(subrange: Range<Index>) {
276+
if subrange.isEmpty { return }
277+
var lower = subrange.lowerBound
278+
var upper = subrange.upperBound
279+
while lower < upper {
280+
formIndex(before: &upper)
281+
swapAt(lower, upper)
282+
formIndex(after: &lower)
283+
}
284+
}
285+
}
286+
287+
private extension MutableCollection where Self: BidirectionalCollection, Element: Comparable {
288+
/// Permutes this collection's elements through all the lexical orderings.
289+
///
290+
/// Call `nextPermutation()` repeatedly starting with the collection in sorted
291+
/// order. When the full cycle of all permutations has been completed, the
292+
/// collection will be back in sorted order and this method will return
293+
/// `false`.
294+
///
295+
/// - Returns: A Boolean value indicating whether the collection still has
296+
/// remaining permutations. When this method returns `false`, the collection
297+
/// is in ascending order according to `areInIncreasingOrder`.
298+
///
299+
/// - Complexity: O(*n*), where *n* is the length of the collection.
300+
mutating func nextPermutation(upperBound: Index? = nil) -> Bool {
301+
// Ensure we have > 1 element in the collection.
302+
guard !isEmpty else { return false }
303+
var i = index(before: endIndex)
304+
if i == startIndex { return false }
305+
306+
let upperBound = upperBound ?? endIndex
307+
308+
while true {
309+
let ip1 = i
310+
formIndex(before: &i)
311+
312+
// Find the last ascending pair (ie. ..., a, b, ... where a < b)
313+
if self[i] < self[ip1] {
314+
// Find the last element greater than self[i]
315+
// swift-format-ignore: NeverForceUnwrap
316+
// This is _always_ at most `ip1` due to if statement above
317+
let j = lastIndex(where: { self[i] < $0 })!
318+
319+
// At this point we have something like this:
320+
// 0, 1, 4, 3, 2
321+
// ^ ^
322+
// i j
323+
swapAt(i, j)
324+
self.reverse(subrange: ip1..<endIndex)
325+
326+
// Only return if we've made a change within ..<upperBound region
327+
if i < upperBound {
328+
return true
329+
} else {
330+
i = index(before: endIndex)
331+
continue
332+
}
333+
}
334+
335+
if i == startIndex {
336+
self.reverse()
337+
return false
338+
}
339+
}
340+
}
341+
}

0 commit comments

Comments
 (0)