From bf9f9f75c08a0ee629bd35d596b58f77ef9ed9ef Mon Sep 17 00:00:00 2001 From: broken-circle <252359939+broken-circle@users.noreply.github.com> Date: Fri, 15 May 2026 21:23:06 -0700 Subject: [PATCH] Remove spurious leading newline in `AttributeRemover` When the removed attribute's leading trivia contains the newline that ends its line, the existing backward-trim drops the newline and the trailing indentation. But when the attribute has no leading trivia of its own (e.g. it's the first token of the file), the line-terminating newline lives on the following node, out of reach of the backward-trim, and is preserved as a spurious leading newline. Detect that case and record that the next call to attach accumulated trivia should also drop one leading newline from the combined result, whether it ends up on the next kept attribute or the next token. --- .../MacroSystem.swift | 44 +++++++++++++++++-- .../AttributeRemoverTests.swift | 15 +++---- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/Sources/SwiftSyntaxMacroExpansion/MacroSystem.swift b/Sources/SwiftSyntaxMacroExpansion/MacroSystem.swift index d3b356106c5..00c4369add1 100644 --- a/Sources/SwiftSyntaxMacroExpansion/MacroSystem.swift +++ b/Sources/SwiftSyntaxMacroExpansion/MacroSystem.swift @@ -527,6 +527,16 @@ public class AttributeRemover: SyntaxRewriter { var triviaToAttachToNextToken: Trivia = Trivia() + /// When `true`, the next call to `prependAndClearAccumulatedTrivia(to:)` + /// should also drop one leading newline (and any spaces/tabs that + /// immediately precede it) from the resulting trivia. + /// + /// This is set when we remove an attribute that occupies its own line but + /// whose own leading trivia does not contain the line-terminating newline + /// (e.g. the attribute is the first token of the file, so the newline + /// terminating its line lives on the following node). + var dropLeadingNewlineOnNextNode: Bool = false + /// Initializes an attribute remover with a given predicate to determine which attributes to remove. /// /// - Parameter predicate: A closure that determines whether a given `AttributeSyntax` should be removed. @@ -560,6 +570,16 @@ public class AttributeRemover: SyntaxRewriter { !nextToken.leadingTrivia.isEmpty { leadingTrivia = Trivia(pieces: leadingTrivia.pieces[..(to syntaxNode: T) -> T { - guard !triviaToAttachToNextToken.isEmpty else { return syntaxNode } - defer { triviaToAttachToNextToken = Trivia() } - return syntaxNode.with(\.leadingTrivia, triviaToAttachToNextToken + syntaxNode.leadingTrivia) + guard !triviaToAttachToNextToken.isEmpty || dropLeadingNewlineOnNextNode else { + return syntaxNode + } + defer { + triviaToAttachToNextToken = Trivia() + dropLeadingNewlineOnNextNode = false + } + + var combined = triviaToAttachToNextToken + syntaxNode.leadingTrivia + if dropLeadingNewlineOnNextNode, + let firstNewline = combined.pieces.firstIndex(where: \.isNewline), + combined.pieces[..