Skip to content

Introduce FixIt.Change API that replaces the child of a syntax node #2758

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
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
6 changes: 6 additions & 0 deletions Release Notes/601.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@
- Description: Allows retrieving the radix value from the `literal.text`.
- Issue: https://github.com/apple/swift-syntax/issues/405
- Pull Request: https://github.com/apple/swift-syntax/pull/2605

- `FixIt.Change` gained a new case `replaceChild(data:)`.
- Description: The new case covers the replacement of a child node with another node.
- Issue: https://github.com/swiftlang/swift-syntax/issues/2205
- Pull Request: https://github.com/swiftlang/swift-syntax/pull/2758
- Migration steps: In exhaustive switches over `FixIt.Change`, cover the new case.

## Template

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,15 @@ extension PluginMessage.Diagnostic {
to: .afterTrailingTrivia
)
text = newTrivia.description
case .replaceChild(let replaceChildData):
range = sourceManager.range(replaceChildData.replacementRange, in: replaceChildData.parent)
text = replaceChildData.newChild.description
#if RESILIENT_LIBRARIES
@unknown default:
fatalError()
#endif
}
guard let range = range else {
guard let range else {
return nil
}
return .init(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,20 +169,25 @@ class SourceManager {
of node: Syntax,
from startKind: PositionInSyntaxNode = .afterLeadingTrivia,
to endKind: PositionInSyntaxNode = .beforeTrailingTrivia
) -> SourceRange? {
range(node.position(at: startKind)..<node.position(at: endKind), in: node)
}

/// Get ``SourceRange`` (file name + UTF-8 offset range) of `localRange` in `node`'s root node, which must be one
/// of the returned values from `add(_:)`.
func range(
_ localRange: @autoclosure () -> Range<AbsolutePosition>,
in node: some SyntaxProtocol
) -> SourceRange? {
guard let base = self.knownSourceSyntax[node.root.id] else {
return nil
}
let localStartPosition = node.position(at: startKind)
let localEndPosition = node.position(at: endKind)
precondition(localStartPosition <= localEndPosition)

let positionOffset = base.location.offset

let localRange = localRange()
return SourceRange(
fileName: base.location.fileName,
startUTF8Offset: localStartPosition.advanced(by: positionOffset).utf8Offset,
endUTF8Offset: localEndPosition.advanced(by: positionOffset).utf8Offset
startUTF8Offset: localRange.lowerBound.advanced(by: positionOffset).utf8Offset,
endUTF8Offset: localRange.upperBound.advanced(by: positionOffset).utf8Offset
)
}

Expand Down
16 changes: 16 additions & 0 deletions Sources/SwiftDiagnostics/Convenience.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,20 @@ extension FixIt {
]
)
}

public static func replaceChild<Parent: SyntaxProtocol, Child: SyntaxProtocol>(
message: FixItMessage,
parent: Parent,
replacingChildAt keyPath: WritableKeyPath<Parent, Child?> & Sendable,
with newChild: Child
) -> Self {
FixIt(
message: message,
changes: [
.replaceChild(
data: FixIt.Change.ReplacingOptionalChildData(parent: parent, newChild: newChild, keyPath: keyPath)
)
]
)
}
}
47 changes: 47 additions & 0 deletions Sources/SwiftDiagnostics/FixIt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,56 @@ public protocol FixItMessage: Sendable {
var fixItID: MessageID { get }
}

/// Types conforming to this protocol provide the data required for replacing a child node of a parent node.
///
/// Conforming types should ensure the child of ``parent`` to be replaced at ``replacementRange`` is type-compatible
/// with ``newChild``. Conforming types are stored as type-erased existentials (i.e. `any ReplacingChildData`) in
/// ``FixIt/Change/replaceChild(data:)`` to keep ``FixIt`` type-erased.
public protocol ReplacingChildData: Sendable {
associatedtype Parent: SyntaxProtocol
associatedtype Child: SyntaxProtocol

/// The node whose child node at ``replacementRange`` to be replaced by ``newChild``.
var parent: Parent { get }

/// The node to replace the child node of ``parent`` at ``replacementRange``.
var newChild: Child { get }

/// The absolute position range of the child node to be replaced.
///
/// If a nil child node is to be replaced, conforming types should provide a zero-length range with both bounds
/// denoting the start position of ``newChild`` in ``parent`` after replacement.
var replacementRange: Range<AbsolutePosition> { get }
}

/// A Fix-It that can be applied to resolve a diagnostic.
public struct FixIt: Sendable {
public enum Change: Sendable {
struct ReplacingOptionalChildData<Parent: SyntaxProtocol, Child: SyntaxProtocol>: ReplacingChildData {
let parent: Parent
let newChild: Child
let keyPath: WritableKeyPath<Parent, Child?> & Sendable

var replacementRange: Range<AbsolutePosition> {
// need to upcast keyPath to strip Sendable for older Swift versions
let keyPath: WritableKeyPath<Parent, Child?> = keyPath
if let oldChild = parent[keyPath: keyPath] {
return oldChild.range
} else {
let newChild = parent.with(keyPath, newChild)[keyPath: keyPath]!
return newChild.position..<newChild.position
}
}
}

/// Replace `oldNode` by `newNode`.
case replace(oldNode: Syntax, newNode: Syntax)
/// Replace the leading trivia on the given token
case replaceLeadingTrivia(token: TokenSyntax, newTrivia: Trivia)
/// Replace the trailing trivia on the given token
case replaceTrailingTrivia(token: TokenSyntax, newTrivia: Trivia)
/// Replace the child node of the given parent node at the given replacement range with the given new child node
case replaceChild(data: any ReplacingChildData)
}

/// A description of what this Fix-It performs.
Expand Down Expand Up @@ -89,6 +130,12 @@ private extension FixIt.Change {
range: token.endPositionBeforeTrailingTrivia..<token.endPosition,
replacement: newTrivia.description
)

case .replaceChild(let replacingChildData):
return SourceEdit(
range: replacingChildData.replacementRange,
replacement: replacingChildData.newChild.description
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,15 @@ fileprivate extension FixIt.Change {
range: start..<end,
replacement: newTrivia.description
)

case .replaceChild(let replacingChildData):
let range = replacingChildData.replacementRange
let start = expansionContext.position(of: range.lowerBound, anchoredAt: replacingChildData.parent)
let end = expansionContext.position(of: range.upperBound, anchoredAt: replacingChildData.parent)
return SourceEdit(
range: start..<end,
replacement: replacingChildData.newChild.description
)
}
}
}
Expand Down
17 changes: 5 additions & 12 deletions Tests/SwiftSyntaxMacroExpansionTest/PeerMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,24 +49,17 @@ final class PeerMacroTests: XCTestCase {
newEffects = FunctionEffectSpecifiersSyntax(asyncSpecifier: .keyword(.async))
}

let newSignature = funcDecl.signature.with(\.effectSpecifiers, newEffects)

let diag = Diagnostic(
node: Syntax(funcDecl.funcKeyword),
message: SwiftSyntaxMacros.MacroExpansionErrorMessage(
"can only add a completion-handler variant to an 'async' function"
),
fixIts: [
FixIt(
message: SwiftSyntaxMacros.MacroExpansionFixItMessage(
"add 'async'"
),
changes: [
FixIt.Change.replace(
oldNode: Syntax(funcDecl.signature),
newNode: Syntax(newSignature)
)
]
.replaceChild(
message: SwiftSyntaxMacros.MacroExpansionFixItMessage("add 'async'"),
parent: funcDecl,
replacingChildAt: \.signature.effectSpecifiers,
with: newEffects
)
]
)
Expand Down