Skip to content

Commit 71eb322

Browse files
committed
Update BlankLineBetweenMembers rule to remove false positives.
The rule was overly eager in adding blank lines around members that have comments in their leading trivia, because it didn't always correctly determine whether a given comment should trigger blank lines around the member. I have refactored the rule using the following criteria for adding blank lines: 1. Any member whose leading trivia contains a non-blank-line-terminated comment must have a leading blank line (before the comment) and a trailing blank line (after the member). 2. Any comment that contains at least 1 blank line between the comment and the relevant syntax token is ignored entirely by the rule.
1 parent 1c01dee commit 71eb322

File tree

5 files changed

+156
-26
lines changed

5 files changed

+156
-26
lines changed

Sources/SwiftFormatConfiguration/Configuration.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,11 @@ public class Configuration: Codable {
159159
/// Configuration for the BlankLineBetweenMembers rule.
160160
public struct BlankLineBetweenMembersConfiguration: Codable {
161161
/// If true, blank lines are not required between single-line properties.
162-
public let ignoreSingleLineProperties = true
162+
public let ignoreSingleLineProperties: Bool
163+
164+
public init(ignoreSingleLineProperties: Bool = true) {
165+
self.ignoreSingleLineProperties = ignoreSingleLineProperties
166+
}
163167
}
164168

165169
/// Configuration for the NoPlaygroundLiterals rule.

Sources/SwiftFormatRules/BlankLineBetweenMembers.swift

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,12 @@ import SwiftSyntax
3030
/// - SeeAlso: https://google.github.io/swift#vertical-whitespace
3131
public final class BlankLineBetweenMembers: SyntaxFormatRule {
3232
public override func visit(_ node: MemberDeclBlockSyntax) -> Syntax {
33-
guard let firstMember = node.members.first else { return super.visit(node) }
33+
// This rule is implemented by ensuring that all multiline member decls include a blank line
34+
// before the member's leading trivia and a blank line after any members that have a preceding
35+
// comment. Any comments that are separated from a member with blank lines are ignored, because
36+
// those comments aren't considered to be related to a specific member.
3437

38+
guard let firstMember = node.members.first else { return super.visit(node) }
3539
let ignoreSingleLine = context.configuration.blankLineBetweenMembers.ignoreSingleLineProperties
3640

3741
// The first member can just be added as-is; we don't force any newline before it.
@@ -40,45 +44,45 @@ public final class BlankLineBetweenMembers: SyntaxFormatRule {
4044
// The first comment may not be "attached" to the first member. Ignore any comments that are
4145
// separated from the member by a newline.
4246
let shouldIncludeLeadingComment = !isLeadingTriviaSeparate(from: firstMember)
43-
44-
// Iterates through all the declaration of the member, to ensure that the declarations have
45-
// at least one blank line between them when necessary.
4647
var previousMemberWasSingleLine = firstMember.isSingleLine(
4748
includingLeadingComment: shouldIncludeLeadingComment,
4849
sourceLocationConverter: context.sourceLocationConverter
4950
)
5051

52+
// Iterates through all the declaration of the member, to ensure that the declarations have
53+
// at least one blank line between them when necessary.
5154
for member in node.members.dropFirst() {
5255
var memberToAdd = visitNestedDecls(of: member)
5356

54-
let memberIsSingleLine = memberToAdd.isSingleLine(
57+
// Include the comment here to ensure all comments have a preceding blank line.
58+
let isMemberSingleLine = memberToAdd.isSingleLine(
5559
includingLeadingComment: true,
56-
sourceLocationConverter: context.sourceLocationConverter
57-
)
60+
sourceLocationConverter: context.sourceLocationConverter)
5861

59-
if let memberTrivia = memberToAdd.leadingTrivia,
60-
!(ignoreSingleLine && previousMemberWasSingleLine && memberIsSingleLine)
61-
&& memberTrivia.numberOfLeadingNewlines == 1 // Only one newline => no blank line
62-
{
63-
let correctTrivia = Trivia.newlines(1) + memberTrivia
62+
let ignoreMember = ignoreSingleLine && isMemberSingleLine
63+
if (!previousMemberWasSingleLine || !ignoreMember) && !isLeadingBlankLinePresent(on: member) {
64+
let blankLinePrefacedTrivia = Trivia.newlines(1) + (member.leadingTrivia ?? [])
6465
memberToAdd = replaceTrivia(
65-
on: memberToAdd, token: memberToAdd.firstToken!,
66-
leadingTrivia: correctTrivia
66+
on: memberToAdd,
67+
token: memberToAdd.firstToken,
68+
leadingTrivia: blankLinePrefacedTrivia
6769
) as! MemberDeclListItemSyntax
68-
6970
diagnose(.addBlankLine, on: member)
7071
}
7172

7273
membersList.append(memberToAdd)
73-
previousMemberWasSingleLine = memberIsSingleLine
74+
75+
// Consider the member single line if the trivia was separate so that non-member-specific
76+
// comments won't cause blank lines between members that are otherwise single line.
77+
previousMemberWasSingleLine = isMemberSingleLine || isLeadingTriviaSeparate(from: memberToAdd)
7478
}
7579

7680
return node.withMembers(SyntaxFactory.makeMemberDeclList(membersList))
7781
}
7882

7983
/// Returns whether any comments in the leading trivia of the given node are separated from the
8084
/// non-trivia tokens by at least 1 blank line.
81-
func isLeadingTriviaSeparate(from node: Syntax) -> Bool {
85+
private func isLeadingTriviaSeparate(from node: Syntax) -> Bool {
8286
guard let leadingTrivia = node.leadingTrivia else {
8387
return false
8488
}
@@ -88,6 +92,14 @@ public final class BlankLineBetweenMembers: SyntaxFormatRule {
8892
return false
8993
}
9094

95+
/// Returns whether there is at least 1 blank line in the leading trivia of the given node.
96+
private func isLeadingBlankLinePresent(on node: Syntax) -> Bool {
97+
guard let leadingTrivia = node.leadingTrivia else {
98+
return false
99+
}
100+
return leadingTrivia.numberOfLeadingNewlines > 1
101+
}
102+
91103
/// Recursively ensures all nested member types follows the BlankLineBetweenMembers rule.
92104
func visitNestedDecls(of member: MemberDeclListItemSyntax) -> MemberDeclListItemSyntax {
93105
switch member.decl {

Tests/SwiftFormatRulesTests/BlankLineBetweenMembersTests.swift

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Foundation
2-
import XCTest
2+
import SwiftFormatConfiguration
33
import SwiftSyntax
4+
import XCTest
45

56
@testable import SwiftFormatRules
67

@@ -192,38 +193,146 @@ public class BlankLineBetweenMembersTests: DiagnosingTestCase {
192193
let baz = 2
193194
}
194195
enum Foo {
195-
196196
// MARK: - This is an important region of the code.
197197
198198
let bar = 1
199199
let baz = 2
200200
}
201201
enum Foo {
202+
var quxxe = 0
203+
// MARK: - This is an important region of the code.
202204
203-
// This comment is describing bar.
204205
let bar = 1
205206
let baz = 2
206207
}
208+
enum Foo {
209+
let bar = 1
210+
let baz = 2
211+
212+
// MARK: - This is an important region of the code.
213+
}
207214
""",
208215
expected: """
209216
enum Foo {
210217
let bar = 1
211218
let baz = 2
212219
}
213220
enum Foo {
221+
// MARK: - This is an important region of the code.
222+
223+
let bar = 1
224+
let baz = 2
225+
}
226+
enum Foo {
227+
var quxxe = 0
214228
215229
// MARK: - This is an important region of the code.
216230
217231
let bar = 1
218232
let baz = 2
219233
}
234+
enum Foo {
235+
let bar = 1
236+
let baz = 2
237+
238+
// MARK: - This is an important region of the code.
239+
}
240+
""")
241+
}
242+
243+
public func testBlankLinesAroundDocumentedMembers() {
244+
XCTAssertFormatting(
245+
BlankLineBetweenMembers.self,
246+
input: """
220247
enum Foo {
221248
222249
// This comment is describing bar.
223250
let bar = 1
251+
let baz = 2
252+
let quxxe = 3
253+
}
254+
enum Foo {
255+
var quxxe = 0
224256
257+
/// bar: A property that has a Bar.
258+
let bar = 1
225259
let baz = 2
260+
var car = 3
226261
}
227-
""")
262+
""",
263+
expected: """
264+
enum Foo {
265+
266+
// This comment is describing bar.
267+
let bar = 1
268+
269+
let baz = 2
270+
let quxxe = 3
271+
}
272+
enum Foo {
273+
var quxxe = 0
274+
275+
/// bar: A property that has a Bar.
276+
let bar = 1
277+
278+
let baz = 2
279+
var car = 3
280+
}
281+
""")
282+
}
283+
284+
public func testBlankLineBetweenMembersIgnoreSingleLineDisabled() {
285+
let config = Configuration()
286+
config.blankLineBetweenMembers =
287+
BlankLineBetweenMembersConfiguration(ignoreSingleLineProperties: false)
288+
289+
let input = """
290+
enum Foo {
291+
let bar = 1
292+
let baz = 2
293+
}
294+
enum Foo {
295+
// MARK: - This is an important region of the code.
296+
297+
let bar = 1
298+
let baz = 2
299+
}
300+
enum Foo {
301+
var quxxe = 0
302+
// MARK: - This is an important region of the code.
303+
304+
let bar = 1
305+
let baz = 2
306+
}
307+
"""
308+
let expected = """
309+
enum Foo {
310+
let bar = 1
311+
312+
let baz = 2
313+
}
314+
enum Foo {
315+
// MARK: - This is an important region of the code.
316+
317+
let bar = 1
318+
319+
let baz = 2
320+
}
321+
enum Foo {
322+
var quxxe = 0
323+
324+
// MARK: - This is an important region of the code.
325+
326+
let bar = 1
327+
328+
let baz = 2
329+
}
330+
"""
331+
332+
XCTAssertFormatting(
333+
BlankLineBetweenMembers.self,
334+
input: input,
335+
expected: expected,
336+
configuration: config)
228337
}
229338
}

Tests/SwiftFormatRulesTests/DiagnosingTestCase.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,11 @@ public class DiagnosingTestCase: XCTestCase {
3838
}
3939

4040
/// Creates and returns a new `Context` from the given syntax tree.
41-
private func makeContext(sourceFileSyntax: SourceFileSyntax) -> Context {
41+
private func makeContext(sourceFileSyntax: SourceFileSyntax, configuration: Configuration? = nil)
42+
-> Context
43+
{
4244
let context = Context(
43-
configuration: Configuration(),
45+
configuration: configuration ?? Configuration(),
4446
diagnosticEngine: DiagnosticEngine(),
4547
fileURL: URL(fileURLWithPath: "/tmp/test.swift"),
4648
sourceFileSyntax: sourceFileSyntax)
@@ -101,7 +103,8 @@ public class DiagnosingTestCase: XCTestCase {
101103
expected: String,
102104
file: StaticString = #file,
103105
line: UInt = #line,
104-
checkForUnassertedDiagnostics: Bool = false
106+
checkForUnassertedDiagnostics: Bool = false,
107+
configuration: Configuration? = nil
105108
) {
106109
let sourceFileSyntax: SourceFileSyntax
107110
do {
@@ -111,7 +114,7 @@ public class DiagnosingTestCase: XCTestCase {
111114
return
112115
}
113116

114-
context = makeContext(sourceFileSyntax: sourceFileSyntax)
117+
context = makeContext(sourceFileSyntax: sourceFileSyntax, configuration: configuration)
115118

116119
// Force the rule to be enabled while we test it.
117120
context!.configuration.rules[formatType.ruleName] = true

Tests/SwiftFormatRulesTests/XCTestManifests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ extension BlankLineBetweenMembersTests {
4545
// to regenerate.
4646
static let __allTests__BlankLineBetweenMembersTests = [
4747
("testBlankLineBeforeFirstChildOrNot", testBlankLineBeforeFirstChildOrNot),
48+
("testBlankLineBetweenMembersIgnoreSingleLineDisabled", testBlankLineBetweenMembersIgnoreSingleLineDisabled),
49+
("testBlankLinesAroundDocumentedMembers", testBlankLinesAroundDocumentedMembers),
4850
("testInvalidBlankLineBetweenMembers", testInvalidBlankLineBetweenMembers),
4951
("testNestedMembers", testNestedMembers),
5052
("testNoBlankLineBetweenSingleLineMembers", testNoBlankLineBetweenSingleLineMembers),

0 commit comments

Comments
 (0)