|
| 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