Skip to content

Commit 52ed95c

Browse files
committed
Move comments using a SyntaxRewriter.
1 parent c6a4b5d commit 52ed95c

File tree

4 files changed

+354
-51
lines changed

4 files changed

+354
-51
lines changed

Sources/SwiftFormatCore/Trivia+Convenience.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ extension Trivia {
6363
return count
6464
}
6565

66+
/// Returns whether the trivia contains at least 1 `lineComment`.
67+
public var hasLineComment: Bool {
68+
return self.contains {
69+
if case .lineComment = $0 { return true }
70+
return false
71+
}
72+
}
73+
6674
public var hasSpaces: Bool {
6775
for piece in self {
6876
if case .tabs = piece { return true }

Sources/SwiftFormatPrettyPrint/TokenStreamCreator.swift

Lines changed: 159 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -447,10 +447,24 @@ private final class TokenStreamCreator: SyntaxVisitor {
447447

448448
arrangeBracesAndContents(of: node.body, contentsKeyPath: \.statements)
449449

450-
let elsePrecedingBreak = config.lineBreakBeforeControlFlowKeywords ? Token.newline : Token.space
451-
before(node.elseKeyword, tokens: elsePrecedingBreak)
452-
if node.elseBody is IfStmtSyntax {
453-
after(node.elseKeyword, tokens: .space)
450+
if let elseKeyword = node.elseKeyword {
451+
// Add a token before the else keyword. Breaking before `else` is explicitly allowed when
452+
// there's a comment.
453+
if config.lineBreakBeforeControlFlowKeywords {
454+
before(elseKeyword, tokens: .newline)
455+
} else if elseKeyword.leadingTrivia.hasLineComment {
456+
before(elseKeyword, tokens: .break(.same, size: 1))
457+
} else {
458+
before(elseKeyword, tokens: .space)
459+
}
460+
461+
// Breaks are only allowed after `else` when there's a comment; otherwise there shouldn't be
462+
// any newlines between `else` and the open brace or a following `if`.
463+
if let tokenAfterElse = elseKeyword.nextToken, tokenAfterElse.leadingTrivia.hasLineComment {
464+
after(node.elseKeyword, tokens: .break(.same, size: 1))
465+
} else if node.elseBody is IfStmtSyntax {
466+
after(node.elseKeyword, tokens: .space)
467+
}
454468
}
455469

456470
arrangeBracesAndContents(of: node.elseBody as? CodeBlockSyntax, contentsKeyPath: \.statements)
@@ -631,6 +645,14 @@ private final class TokenStreamCreator: SyntaxVisitor {
631645
// `stackedIndentationBehavior`).
632646
after(node.leftParen, tokens: .open)
633647
before(node.rightParen, tokens: .close)
648+
649+
// When there's a comment inside of a parenthesized expression, we want to allow the comment
650+
// to exist at the EOL with the left paren or on its own line. The contents are always
651+
// indented on the following lines, since parens always create a scope. An open/close break
652+
// pair isn't used here to avoid forcing the closing paren down onto a new line.
653+
if node.leftParen.nextToken?.leadingTrivia.hasLineComment ?? false {
654+
after(node.leftParen, tokens: .break(.continue, size: 0))
655+
}
634656
} else if elementCount > 1 {
635657
// Tuples with more than one element are "true" tuples, and should indent as block structures.
636658
after(node.leftParen, tokens: .break(.open, size: 0), .open)
@@ -2717,50 +2739,6 @@ private final class TokenStreamCreator: SyntaxVisitor {
27172739
// Add this break so that trivia parsing will allow discretionary newlines after the node.
27182740
appendToken(.break(.same, size: 0))
27192741
}
2720-
2721-
/// Returns whether the given trivia includes a directive to ignore formatting for the next node.
2722-
///
2723-
/// - Parameter trivia: Leading trivia for a node that the formatter supports ignoring.
2724-
private func isFormatterIgnorePresent(inTrivia trivia: Trivia) -> Bool {
2725-
func isFormatterIgnore(in commentText: String, prefix: String, suffix: String) -> Bool {
2726-
let trimmed =
2727-
commentText.dropFirst(prefix.count)
2728-
.dropLast(suffix.count)
2729-
.trimmingCharacters(in: .whitespaces)
2730-
return trimmed == "swift-format-ignore"
2731-
}
2732-
2733-
for piece in trivia {
2734-
switch piece {
2735-
case .lineComment(let text):
2736-
if isFormatterIgnore(in: text, prefix: "//", suffix: "") { return true }
2737-
break
2738-
case .blockComment(let text):
2739-
if isFormatterIgnore(in: text, prefix: "/*", suffix: "*/") { return true }
2740-
break
2741-
default:
2742-
break
2743-
}
2744-
}
2745-
return false
2746-
}
2747-
2748-
/// Returns whether the formatter should ignore the given node by printing it without changing the
2749-
/// node's internal text representation (i.e. print all text inside of the node as it was in the
2750-
/// original source).
2751-
///
2752-
/// - Note: The caller is responsible for ensuring that the given node is a type of node that can
2753-
/// be safely ignored.
2754-
///
2755-
/// - Parameter node: A node that can be safely ignored.
2756-
private func shouldFormatterIgnore(node: Syntax) -> Bool {
2757-
// Regardless of the level of nesting, if the ignore directive is present on the first token
2758-
// contained within the node then the entire node is eligible for ignoring.
2759-
if let firstTrivia = node.firstToken?.leadingTrivia {
2760-
return isFormatterIgnorePresent(inTrivia: firstTrivia)
2761-
}
2762-
return false
2763-
}
27642742
}
27652743

27662744
extension Syntax {
@@ -2769,8 +2747,10 @@ extension Syntax {
27692747
// First, fold the sequence expressions in the tree before passing it along to the token stream,
27702748
// because we don't want to modify the tree during token stream creation.
27712749
let folded = SequenceExprFoldingRewriter(operatorContext: operatorContext).visit(self)
2750+
let commentsMoved = CommentMovingRewriter().visit(folded)
27722751
return TokenStreamCreator(
2773-
configuration: configuration, operatorContext: operatorContext).makeStream(from: folded)
2752+
configuration: configuration, operatorContext: operatorContext)
2753+
.makeStream(from: commentsMoved)
27742754
}
27752755
}
27762756

@@ -2792,8 +2772,138 @@ class SequenceExprFoldingRewriter: SyntaxRewriter {
27922772
}
27932773
}
27942774

2775+
/// Rewriter that relocates comment trivia around nodes where comments are known to be better
2776+
/// formatted when placed before or after the node.
2777+
///
2778+
/// For example, comments after binary operators are relocated to be before the operator, which
2779+
/// results in fewer line breaks with the comment closer to the relevant tokens.
2780+
class CommentMovingRewriter: SyntaxRewriter {
2781+
/// Map of tokens to alternate trivia to use as the token's leading trivia.
2782+
var rewriteTokenTriviaMap: [TokenSyntax: Trivia] = [:]
2783+
2784+
override func visit(_ node: CodeBlockItemSyntax) -> Syntax {
2785+
if shouldFormatterIgnore(node: node) {
2786+
return node
2787+
}
2788+
return super.visit(node)
2789+
}
2790+
2791+
override func visit(_ node: MemberDeclListItemSyntax) -> Syntax {
2792+
if shouldFormatterIgnore(node: node) {
2793+
return node
2794+
}
2795+
return super.visit(node)
2796+
}
2797+
2798+
override func visit(_ token: TokenSyntax) -> Syntax {
2799+
if let rewrittenTrivia = rewriteTokenTriviaMap[token] {
2800+
return token.withLeadingTrivia(rewrittenTrivia)
2801+
}
2802+
return token
2803+
}
2804+
2805+
override func visit(_ node: SequenceExprSyntax) -> ExprSyntax {
2806+
for element in node.elements {
2807+
if let binaryOperatorExpr = element as? BinaryOperatorExprSyntax,
2808+
let followingToken = binaryOperatorExpr.operatorToken.nextToken,
2809+
followingToken.leadingTrivia.hasLineComment
2810+
{
2811+
// Rewrite the trivia so that the comment is in the operator token's leading trivia.
2812+
let (remainingTrivia, extractedTrivia) = extractLineCommentTrivia(from: followingToken)
2813+
let combinedTrivia = binaryOperatorExpr.operatorToken.leadingTrivia + extractedTrivia
2814+
rewriteTokenTriviaMap[binaryOperatorExpr.operatorToken] = combinedTrivia
2815+
rewriteTokenTriviaMap[followingToken] = remainingTrivia
2816+
}
2817+
}
2818+
return super.visit(node)
2819+
}
2820+
2821+
/// Extracts trivia containing and related to line comments from `token`'s leading trivia. Returns
2822+
/// 2 trivia collections: the trivia that wasn't extracted and should remain in `token`'s leading
2823+
/// trivia and the trivia that meets the criteria for extraction.
2824+
/// - Parameter token: A token whose leading trivia should be split to extract line comments.
2825+
private func extractLineCommentTrivia(from token: TokenSyntax) -> (
2826+
remainingTrivia: Trivia, extractedTrivia: Trivia
2827+
) {
2828+
var pendingPieces = [TriviaPiece]()
2829+
var keepWithTokenPieces = [TriviaPiece]()
2830+
var extractingPieces = [TriviaPiece]()
2831+
2832+
// Line comments and adjacent newlines are extracted so they can be moved to a different token's
2833+
// leading trivia, while all other kinds of tokens are left as-is.
2834+
var lastPiece: TriviaPiece?
2835+
for piece in token.leadingTrivia {
2836+
defer { lastPiece = piece }
2837+
switch piece {
2838+
case .lineComment:
2839+
extractingPieces.append(contentsOf: pendingPieces)
2840+
pendingPieces.removeAll()
2841+
extractingPieces.append(piece)
2842+
case .blockComment, .docLineComment, .docBlockComment:
2843+
keepWithTokenPieces.append(contentsOf: pendingPieces)
2844+
pendingPieces.removeAll()
2845+
keepWithTokenPieces.append(piece)
2846+
case .newlines, .carriageReturns, .carriageReturnLineFeeds:
2847+
if case .lineComment = lastPiece {
2848+
extractingPieces.append(piece)
2849+
} else {
2850+
pendingPieces.append(piece)
2851+
}
2852+
default:
2853+
pendingPieces.append(piece)
2854+
}
2855+
}
2856+
keepWithTokenPieces.append(contentsOf: pendingPieces)
2857+
return (Trivia(pieces: keepWithTokenPieces), Trivia(pieces: extractingPieces))
2858+
}
2859+
}
2860+
27952861
extension Collection {
27962862
subscript(safe index: Index) -> Element? {
27972863
return index < endIndex ? self[index] : nil
27982864
}
27992865
}
2866+
2867+
/// Returns whether the given trivia includes a directive to ignore formatting for the next node.
2868+
///
2869+
/// - Parameter trivia: Leading trivia for a node that the formatter supports ignoring.
2870+
private func isFormatterIgnorePresent(inTrivia trivia: Trivia) -> Bool {
2871+
func isFormatterIgnore(in commentText: String, prefix: String, suffix: String) -> Bool {
2872+
let trimmed =
2873+
commentText.dropFirst(prefix.count)
2874+
.dropLast(suffix.count)
2875+
.trimmingCharacters(in: .whitespaces)
2876+
return trimmed == "swift-format-ignore"
2877+
}
2878+
2879+
for piece in trivia {
2880+
switch piece {
2881+
case .lineComment(let text):
2882+
if isFormatterIgnore(in: text, prefix: "//", suffix: "") { return true }
2883+
break
2884+
case .blockComment(let text):
2885+
if isFormatterIgnore(in: text, prefix: "/*", suffix: "*/") { return true }
2886+
break
2887+
default:
2888+
break
2889+
}
2890+
}
2891+
return false
2892+
}
2893+
2894+
/// Returns whether the formatter should ignore the given node by printing it without changing the
2895+
/// node's internal text representation (i.e. print all text inside of the node as it was in the
2896+
/// original source).
2897+
///
2898+
/// - Note: The caller is responsible for ensuring that the given node is a type of node that can
2899+
/// be safely ignored.
2900+
///
2901+
/// - Parameter node: A node that can be safely ignored.
2902+
private func shouldFormatterIgnore(node: Syntax) -> Bool {
2903+
// Regardless of the level of nesting, if the ignore directive is present on the first token
2904+
// contained within the node then the entire node is eligible for ignoring.
2905+
if let firstTrivia = node.firstToken?.leadingTrivia {
2906+
return isFormatterIgnorePresent(inTrivia: firstTrivia)
2907+
}
2908+
return false
2909+
}

0 commit comments

Comments
 (0)