Skip to content

Commit 8b031e3

Browse files
authored
Fix application of fix-its replacing parent instead of first child (#16)
* Bump deps * Add test showing that a fix-it wrongly replaces parent instead of targeted child See #15. * Don't replace parent syntax collection when targeting its first child This pulls in the latest `FixItApplier` from swift-syntax `main` (d647052), which is now String-based. Fixes #15.
1 parent 7505224 commit 8b031e3

File tree

5 files changed

+342
-69
lines changed

5 files changed

+342
-69
lines changed

Package.resolved

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Sources/MacroTesting/AssertMacro.swift

Lines changed: 59 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,16 @@ public func assertMacro(
250250
if !allDiagnostics.isEmpty && allDiagnostics.allSatisfy({ !$0.fixIts.isEmpty }) {
251251
offset += 1
252252

253+
let edits =
254+
context.diagnostics
255+
.flatMap(\.fixIts)
256+
.flatMap { $0.changes }
257+
.map { $0.edit(in: context) }
258+
253259
var fixedSourceFile = origSourceFile
254260
fixedSourceFile = Parser.parse(
255-
source: FixItApplier.applyFixes(
256-
context: context, in: allDiagnostics.map(anchor), to: origSourceFile
261+
source: FixItApplier.apply(
262+
edits: edits, to: origSourceFile
257263
)
258264
.description
259265
)
@@ -343,6 +349,57 @@ public func assertMacro(
343349
}
344350
}
345351

352+
// From: https://github.com/apple/swift-syntax/blob/d647052/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift
353+
extension FixIt.Change {
354+
/// Returns the edit for this change, translating positions from detached nodes
355+
/// to the corresponding locations in the original source file based on
356+
/// `expansionContext`.
357+
///
358+
/// - SeeAlso: `FixIt.Change.edit`
359+
fileprivate func edit(in expansionContext: BasicMacroExpansionContext) -> SourceEdit {
360+
switch self {
361+
case .replace(let oldNode, let newNode):
362+
let start = expansionContext.position(of: oldNode.position, anchoredAt: oldNode)
363+
let end = expansionContext.position(of: oldNode.endPosition, anchoredAt: oldNode)
364+
return SourceEdit(
365+
range: start..<end,
366+
replacement: newNode.description
367+
)
368+
369+
case .replaceLeadingTrivia(let token, let newTrivia):
370+
let start = expansionContext.position(of: token.position, anchoredAt: token)
371+
let end = expansionContext.position(
372+
of: token.positionAfterSkippingLeadingTrivia, anchoredAt: token)
373+
return SourceEdit(
374+
range: start..<end,
375+
replacement: newTrivia.description
376+
)
377+
378+
case .replaceTrailingTrivia(let token, let newTrivia):
379+
let start = expansionContext.position(
380+
of: token.endPositionBeforeTrailingTrivia, anchoredAt: token)
381+
let end = expansionContext.position(of: token.endPosition, anchoredAt: token)
382+
return SourceEdit(
383+
range: start..<end,
384+
replacement: newTrivia.description
385+
)
386+
}
387+
}
388+
}
389+
390+
// From: https://github.com/apple/swift-syntax/blob/d647052/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift
391+
extension BasicMacroExpansionContext {
392+
/// Translates a position from a detached node to the corresponding position
393+
/// in the original source file.
394+
fileprivate func position(
395+
of position: AbsolutePosition,
396+
anchoredAt node: some SyntaxProtocol
397+
) -> AbsolutePosition {
398+
let location = self.location(for: position, anchoredAt: Syntax(node), fileName: "")
399+
return AbsolutePosition(utf8Offset: location.offset)
400+
}
401+
}
402+
346403
/// Asserts that a given Swift source string matches an expected string with all macros expanded.
347404
///
348405
/// See ``assertMacro(_:indentationWidth:record:of:diagnostics:fixes:expansion:file:function:line:column:)-pkfi``
@@ -619,69 +676,6 @@ extension Dictionary where Key == String, Value == Macro.Type {
619676
}
620677
}
621678

622-
private class FixItApplier: SyntaxRewriter {
623-
let context: BasicMacroExpansionContext
624-
let diagnostics: [Diagnostic]
625-
626-
init(context: BasicMacroExpansionContext, diagnostics: [Diagnostic]) {
627-
self.context = context
628-
self.diagnostics = diagnostics
629-
super.init(viewMode: .all)
630-
}
631-
632-
public override func visitAny(_ node: Syntax) -> Syntax? {
633-
for diagnostic in diagnostics {
634-
for fixIts in diagnostic.fixIts {
635-
for change in fixIts.changes {
636-
switch change {
637-
case .replace(let oldNode, let newNode):
638-
let offset =
639-
context
640-
.location(for: oldNode.position, anchoredAt: oldNode, fileName: "")
641-
.offset
642-
if node.position.utf8Offset == offset {
643-
return newNode
644-
}
645-
default:
646-
break
647-
}
648-
}
649-
}
650-
}
651-
return nil
652-
}
653-
654-
override func visit(_ node: TokenSyntax) -> TokenSyntax {
655-
var modifiedNode = node
656-
for diagnostic in diagnostics {
657-
for fixIts in diagnostic.fixIts {
658-
for change in fixIts.changes {
659-
switch change {
660-
case .replaceLeadingTrivia(token: let changedNode, let newTrivia)
661-
where changedNode.id == node.id:
662-
modifiedNode = node.with(\.leadingTrivia, newTrivia)
663-
case .replaceTrailingTrivia(token: let changedNode, let newTrivia)
664-
where changedNode.id == node.id:
665-
modifiedNode = node.with(\.trailingTrivia, newTrivia)
666-
default:
667-
break
668-
}
669-
}
670-
}
671-
}
672-
return modifiedNode
673-
}
674-
675-
public static func applyFixes(
676-
context: BasicMacroExpansionContext,
677-
in diagnostics: [Diagnostic],
678-
to tree: some SyntaxProtocol
679-
) -> Syntax {
680-
let applier = FixItApplier(context: context, diagnostics: diagnostics)
681-
return applier.rewrite(tree)
682-
}
683-
}
684-
685679
private let oldPrefix = "\u{2212}"
686680
private let newPrefix = "+"
687681
private let prefix = "\u{2007}"
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2023 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+
import SwiftSyntax
14+
15+
/// A textual edit to the original source represented by a range and a
16+
/// replacement.
17+
public struct SourceEdit: Equatable {
18+
/// The half-open range that this edit applies to.
19+
public let range: Range<AbsolutePosition>
20+
/// The text to replace the original range with. Empty for a deletion.
21+
public let replacement: String
22+
23+
/// Length of the original source range that this edit applies to. Zero if
24+
/// this is an addition.
25+
public var length: SourceLength {
26+
return SourceLength(utf8Length: range.lowerBound.utf8Offset - range.upperBound.utf8Offset)
27+
}
28+
29+
/// Create an edit to replace `range` in the original source with
30+
/// `replacement`.
31+
public init(range: Range<AbsolutePosition>, replacement: String) {
32+
self.range = range
33+
self.replacement = replacement
34+
}
35+
36+
/// Convenience function to create a textual addition after the given node
37+
/// and its trivia.
38+
public static func insert(_ newText: String, after node: some SyntaxProtocol) -> SourceEdit {
39+
return SourceEdit(range: node.endPosition..<node.endPosition, replacement: newText)
40+
}
41+
42+
/// Convenience function to create a textual addition before the given node
43+
/// and its trivia.
44+
public static func insert(_ newText: String, before node: some SyntaxProtocol) -> SourceEdit {
45+
return SourceEdit(range: node.position..<node.position, replacement: newText)
46+
}
47+
48+
/// Convenience function to create a textual replacement of the given node,
49+
/// including its trivia.
50+
public static func replace(_ node: some SyntaxProtocol, with replacement: String) -> SourceEdit {
51+
return SourceEdit(range: node.position..<node.endPosition, replacement: replacement)
52+
}
53+
54+
/// Convenience function to create a textual deletion the given node and its
55+
/// trivia.
56+
public static func remove(_ node: some SyntaxProtocol) -> SourceEdit {
57+
return SourceEdit(range: node.position..<node.endPosition, replacement: "")
58+
}
59+
}
60+
61+
extension SourceEdit: CustomDebugStringConvertible {
62+
public var debugDescription: String {
63+
let hasNewline = replacement.contains { $0.isNewline }
64+
if hasNewline {
65+
return #"""
66+
\#(range.lowerBound.utf8Offset)-\#(range.upperBound.utf8Offset)
67+
"""
68+
\#(replacement)
69+
"""
70+
"""#
71+
}
72+
return "\(range.lowerBound.utf8Offset)-\(range.upperBound.utf8Offset) \"\(replacement)\""
73+
}
74+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2023 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+
import SwiftDiagnostics
14+
import SwiftSyntax
15+
import SwiftSyntaxMacroExpansion
16+
17+
public enum FixItApplier {
18+
/// Applies selected or all Fix-Its from the provided diagnostics to a given syntax tree.
19+
///
20+
/// - Parameters:
21+
/// - diagnostics: An array of `Diagnostic` objects, each containing one or more Fix-Its.
22+
/// - filterByMessages: An optional array of message strings to filter which Fix-Its to apply.
23+
/// If `nil`, the first Fix-It from each diagnostic is applied.
24+
/// - tree: The syntax tree to which the Fix-Its will be applied.
25+
///
26+
/// - Returns: A `String` representation of the modified syntax tree after applying the Fix-Its.
27+
// public static func applyFixes(
28+
// from diagnostics: [Diagnostic],
29+
// filterByMessages messages: [String]?,
30+
// to tree: any SyntaxProtocol
31+
// ) -> String {
32+
// let messages = messages ?? diagnostics.compactMap { $0.fixIts.first?.message.message }
33+
//
34+
// let edits =
35+
// diagnostics
36+
// .flatMap(\.fixIts)
37+
// .filter { messages.contains($0.message.message) }
38+
// .flatMap(\.edits)
39+
//
40+
// return self.apply(edits: edits, to: tree)
41+
// }
42+
43+
/// Apply the given edits to the syntax tree.
44+
///
45+
/// - Parameters:
46+
/// - edits: The edits to apply to the syntax tree
47+
/// - tree: he syntax tree to which the edits should be applied.
48+
/// - Returns: A `String` representation of the modified syntax tree after applying the edits.
49+
public static func apply(
50+
edits: [SourceEdit],
51+
to tree: any SyntaxProtocol
52+
) -> String {
53+
var edits = edits
54+
var source = tree.description
55+
56+
while let edit = edits.first {
57+
edits = Array(edits.dropFirst())
58+
59+
let startIndex = source.utf8.index(source.utf8.startIndex, offsetBy: edit.startUtf8Offset)
60+
let endIndex = source.utf8.index(source.utf8.startIndex, offsetBy: edit.endUtf8Offset)
61+
62+
source.replaceSubrange(startIndex..<endIndex, with: edit.replacement)
63+
64+
edits = edits.compactMap { remainingEdit -> SourceEdit? in
65+
if remainingEdit.replacementRange.overlaps(edit.replacementRange) {
66+
// The edit overlaps with the previous edit. We can't apply both
67+
// without conflicts. Apply the one that's listed first and drop the
68+
// later edit.
69+
return nil
70+
}
71+
72+
// If the remaining edit starts after or at the end of the edit that we just applied,
73+
// shift it by the current edit's difference in length.
74+
if edit.endUtf8Offset <= remainingEdit.startUtf8Offset {
75+
let startPosition = AbsolutePosition(
76+
utf8Offset: remainingEdit.startUtf8Offset - edit.replacementRange.count
77+
+ edit.replacementLength)
78+
let endPosition = AbsolutePosition(
79+
utf8Offset: remainingEdit.endUtf8Offset - edit.replacementRange.count
80+
+ edit.replacementLength)
81+
return SourceEdit(
82+
range: startPosition..<endPosition, replacement: remainingEdit.replacement)
83+
}
84+
85+
return remainingEdit
86+
}
87+
}
88+
89+
return source
90+
}
91+
}
92+
93+
extension SourceEdit {
94+
fileprivate var startUtf8Offset: Int {
95+
return range.lowerBound.utf8Offset
96+
}
97+
98+
fileprivate var endUtf8Offset: Int {
99+
return range.upperBound.utf8Offset
100+
}
101+
102+
fileprivate var replacementLength: Int {
103+
return replacement.utf8.count
104+
}
105+
106+
fileprivate var replacementRange: Range<Int> {
107+
return startUtf8Offset..<endUtf8Offset
108+
}
109+
}

0 commit comments

Comments
 (0)