Skip to content

Commit

Permalink
Fix application of fix-its replacing parent instead of first child (#16)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
gohanlon authored Jan 3, 2024
1 parent 7505224 commit 8b031e3
Show file tree
Hide file tree
Showing 5 changed files with 342 additions and 69 deletions.
8 changes: 4 additions & 4 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
"state" : {
"revision" : "4862d48562483d274a2ac7522d905c9237a31a48",
"version" : "1.15.0"
"revision" : "59b663f68e69f27a87b45de48cb63264b8194605",
"version" : "1.15.1"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-syntax.git",
"state" : {
"revision" : "74203046135342e4a4a627476dd6caf8b28fe11b",
"version" : "509.0.0"
"revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036",
"version" : "509.0.2"
}
}
],
Expand Down
124 changes: 59 additions & 65 deletions Sources/MacroTesting/AssertMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,10 +250,16 @@ public func assertMacro(
if !allDiagnostics.isEmpty && allDiagnostics.allSatisfy({ !$0.fixIts.isEmpty }) {
offset += 1

let edits =
context.diagnostics
.flatMap(\.fixIts)
.flatMap { $0.changes }
.map { $0.edit(in: context) }

var fixedSourceFile = origSourceFile
fixedSourceFile = Parser.parse(
source: FixItApplier.applyFixes(
context: context, in: allDiagnostics.map(anchor), to: origSourceFile
source: FixItApplier.apply(
edits: edits, to: origSourceFile
)
.description
)
Expand Down Expand Up @@ -343,6 +349,57 @@ public func assertMacro(
}
}

// From: https://github.com/apple/swift-syntax/blob/d647052/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift
extension FixIt.Change {
/// Returns the edit for this change, translating positions from detached nodes
/// to the corresponding locations in the original source file based on
/// `expansionContext`.
///
/// - SeeAlso: `FixIt.Change.edit`
fileprivate func edit(in expansionContext: BasicMacroExpansionContext) -> SourceEdit {
switch self {
case .replace(let oldNode, let newNode):
let start = expansionContext.position(of: oldNode.position, anchoredAt: oldNode)
let end = expansionContext.position(of: oldNode.endPosition, anchoredAt: oldNode)
return SourceEdit(
range: start..<end,
replacement: newNode.description
)

case .replaceLeadingTrivia(let token, let newTrivia):
let start = expansionContext.position(of: token.position, anchoredAt: token)
let end = expansionContext.position(
of: token.positionAfterSkippingLeadingTrivia, anchoredAt: token)
return SourceEdit(
range: start..<end,
replacement: newTrivia.description
)

case .replaceTrailingTrivia(let token, let newTrivia):
let start = expansionContext.position(
of: token.endPositionBeforeTrailingTrivia, anchoredAt: token)
let end = expansionContext.position(of: token.endPosition, anchoredAt: token)
return SourceEdit(
range: start..<end,
replacement: newTrivia.description
)
}
}
}

// From: https://github.com/apple/swift-syntax/blob/d647052/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift
extension BasicMacroExpansionContext {
/// Translates a position from a detached node to the corresponding position
/// in the original source file.
fileprivate func position(
of position: AbsolutePosition,
anchoredAt node: some SyntaxProtocol
) -> AbsolutePosition {
let location = self.location(for: position, anchoredAt: Syntax(node), fileName: "")
return AbsolutePosition(utf8Offset: location.offset)
}
}

/// Asserts that a given Swift source string matches an expected string with all macros expanded.
///
/// See ``assertMacro(_:indentationWidth:record:of:diagnostics:fixes:expansion:file:function:line:column:)-pkfi``
Expand Down Expand Up @@ -619,69 +676,6 @@ extension Dictionary where Key == String, Value == Macro.Type {
}
}

private class FixItApplier: SyntaxRewriter {
let context: BasicMacroExpansionContext
let diagnostics: [Diagnostic]

init(context: BasicMacroExpansionContext, diagnostics: [Diagnostic]) {
self.context = context
self.diagnostics = diagnostics
super.init(viewMode: .all)
}

public override func visitAny(_ node: Syntax) -> Syntax? {
for diagnostic in diagnostics {
for fixIts in diagnostic.fixIts {
for change in fixIts.changes {
switch change {
case .replace(let oldNode, let newNode):
let offset =
context
.location(for: oldNode.position, anchoredAt: oldNode, fileName: "")
.offset
if node.position.utf8Offset == offset {
return newNode
}
default:
break
}
}
}
}
return nil
}

override func visit(_ node: TokenSyntax) -> TokenSyntax {
var modifiedNode = node
for diagnostic in diagnostics {
for fixIts in diagnostic.fixIts {
for change in fixIts.changes {
switch change {
case .replaceLeadingTrivia(token: let changedNode, let newTrivia)
where changedNode.id == node.id:
modifiedNode = node.with(\.leadingTrivia, newTrivia)
case .replaceTrailingTrivia(token: let changedNode, let newTrivia)
where changedNode.id == node.id:
modifiedNode = node.with(\.trailingTrivia, newTrivia)
default:
break
}
}
}
}
return modifiedNode
}

public static func applyFixes(
context: BasicMacroExpansionContext,
in diagnostics: [Diagnostic],
to tree: some SyntaxProtocol
) -> Syntax {
let applier = FixItApplier(context: context, diagnostics: diagnostics)
return applier.rewrite(tree)
}
}

private let oldPrefix = "\u{2212}"
private let newPrefix = "+"
private let prefix = "\u{2007}"
74 changes: 74 additions & 0 deletions Sources/MacroTesting/SwiftSyntax/SourceEdit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2023 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
//
//===----------------------------------------------------------------------===//

import SwiftSyntax

/// A textual edit to the original source represented by a range and a
/// replacement.
public struct SourceEdit: Equatable {
/// The half-open range that this edit applies to.
public let range: Range<AbsolutePosition>
/// The text to replace the original range with. Empty for a deletion.
public let replacement: String

/// Length of the original source range that this edit applies to. Zero if
/// this is an addition.
public var length: SourceLength {
return SourceLength(utf8Length: range.lowerBound.utf8Offset - range.upperBound.utf8Offset)
}

/// Create an edit to replace `range` in the original source with
/// `replacement`.
public init(range: Range<AbsolutePosition>, replacement: String) {
self.range = range
self.replacement = replacement
}

/// Convenience function to create a textual addition after the given node
/// and its trivia.
public static func insert(_ newText: String, after node: some SyntaxProtocol) -> SourceEdit {
return SourceEdit(range: node.endPosition..<node.endPosition, replacement: newText)
}

/// Convenience function to create a textual addition before the given node
/// and its trivia.
public static func insert(_ newText: String, before node: some SyntaxProtocol) -> SourceEdit {
return SourceEdit(range: node.position..<node.position, replacement: newText)
}

/// Convenience function to create a textual replacement of the given node,
/// including its trivia.
public static func replace(_ node: some SyntaxProtocol, with replacement: String) -> SourceEdit {
return SourceEdit(range: node.position..<node.endPosition, replacement: replacement)
}

/// Convenience function to create a textual deletion the given node and its
/// trivia.
public static func remove(_ node: some SyntaxProtocol) -> SourceEdit {
return SourceEdit(range: node.position..<node.endPosition, replacement: "")
}
}

extension SourceEdit: CustomDebugStringConvertible {
public var debugDescription: String {
let hasNewline = replacement.contains { $0.isNewline }
if hasNewline {
return #"""
\#(range.lowerBound.utf8Offset)-\#(range.upperBound.utf8Offset)
"""
\#(replacement)
"""
"""#
}
return "\(range.lowerBound.utf8Offset)-\(range.upperBound.utf8Offset) \"\(replacement)\""
}
}
109 changes: 109 additions & 0 deletions Sources/MacroTesting/_SwiftSyntaxTestSupport/FixItApplier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2023 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
//
//===----------------------------------------------------------------------===//

import SwiftDiagnostics
import SwiftSyntax
import SwiftSyntaxMacroExpansion

public enum FixItApplier {
/// Applies selected or all Fix-Its from the provided diagnostics to a given syntax tree.
///
/// - Parameters:
/// - diagnostics: An array of `Diagnostic` objects, each containing one or more Fix-Its.
/// - 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.
///
/// - 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
// ) -> String {
// let messages = messages ?? diagnostics.compactMap { $0.fixIts.first?.message.message }
//
// let edits =
// diagnostics
// .flatMap(\.fixIts)
// .filter { messages.contains($0.message.message) }
// .flatMap(\.edits)
//
// return self.apply(edits: edits, to: tree)
// }

/// Apply the given edits to the 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.
public static func apply(
edits: [SourceEdit],
to tree: any SyntaxProtocol
) -> String {
var edits = edits
var source = tree.description

while let edit = edits.first {
edits = Array(edits.dropFirst())

let startIndex = source.utf8.index(source.utf8.startIndex, offsetBy: edit.startUtf8Offset)
let endIndex = source.utf8.index(source.utf8.startIndex, offsetBy: edit.endUtf8Offset)

source.replaceSubrange(startIndex..<endIndex, with: edit.replacement)

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
}

// 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)
let endPosition = AbsolutePosition(
utf8Offset: remainingEdit.endUtf8Offset - edit.replacementRange.count
+ edit.replacementLength)
return SourceEdit(
range: startPosition..<endPosition, replacement: remainingEdit.replacement)
}

return remainingEdit
}
}

return source
}
}

extension SourceEdit {
fileprivate var startUtf8Offset: Int {
return range.lowerBound.utf8Offset
}

fileprivate var endUtf8Offset: Int {
return range.upperBound.utf8Offset
}

fileprivate var replacementLength: Int {
return replacement.utf8.count
}

fileprivate var replacementRange: Range<Int> {
return startUtf8Offset..<endUtf8Offset
}
}
Loading

0 comments on commit 8b031e3

Please sign in to comment.