Skip to content

Commit 8d10dac

Browse files
committed
SwiftIDEUtilsTest: Add tests for FixItApplier
1 parent c40a978 commit 8d10dac

File tree

1 file changed

+293
-0
lines changed

1 file changed

+293
-0
lines changed
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
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 testMultipleNonOverlappingInsertionsSingleLine() {
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 testMultipleAdjacentReplacementsSingleLine() {
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 testMultipleNonOverlappingEditsSingleLine() {
70+
assertAppliedEdits(
71+
to: "var x = foo(1, 2)",
72+
edits: [
73+
.init(range: 0..<5, replacement: "_"), // Replacement
74+
.init(range: 6..<7, replacement: "="), // Replacement
75+
.init(range: 12..<12, replacement: "331"), // Insertion
76+
.init(range: 8..<11, replacement: ""), // Deletion
77+
// Adjacent, not overlapping.
78+
.init(range: 16..<16, replacement: "33"), // Insertion
79+
.init(range: 15..<16, replacement: "11"), // Replacement
80+
],
81+
output: "_ = (3311, 1133)"
82+
)
83+
}
84+
85+
func testMultipleNonOverlappingEditsOnDifferentLines() {
86+
assertAppliedEdits(
87+
to: """
88+
var x = 1
89+
var y = 2
90+
var z = 3
91+
var w = foo(1, 2)
92+
""",
93+
edits: [
94+
.init(range: 0..<3, replacement: "let"), // Replacement
95+
.init(range: 19..<19, replacement: "44"), // Insertion
96+
.init(range: 20..<24, replacement: ""), // Deletion
97+
.init(range: 38..<41, replacement: "fooo"), // Replacement
98+
.init(range: 46..<46, replacement: "33"), // Insertion
99+
.init(range: 30..<34, replacement: ""), // Deletion
100+
],
101+
output: """
102+
let x = 1
103+
var y = 244
104+
z = 3
105+
w = fooo(1, 233)
106+
"""
107+
)
108+
}
109+
110+
func testMultipleNonOverlappingEditsAcrossLines() {
111+
assertAppliedEdits(
112+
to: """
113+
var x = 1
114+
let y = 2
115+
var w = 3
116+
let z = 4
117+
""",
118+
edits: [
119+
.init(range: 6..<17, replacement: ""),
120+
.init(range: 17..<28, replacement: "= 5"),
121+
],
122+
output: """
123+
var x = 53
124+
let z = 4
125+
"""
126+
)
127+
}
128+
129+
func testMultipleOverlappingEditsSingleLine1() {
130+
assertAppliedEdits(
131+
to: "var foo = 1",
132+
edits: [
133+
.init(range: 0..<5, replacement: "ab"),
134+
.init(range: 3..<7, replacement: "cd"),
135+
],
136+
// The second edit is skipped.
137+
possibleOutputs: ["aboo = 1", "varcd = 1"]
138+
)
139+
}
140+
141+
func testMultipleOverlappingEditsSingleLine2() {
142+
assertAppliedEdits(
143+
to: "var x = 1",
144+
edits: [
145+
.init(range: 0..<5, replacement: "_"),
146+
.init(range: 0..<5, replacement: "_"),
147+
.init(range: 8..<8, replacement: "1"),
148+
.init(range: 0..<5, replacement: "_"),
149+
.init(range: 0..<3, replacement: "let"),
150+
],
151+
possibleOutputs: ["_ = 11", "let x = 11"]
152+
)
153+
}
154+
155+
func testMultipleOverlappingInsertions() {
156+
assertAppliedEdits(
157+
to: "x = 1",
158+
edits: [
159+
.init(range: 0..<0, replacement: "var "),
160+
.init(range: 0..<0, replacement: "var "),
161+
.init(range: 0..<0, replacement: "var "),
162+
],
163+
output: "var var var x = 1"
164+
)
165+
}
166+
167+
func testOverlappingReplacementAndInsertion() {
168+
assertAppliedEdits(
169+
to: "var x = 1",
170+
edits: [
171+
.init(range: 0..<5, replacement: "_"), // Replacement
172+
.init(range: 2..<2, replacement: ""), // Empty edit
173+
],
174+
// Empty edit never overlaps with anything.
175+
output: "_ = 1"
176+
)
177+
178+
assertAppliedEdits(
179+
to: "var x = 1",
180+
edits: [
181+
.init(range: 0..<5, replacement: "_"), // Replacement
182+
.init(range: 2..<2, replacement: "a"), // Insertion
183+
],
184+
// FIXME: This behavior where these edits are not considered overlapping doesn't feel desirable
185+
possibleOutputs: ["_x = 1", "_ a= 1"]
186+
)
187+
}
188+
}
189+
190+
/// Asserts that at least one element in `possibleOutputs` matches the result
191+
/// of applying an array of edits to `input`, for all permutations of `edits`.
192+
private func assertAppliedEdits(
193+
to tree: SourceFileSyntax,
194+
edits: [SourceEdit],
195+
possibleOutputs: [String]
196+
) {
197+
precondition(!possibleOutputs.isEmpty)
198+
199+
var indices = Array(edits.indices)
200+
while true {
201+
let result = FixItApplier.apply(edits: indices.map { edits[$0] }, to: tree)
202+
guard possibleOutputs.contains(result) else {
203+
XCTFail("\"\(result)\" is not equal to either of \(possibleOutputs)")
204+
return
205+
}
206+
207+
let keepGoing = indices.nextPermutation()
208+
guard keepGoing else {
209+
break
210+
}
211+
}
212+
}
213+
214+
/// Asserts that `output` matches the result of applying an array of edits to
215+
/// `input`, for all permutations of `edits`.
216+
private func assertAppliedEdits(
217+
to tree: SourceFileSyntax,
218+
edits: [SourceEdit],
219+
output: String
220+
) {
221+
assertAppliedEdits(to: tree, edits: edits, possibleOutputs: [output])
222+
}
223+
224+
// Grabbed from https://github.com/apple/swift-algorithms/blob/main/Sources/Algorithms/Permutations.swift
225+
226+
private extension MutableCollection where Self: BidirectionalCollection {
227+
mutating func reverse(subrange: Range<Index>) {
228+
if subrange.isEmpty { return }
229+
var lower = subrange.lowerBound
230+
var upper = subrange.upperBound
231+
while lower < upper {
232+
formIndex(before: &upper)
233+
swapAt(lower, upper)
234+
formIndex(after: &lower)
235+
}
236+
}
237+
}
238+
239+
private extension MutableCollection where Self: BidirectionalCollection, Element: Comparable {
240+
/// Permutes this collection's elements through all the lexical orderings.
241+
///
242+
/// Call `nextPermutation()` repeatedly starting with the collection in sorted
243+
/// order. When the full cycle of all permutations has been completed, the
244+
/// collection will be back in sorted order and this method will return
245+
/// `false`.
246+
///
247+
/// - Returns: A Boolean value indicating whether the collection still has
248+
/// remaining permutations. When this method returns `false`, the collection
249+
/// is in ascending order according to `areInIncreasingOrder`.
250+
///
251+
/// - Complexity: O(*n*), where *n* is the length of the collection.
252+
mutating func nextPermutation(upperBound: Index? = nil) -> Bool {
253+
// Ensure we have > 1 element in the collection.
254+
guard !isEmpty else { return false }
255+
var i = index(before: endIndex)
256+
if i == startIndex { return false }
257+
258+
let upperBound = upperBound ?? endIndex
259+
260+
while true {
261+
let ip1 = i
262+
formIndex(before: &i)
263+
264+
// Find the last ascending pair (ie. ..., a, b, ... where a < b)
265+
if self[i] < self[ip1] {
266+
// Find the last element greater than self[i]
267+
// swift-format-ignore: NeverForceUnwrap
268+
// This is _always_ at most `ip1` due to if statement above
269+
let j = lastIndex(where: { self[i] < $0 })!
270+
271+
// At this point we have something like this:
272+
// 0, 1, 4, 3, 2
273+
// ^ ^
274+
// i j
275+
swapAt(i, j)
276+
self.reverse(subrange: ip1..<endIndex)
277+
278+
// Only return if we've made a change within ..<upperBound region
279+
if i < upperBound {
280+
return true
281+
} else {
282+
i = index(before: endIndex)
283+
continue
284+
}
285+
}
286+
287+
if i == startIndex {
288+
self.reverse()
289+
return false
290+
}
291+
}
292+
}
293+
}

0 commit comments

Comments
 (0)