diff --git a/Sources/SwiftIDEUtils/FixItApplier.swift b/Sources/SwiftIDEUtils/FixItApplier.swift index de5785ff941..6253710930d 100644 --- a/Sources/SwiftIDEUtils/FixItApplier.swift +++ b/Sources/SwiftIDEUtils/FixItApplier.swift @@ -27,12 +27,15 @@ public enum FixItApplier { /// - filterByMessages: An optional array of message strings to filter which Fix-Its to apply. /// If `nil`, the first Fix-It from each diagnostic is applied. /// - tree: The syntax tree to which the Fix-Its will be applied. + /// - allowDuplicateInsertions: Whether to apply duplicate insertions. + /// Defaults to `true`. /// /// - Returns: A `String` representation of the modified syntax tree after applying the Fix-Its. public static func applyFixes( from diagnostics: [Diagnostic], filterByMessages messages: [String]?, - to tree: any SyntaxProtocol + to tree: some SyntaxProtocol, + allowDuplicateInsertions: Bool = true ) -> String { let messages = messages ?? diagnostics.compactMap { $0.fixIts.first?.message.message } @@ -43,51 +46,89 @@ public enum FixItApplier { .filter { messages.contains($0.message.message) } .flatMap(\.edits) - return self.apply(edits: edits, to: tree) + return self.apply(edits: edits, to: tree, allowDuplicateInsertions: allowDuplicateInsertions) } - /// Apply the given edits to the syntax tree. + /// Applies the given edits to the given syntax tree. /// /// - Parameters: - /// - edits: The edits to apply to the syntax tree - /// - tree: he syntax tree to which the edits should be applied. - /// - Returns: A `String` representation of the modified syntax tree after applying the edits. + /// - edits: The edits to apply. + /// - tree: The syntax tree to which the edits should be applied. + /// - allowDuplicateInsertions: Whether to apply duplicate insertions. + /// Defaults to `true`. + /// + /// - Returns: A `String` representation of the modified syntax tree. public static func apply( edits: [SourceEdit], - to tree: any SyntaxProtocol + to tree: some SyntaxProtocol, + allowDuplicateInsertions: Bool = true ) -> String { var edits = edits var source = tree.description - while let edit = edits.first { - edits = Array(edits.dropFirst()) + for var editIndex in edits.indices { + let edit = edits[editIndex] + + // Empty edits do nothing. + guard !edit.isEmpty else { + continue + } + + do { + let utf8 = source.utf8 + let startIndex = utf8.index(utf8.startIndex, offsetBy: edit.startUtf8Offset) + let endIndex = utf8.index(utf8.startIndex, offsetBy: edit.endUtf8Offset) + + source.replaceSubrange(startIndex.. Bool { + // Insertions never conflict between themselves, unless we were asked + // to drop duplicate insertions. + if edit.range.isEmpty && remainingEdit.range.isEmpty { + if allowDuplicateInsertions { + return false + } - let startIndex = source.utf8.index(source.utf8.startIndex, offsetBy: edit.startUtf8Offset) - let endIndex = source.utf8.index(source.utf8.startIndex, offsetBy: edit.endUtf8Offset) + return edit == remainingEdit + } - source.replaceSubrange(startIndex.. remainingEdit.startUtf8Offset + && edit.startUtf8Offset < remainingEdit.endUtf8Offset + } - edits = edits.compactMap { remainingEdit -> SourceEdit? in - if remainingEdit.replacementRange.overlaps(edit.replacementRange) { - // The edit overlaps with the previous edit. We can't apply both - // without conflicts. Apply the one that's listed first and drop the - // later edit. - return nil + guard !shouldDropRemainingEdit() else { + // Drop the edit by swapping it for an empty one. + edits[editIndex] = SourceEdit() + continue } // If the remaining edit starts after or at the end of the edit that we just applied, // shift it by the current edit's difference in length. if edit.endUtf8Offset <= remainingEdit.startUtf8Offset { - let startPosition = AbsolutePosition( - utf8Offset: remainingEdit.startUtf8Offset - edit.replacementRange.count + edit.replacementLength.utf8Length - ) - let endPosition = AbsolutePosition( - utf8Offset: remainingEdit.endUtf8Offset - edit.replacementRange.count + edit.replacementLength.utf8Length - ) - return SourceEdit(range: startPosition.. Index { + self.formIndex(after: &index) as Void + return index + } +} + private extension SourceEdit { var startUtf8Offset: Int { return range.lowerBound.utf8Offset @@ -104,7 +152,15 @@ private extension SourceEdit { return range.upperBound.utf8Offset } - var replacementRange: Range { - return startUtf8Offset.. Bool { + return lhs.utf8Offset < rhs.utf8Offset + } +} + +extension AbsolutePosition: Strideable { public func advanced(by offset: Int) -> AbsolutePosition { - return AbsolutePosition(utf8Offset: self.utf8Offset + offset) + AbsolutePosition(utf8Offset: self.utf8Offset + offset) } - public static func < (lhs: AbsolutePosition, rhs: AbsolutePosition) -> Bool { - return lhs.utf8Offset < rhs.utf8Offset + public func distance(to other: AbsolutePosition) -> Int { + self.utf8Offset.distance(to: other.utf8Offset) } } diff --git a/Tests/SwiftIDEUtilsTest/FixItApplierTests.swift b/Tests/SwiftIDEUtilsTest/FixItApplierTests.swift new file mode 100644 index 00000000000..d45be14f3c5 --- /dev/null +++ b/Tests/SwiftIDEUtilsTest/FixItApplierTests.swift @@ -0,0 +1,418 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +@_spi(FixItApplier) import SwiftIDEUtils +import SwiftSyntax +import XCTest + +private extension SourceEdit { + init(range: Range, replacement: String) { + self.init( + range: AbsolutePosition(utf8Offset: range.lowerBound)..) { + if subrange.isEmpty { return } + var lower = subrange.lowerBound + var upper = subrange.upperBound + while lower < upper { + formIndex(before: &upper) + swapAt(lower, upper) + formIndex(after: &lower) + } + } +} + +private extension MutableCollection where Self: BidirectionalCollection, Element: Comparable { + /// Permutes this collection's elements through all the lexical orderings. + /// + /// Call `nextPermutation()` repeatedly starting with the collection in sorted + /// order. When the full cycle of all permutations has been completed, the + /// collection will be back in sorted order and this method will return + /// `false`. + /// + /// - Returns: A Boolean value indicating whether the collection still has + /// remaining permutations. When this method returns `false`, the collection + /// is in ascending order according to `areInIncreasingOrder`. + /// + /// - Complexity: O(*n*), where *n* is the length of the collection. + mutating func nextPermutation(upperBound: Index? = nil) -> Bool { + // Ensure we have > 1 element in the collection. + guard !isEmpty else { return false } + var i = index(before: endIndex) + if i == startIndex { return false } + + let upperBound = upperBound ?? endIndex + + while true { + let ip1 = i + formIndex(before: &i) + + // Find the last ascending pair (ie. ..., a, b, ... where a < b) + if self[i] < self[ip1] { + // Find the last element greater than self[i] + // swift-format-ignore: NeverForceUnwrap + // This is _always_ at most `ip1` due to if statement above + let j = lastIndex(where: { self[i] < $0 })! + + // At this point we have something like this: + // 0, 1, 4, 3, 2 + // ^ ^ + // i j + swapAt(i, j) + self.reverse(subrange: ip1..