Skip to content
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
8 changes: 4 additions & 4 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of inlining these in the library, what do you think of putting it in a file in the SwiftSyntax directory alongside SourceEdit, etc.?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I'm happy to do that. I didn't do so initially because the upstream extensions are fileprivate and kept close to the asserts that use them. Plus, these two extensions are just a small parts of their larger upstream file, and I think files from upstream have so far been pulled in wholesale with minimal alterations.

Here are the upstream extensions' signatures from SwiftSyntax's SwiftSyntaxMacrosTestSupport/Assertions.swift:

extension FixIt.Change {
  fileprivate func edit(
    in expansionContext: BasicMacroExpansionContext
  ) -> SourceEdit {
    
  }
}
extension BasicMacroExpansionContext {
  fileprivate func position(
    of position: AbsolutePosition,
    anchoredAt node: some SyntaxProtocol
  ) -> AbsolutePosition {
    
  }
}

I think it'd be okay to make the extensions internal instead of fileprivate. Here are some options:

  1. Since BasicMacroExpansionContext is common between the two extensions, make the extensions internal and add them to a new file: Sources/SwiftSyntax/SourceEditMacroExpansion.swift.
  2. Make the extensions internal and them directly to Sources/SwiftSyntax/SourceEdit.swift.
  3. If you'd rather leave them in AssertMacro.swift as fileprivate, I can move them to the end of the file.

For me to move forward, feel free to pick one of these ideas or throw in another that you think might work better. Looking forward to your input!

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