Skip to content

FixItApplier: Misc improvements #3121

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 85 additions & 29 deletions Sources/SwiftIDEUtils/FixItApplier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -43,58 +46,103 @@ 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..<endIndex, with: edit.replacement)
}

// Drop any subsequent edits that conflict with one we just applied, and
// adjust the range of the rest.
while edits.formIndex(after: &editIndex) != edits.endIndex {
let remainingEdit = edits[editIndex]

// Empty edits do nothing.
guard !remainingEdit.isEmpty else {
continue
}

func shouldDropRemainingEdit() -> 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..<endIndex, with: edit.replacement)
// Edits conflict in the following cases:
//
// - Their ranges have a common element.
// - One's range is empty and its lower bound is strictly within the
// other's range. So 0..<2 also conflicts with 1..<1, but not with
// 0..<0 or 2..<2.
//
return edit.endUtf8Offset > 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..<endPosition, replacement: remainingEdit.replacement)
}
let shift = edit.replacementLength.utf8Length - edit.range.count
let startPosition = AbsolutePosition(utf8Offset: remainingEdit.startUtf8Offset + shift)
let endPosition = AbsolutePosition(utf8Offset: remainingEdit.endUtf8Offset + shift)

return remainingEdit
edits[editIndex] = SourceEdit(range: startPosition..<endPosition, replacement: remainingEdit.replacement)
}
}
}

return source
}
}

private extension Collection {
func formIndex(after index: inout Index) -> Index {
self.formIndex(after: &index) as Void
return index
}
}

private extension SourceEdit {
var startUtf8Offset: Int {
return range.lowerBound.utf8Offset
Expand All @@ -104,7 +152,15 @@ private extension SourceEdit {
return range.upperBound.utf8Offset
}

var replacementRange: Range<Int> {
return startUtf8Offset..<endUtf8Offset
var isEmpty: Bool {
self.range.isEmpty && self.replacement.isEmpty
}

init() {
self = SourceEdit(
range: AbsolutePosition(utf8Offset: 0)..<AbsolutePosition(utf8Offset: 0),
replacement: []
)
precondition(self.isEmpty)
}
}
12 changes: 9 additions & 3 deletions Sources/SwiftSyntax/AbsolutePosition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ public struct AbsolutePosition: Comparable, Hashable, Sendable {
self.utf8Offset = utf8Offset
}

public static func < (lhs: AbsolutePosition, rhs: AbsolutePosition) -> 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)
}
}
Loading