Skip to content

Commit b6bdc2f

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

File tree

1 file changed

+297
-0
lines changed

1 file changed

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

0 commit comments

Comments
 (0)