diff --git a/Documentation/RuleDocumentation.md b/Documentation/RuleDocumentation.md index 05cdeda22..ecb376ec1 100644 --- a/Documentation/RuleDocumentation.md +++ b/Documentation/RuleDocumentation.md @@ -29,6 +29,7 @@ Here's the list of available rules: - [NoAssignmentInExpressions](#NoAssignmentInExpressions) - [NoBlockComments](#NoBlockComments) - [NoCasesWithOnlyFallthrough](#NoCasesWithOnlyFallthrough) +- [NoEmptyLinesOpeningClosingBraces](#NoEmptyLinesOpeningClosingBraces) - [NoEmptyTrailingClosureParentheses](#NoEmptyTrailingClosureParentheses) - [NoLabelsInCasePatterns](#NoLabelsInCasePatterns) - [NoLeadingUnderscores](#NoLeadingUnderscores) @@ -271,6 +272,16 @@ Format: The fallthrough `case` is added as a prefix to the next case unless the `NoCasesWithOnlyFallthrough` rule can format your code automatically. +### NoEmptyLinesOpeningClosingBraces + +Empty lines are forbidden after opening braces and before closing braces. + +Lint: Empty lines after opening braces and before closing braces yield a lint error. + +Format: Empty lines after opening braces and before closing braces will be removed. + +`NoEmptyLinesOpeningClosingBraces` rule can format your code automatically. + ### NoEmptyTrailingClosureParentheses Function calls with no arguments and a trailing closure should not have empty parentheses. diff --git a/Sources/SwiftFormat/CMakeLists.txt b/Sources/SwiftFormat/CMakeLists.txt index 2564f90b8..471cfa719 100644 --- a/Sources/SwiftFormat/CMakeLists.txt +++ b/Sources/SwiftFormat/CMakeLists.txt @@ -73,6 +73,7 @@ add_library(SwiftFormat Rules/NoAssignmentInExpressions.swift Rules/NoBlockComments.swift Rules/NoCasesWithOnlyFallthrough.swift + Rules/NoEmptyLineOpeningClosingBraces.swift Rules/NoEmptyTrailingClosureParentheses.swift Rules/NoLabelsInCasePatterns.swift Rules/NoLeadingUnderscores.swift diff --git a/Sources/SwiftFormat/Core/Pipelines+Generated.swift b/Sources/SwiftFormat/Core/Pipelines+Generated.swift index 4e524c56b..6f22e1384 100644 --- a/Sources/SwiftFormat/Core/Pipelines+Generated.swift +++ b/Sources/SwiftFormat/Core/Pipelines+Generated.swift @@ -36,6 +36,14 @@ class LintPipeline: SyntaxVisitor { super.init(viewMode: .sourceAccurate) } + override func visit(_ node: AccessorBlockSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoEmptyLinesOpeningClosingBraces.visit, for: node) + return .visitChildren + } + override func visitPost(_ node: AccessorBlockSyntax) { + onVisitPost(rule: NoEmptyLinesOpeningClosingBraces.self, for: node) + } + override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { visitIfEnabled(AllPublicDeclarationsHaveDocumentation.visit, for: node) visitIfEnabled(TypeNamesShouldBeCapitalized.visit, for: node) @@ -93,10 +101,12 @@ class LintPipeline: SyntaxVisitor { } override func visit(_ node: ClosureExprSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoEmptyLinesOpeningClosingBraces.visit, for: node) visitIfEnabled(OmitExplicitReturns.visit, for: node) return .visitChildren } override func visitPost(_ node: ClosureExprSyntax) { + onVisitPost(rule: NoEmptyLinesOpeningClosingBraces.self, for: node) onVisitPost(rule: OmitExplicitReturns.self, for: node) } @@ -134,10 +144,12 @@ class LintPipeline: SyntaxVisitor { override func visit(_ node: CodeBlockSyntax) -> SyntaxVisitorContinueKind { visitIfEnabled(AmbiguousTrailingClosureOverload.visit, for: node) + visitIfEnabled(NoEmptyLinesOpeningClosingBraces.visit, for: node) return .visitChildren } override func visitPost(_ node: CodeBlockSyntax) { onVisitPost(rule: AmbiguousTrailingClosureOverload.self, for: node) + onVisitPost(rule: NoEmptyLinesOpeningClosingBraces.self, for: node) } override func visit(_ node: ConditionElementSyntax) -> SyntaxVisitorContinueKind { @@ -384,10 +396,12 @@ class LintPipeline: SyntaxVisitor { override func visit(_ node: MemberBlockSyntax) -> SyntaxVisitorContinueKind { visitIfEnabled(AmbiguousTrailingClosureOverload.visit, for: node) + visitIfEnabled(NoEmptyLinesOpeningClosingBraces.visit, for: node) return .visitChildren } override func visitPost(_ node: MemberBlockSyntax) { onVisitPost(rule: AmbiguousTrailingClosureOverload.self, for: node) + onVisitPost(rule: NoEmptyLinesOpeningClosingBraces.self, for: node) } override func visit(_ node: OptionalBindingConditionSyntax) -> SyntaxVisitorContinueKind { @@ -411,10 +425,12 @@ class LintPipeline: SyntaxVisitor { } override func visit(_ node: PrecedenceGroupDeclSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoEmptyLinesOpeningClosingBraces.visit, for: node) visitIfEnabled(NoLeadingUnderscores.visit, for: node) return .visitChildren } override func visitPost(_ node: PrecedenceGroupDeclSyntax) { + onVisitPost(rule: NoEmptyLinesOpeningClosingBraces.self, for: node) onVisitPost(rule: NoLeadingUnderscores.self, for: node) } @@ -511,10 +527,12 @@ class LintPipeline: SyntaxVisitor { } override func visit(_ node: SwitchExprSyntax) -> SyntaxVisitorContinueKind { + visitIfEnabled(NoEmptyLinesOpeningClosingBraces.visit, for: node) visitIfEnabled(NoParensAroundConditions.visit, for: node) return .visitChildren } override func visitPost(_ node: SwitchExprSyntax) { + onVisitPost(rule: NoEmptyLinesOpeningClosingBraces.self, for: node) onVisitPost(rule: NoParensAroundConditions.self, for: node) } @@ -597,6 +615,7 @@ extension FormatPipeline { node = NoAccessLevelOnExtensionDeclaration(context: context).rewrite(node) node = NoAssignmentInExpressions(context: context).rewrite(node) node = NoCasesWithOnlyFallthrough(context: context).rewrite(node) + node = NoEmptyLinesOpeningClosingBraces(context: context).rewrite(node) node = NoEmptyTrailingClosureParentheses(context: context).rewrite(node) node = NoLabelsInCasePatterns(context: context).rewrite(node) node = NoParensAroundConditions(context: context).rewrite(node) diff --git a/Sources/SwiftFormat/Core/RuleNameCache+Generated.swift b/Sources/SwiftFormat/Core/RuleNameCache+Generated.swift index ac542c1d4..ed06b5577 100644 --- a/Sources/SwiftFormat/Core/RuleNameCache+Generated.swift +++ b/Sources/SwiftFormat/Core/RuleNameCache+Generated.swift @@ -34,6 +34,7 @@ public let ruleNameCache: [ObjectIdentifier: String] = [ ObjectIdentifier(NoAssignmentInExpressions.self): "NoAssignmentInExpressions", ObjectIdentifier(NoBlockComments.self): "NoBlockComments", ObjectIdentifier(NoCasesWithOnlyFallthrough.self): "NoCasesWithOnlyFallthrough", + ObjectIdentifier(NoEmptyLinesOpeningClosingBraces.self): "NoEmptyLinesOpeningClosingBraces", ObjectIdentifier(NoEmptyTrailingClosureParentheses.self): "NoEmptyTrailingClosureParentheses", ObjectIdentifier(NoLabelsInCasePatterns.self): "NoLabelsInCasePatterns", ObjectIdentifier(NoLeadingUnderscores.self): "NoLeadingUnderscores", diff --git a/Sources/SwiftFormat/Core/RuleRegistry+Generated.swift b/Sources/SwiftFormat/Core/RuleRegistry+Generated.swift index a5aeeab1f..d5c9c9ba1 100644 --- a/Sources/SwiftFormat/Core/RuleRegistry+Generated.swift +++ b/Sources/SwiftFormat/Core/RuleRegistry+Generated.swift @@ -33,6 +33,7 @@ "NoAssignmentInExpressions": true, "NoBlockComments": true, "NoCasesWithOnlyFallthrough": true, + "NoEmptyLinesOpeningClosingBraces": false, "NoEmptyTrailingClosureParentheses": true, "NoLabelsInCasePatterns": true, "NoLeadingUnderscores": false, diff --git a/Sources/SwiftFormat/Rules/NoEmptyLineOpeningClosingBraces.swift b/Sources/SwiftFormat/Rules/NoEmptyLineOpeningClosingBraces.swift new file mode 100644 index 000000000..5fc32a5f0 --- /dev/null +++ b/Sources/SwiftFormat/Rules/NoEmptyLineOpeningClosingBraces.swift @@ -0,0 +1,131 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 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 + +/// Empty lines are forbidden after opening braces and before closing braces. +/// +/// Lint: Empty lines after opening braces and before closing braces yield a lint error. +/// +/// Format: Empty lines after opening braces and before closing braces will be removed. +@_spi(Rules) +public final class NoEmptyLinesOpeningClosingBraces: SyntaxFormatRule { + public override class var isOptIn: Bool { return true } + + public override func visit(_ node: AccessorBlockSyntax) -> AccessorBlockSyntax { + var result = node + switch node.accessors { + case .accessors(let accessors): + result.accessors = .init(rewritten(accessors)) + case .getter(let getter): + result.accessors = .init(rewritten(getter)) + } + result.rightBrace = rewritten(node.rightBrace) + return result + } + + public override func visit(_ node: CodeBlockSyntax) -> CodeBlockSyntax { + var result = node + result.statements = rewritten(node.statements) + result.rightBrace = rewritten(node.rightBrace) + return result + } + + public override func visit(_ node: MemberBlockSyntax) -> MemberBlockSyntax { + var result = node + result.members = rewritten(node.members) + result.rightBrace = rewritten(node.rightBrace) + return result + } + + public override func visit(_ node: ClosureExprSyntax) -> ExprSyntax { + var result = node + result.statements = rewritten(node.statements) + result.rightBrace = rewritten(node.rightBrace) + return ExprSyntax(result) + } + + public override func visit(_ node: SwitchExprSyntax) -> ExprSyntax { + var result = node + result.cases = rewritten(node.cases) + result.rightBrace = rewritten(node.rightBrace) + return ExprSyntax(result) + } + + public override func visit(_ node: PrecedenceGroupDeclSyntax) -> DeclSyntax { + var result = node + result.attributes = rewritten(node.attributes) + result.rightBrace = rewritten(node.rightBrace) + return DeclSyntax(result) + } + + func rewritten(_ token: TokenSyntax) -> TokenSyntax { + let (trimmedLeadingTrivia, count) = token.leadingTrivia.trimmingSuperfluousNewlines() + if trimmedLeadingTrivia.sourceLength != token.leadingTriviaLength { + diagnose(.removeEmptyLinesBefore(count), on: token, anchor: .start) + return token.with(\.leadingTrivia, trimmedLeadingTrivia) + } else { + return token + } + } + + func rewritten(_ collection: C) -> C { + var result = collection + if let first = collection.first, first.leadingTrivia.containsNewlines, + let index = collection.index(of: first) + { + let (trimmedLeadingTrivia, count) = first.leadingTrivia.trimmingSuperfluousNewlines() + if trimmedLeadingTrivia.sourceLength != first.leadingTriviaLength { + diagnose(.removeEmptyLinesAfter(count), on: first, anchor: .leadingTrivia(0)) + result[index] = first.with(\.leadingTrivia, trimmedLeadingTrivia) + } + } + return rewrite(result).as(C.self)! + } +} + +extension Trivia { + func trimmingSuperfluousNewlines() -> (Trivia, Int) { + var trimmmed = 0 + let pieces = self.indices.reduce([TriviaPiece]()) { (partialResult, index) in + let piece = self[index] + // Collapse consecutive newlines into a single one + if case .newlines(let count) = piece { + if let last = partialResult.last, last.isNewline { + trimmmed += count + return partialResult + } else { + trimmmed += count - 1 + return partialResult + [.newlines(1)] + } + } + // Remove spaces/tabs surrounded by newlines + if piece.isSpaceOrTab, index > 0, index < self.count - 1, self[index - 1].isNewline, self[index + 1].isNewline { + return partialResult + } + // Retain other trivia pieces + return partialResult + [piece] + } + + return (Trivia(pieces: pieces), trimmmed) + } +} + +extension Finding.Message { + fileprivate static func removeEmptyLinesAfter(_ count: Int) -> Finding.Message { + "remove empty \(count > 1 ? "lines" : "line") after '{'" + } + + fileprivate static func removeEmptyLinesBefore(_ count: Int) -> Finding.Message { + "remove empty \(count > 1 ? "lines" : "line") before '}'" + } +} diff --git a/Tests/SwiftFormatTests/Rules/NoEmptyLinesOpeningClosingBracesTests.swift b/Tests/SwiftFormatTests/Rules/NoEmptyLinesOpeningClosingBracesTests.swift new file mode 100644 index 000000000..294a38210 --- /dev/null +++ b/Tests/SwiftFormatTests/Rules/NoEmptyLinesOpeningClosingBracesTests.swift @@ -0,0 +1,140 @@ +import _SwiftFormatTestSupport + +@_spi(Rules) import SwiftFormat + +final class NoEmptyLinesOpeningClosingBracesTests: LintOrFormatRuleTestCase { + func testNoEmptyLinesOpeningClosingBracesInCodeBlock() { + assertFormatting( + NoEmptyLinesOpeningClosingBraces.self, + input: """ + func f() {1️⃣ + + // + return + + + 2️⃣} + """, + expected: """ + func f() { + // + return + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove empty line after '{'"), + FindingSpec("2️⃣", message: "remove empty lines before '}'"), + ] + ) + } + + func testNoEmptyLinesOpeningClosingBracesInMemberBlock() { + assertFormatting( + NoEmptyLinesOpeningClosingBraces.self, + input: """ + struct {1️⃣ + + let x: Int + + let y: Int + + 2️⃣} + """, + expected: """ + struct { + let x: Int + + let y: Int + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove empty line after '{'"), + FindingSpec("2️⃣", message: "remove empty line before '}'"), + ] + ) + } + + func testNoEmptyLinesOpeningClosingBracesInAccessorBlock() { + assertFormatting( + NoEmptyLinesOpeningClosingBraces.self, + input: """ + var x: Int {1️⃣ + + // + return _x + + 2️⃣} + + var y: Int {3️⃣ + + get 5️⃣{ + + // + return _y + + 6️⃣ } + + set 7️⃣{ + + // + _x = newValue + + 8️⃣ } + + 4️⃣} + """, + expected: """ + var x: Int { + // + return _x + } + + var y: Int { + get { + // + return _y + } + + set { + // + _x = newValue + } + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove empty line after '{'"), + FindingSpec("2️⃣", message: "remove empty line before '}'"), + FindingSpec("3️⃣", message: "remove empty line after '{'"), + FindingSpec("4️⃣", message: "remove empty line before '}'"), + FindingSpec("5️⃣", message: "remove empty line after '{'"), + FindingSpec("6️⃣", message: "remove empty line before '}'"), + FindingSpec("7️⃣", message: "remove empty line after '{'"), + FindingSpec("8️⃣", message: "remove empty line before '}'"), + ] + ) + } + + func testNoEmptyLinesOpeningClosingBracesInClosureExpr() { + assertFormatting( + NoEmptyLinesOpeningClosingBraces.self, + input: """ + let closure = {1️⃣ + + // + return + + 2️⃣} + """, + expected: """ + let closure = { + // + return + } + """, + findings: [ + FindingSpec("1️⃣", message: "remove empty line after '{'"), + FindingSpec("2️⃣", message: "remove empty line before '}'"), + ] + ) + } +}