Skip to content

Commit be1e888

Browse files
authored
Merge pull request #2215 from gohanlon/improve-attribute-remover-trivia
Fix trivia handling in `AttributeRemover` of `MacroSystem`
2 parents 74e085a + c6ceed4 commit be1e888

File tree

2 files changed

+591
-16
lines changed

2 files changed

+591
-16
lines changed

Sources/SwiftSyntaxMacroExpansion/MacroSystem.swift

Lines changed: 77 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -397,36 +397,97 @@ private class AttributeRemover: SyntaxRewriter {
397397
var filteredAttributes: [AttributeListSyntax.Element] = []
398398
for case .attribute(let attribute) in node {
399399
if attributesToRemove.contains(attribute) {
400-
var leadingTrivia = node.leadingTrivia
401-
if let lastNewline = leadingTrivia.pieces.lastIndex(where: { $0.isNewline }),
400+
var leadingTrivia = attribute.leadingTrivia
401+
402+
// Don't leave behind an empty line when the attribute being removed is on its own line,
403+
// based on the following conditions:
404+
// - Leading trivia ends with a newline followed by arbitrary number of spaces or tabs
405+
// - All leading trivia pieces after the last newline are just whitespace, ensuring
406+
// there are no comments or other non-whitespace characters on the same line
407+
// preceding the attribute.
408+
// - There is no trailing trivia and the next token has leading trivia.
409+
if let lastNewline = leadingTrivia.pieces.lastIndex(where: \.isNewline),
402410
leadingTrivia.pieces[lastNewline...].allSatisfy(\.isWhitespace),
403-
node.trailingTrivia.isEmpty,
404-
node.nextToken(viewMode: .sourceAccurate)?.leadingTrivia.first?.isNewline ?? false
411+
attribute.trailingTrivia.isEmpty,
412+
let nextToken = attribute.nextToken(viewMode: .sourceAccurate),
413+
!nextToken.leadingTrivia.isEmpty
405414
{
406-
// If the attribute is on its own line based on the following conditions,
407-
// remove the newline from it so we don’t end up with an empty line
408-
// - Trailing trivia ends with a newline followed by arbitrary number of spaces or tabs
409-
// - There is no trailing trivia and the next token starts on a new line
410415
leadingTrivia = Trivia(pieces: leadingTrivia.pieces[..<lastNewline])
411416
}
417+
412418
// Drop any spaces or tabs from the trailing trivia because there’s no
413419
// more attribute they need to separate.
414-
let trailingTrivia = Trivia(pieces: attribute.trailingTrivia.drop(while: { $0.isSpaceOrTab }))
420+
let trailingTrivia = attribute.trailingTrivia.trimmingPrefix(while: \.isSpaceOrTab)
415421
triviaToAttachToNextToken += leadingTrivia + trailingTrivia
422+
423+
// If the attribute is not separated from the previous attribute by trivia, as in
424+
// `@First@Second var x: Int` (yes, that's valid Swift), removing the `@Second`
425+
// attribute and dropping all its trivia would cause `@First` and `var` to join
426+
// without any trivia in between, which is invalid. In such cases, the trailing trivia
427+
// of the attribute is significant and must be retained.
428+
if triviaToAttachToNextToken.isEmpty,
429+
let previousToken = attribute.previousToken(viewMode: .sourceAccurate),
430+
previousToken.trailingTrivia.isEmpty
431+
{
432+
triviaToAttachToNextToken = attribute.trailingTrivia
433+
}
416434
} else {
417-
filteredAttributes.append(.attribute(attribute))
435+
filteredAttributes.append(.attribute(prependAndClearAccumulatedTrivia(to: attribute)))
436+
}
437+
}
438+
439+
// Ensure that any horizontal whitespace trailing the attributes list is trimmed if the next
440+
// token starts a new line.
441+
if let nextToken = node.nextToken(viewMode: .sourceAccurate),
442+
nextToken.leadingTrivia.startsWithNewline
443+
{
444+
if !triviaToAttachToNextToken.isEmpty {
445+
triviaToAttachToNextToken = triviaToAttachToNextToken.trimmingSuffix(while: \.isSpaceOrTab)
446+
} else if let lastAttribute = filteredAttributes.last {
447+
filteredAttributes[filteredAttributes.count - 1].trailingTrivia = lastAttribute.trailingTrivia.trimmingSuffix(while: \.isSpaceOrTab)
418448
}
419449
}
420450
return AttributeListSyntax(filteredAttributes)
421451
}
422452

423453
override func visit(_ token: TokenSyntax) -> TokenSyntax {
424-
if !triviaToAttachToNextToken.isEmpty {
425-
defer { triviaToAttachToNextToken = Trivia() }
426-
return token.with(\.leadingTrivia, triviaToAttachToNextToken + token.leadingTrivia)
427-
} else {
428-
return token
429-
}
454+
return prependAndClearAccumulatedTrivia(to: token)
455+
}
456+
457+
/// Prepends the accumulated trivia to the given node's leading trivia.
458+
///
459+
/// To preserve correct formatting after attribute removal, this function reassigns
460+
/// significant trivia accumulated from removed attributes to the provided subsequent node.
461+
/// Once attached, the accumulated trivia is cleared.
462+
///
463+
/// - Parameter node: The syntax node receiving the accumulated trivia.
464+
/// - Returns: The modified syntax node with the prepended trivia.
465+
private func prependAndClearAccumulatedTrivia<T: SyntaxProtocol>(to syntaxNode: T) -> T {
466+
defer { triviaToAttachToNextToken = Trivia() }
467+
return syntaxNode.with(\.leadingTrivia, triviaToAttachToNextToken + syntaxNode.leadingTrivia)
468+
}
469+
}
470+
471+
private extension Trivia {
472+
func trimmingPrefix(
473+
while predicate: (TriviaPiece) -> Bool
474+
) -> Trivia {
475+
Trivia(pieces: self.drop(while: predicate))
476+
}
477+
478+
func trimmingSuffix(
479+
while predicate: (TriviaPiece) -> Bool
480+
) -> Trivia {
481+
Trivia(
482+
pieces: self[...]
483+
.reversed()
484+
.drop(while: predicate)
485+
.reversed()
486+
)
487+
}
488+
489+
var startsWithNewline: Bool {
490+
self.first?.isNewline ?? false
430491
}
431492
}
432493

0 commit comments

Comments
 (0)