Skip to content

Commit 3a7779f

Browse files
committed
Diagnostics for trailing comma changes.
As part of the migration from a phase 1 rule for trailing commas to using the PrettyPrinter, we still need to emit diagnostics when the source's use of trailing commas is incorrect. These diagnostics are similar to those from the original phase 1 rule, except they don't specify the type of collection (instead saying "collection literal"). I think context of the line and column number + the general term "collection" are sufficient for the user to understand the intent. I added support to PrettyPrintTestCase for collecting diagnostics so that they can be tested. Finally, the WhitespaceLinter is only able to handle changes in whitespace. By adding/removing trailing commas, the whitespace linter would fail. Instead of checking the whitespace linter to support skipping comma changes, I've created a "mode" in the PrettyPrinter to print the text of the source exactly as it was while only changing whitespace. If we ever introduce other types of text edits in the PrettyPrinter, this should work for that too.
1 parent cbc37a0 commit 3a7779f

File tree

8 files changed

+169
-14
lines changed

8 files changed

+169
-14
lines changed

Sources/SwiftFormat/SwiftFormatter.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ public final class SwiftFormatter {
109109
context: context,
110110
operatorContext: operatorContext,
111111
node: transformedSyntax,
112-
printTokenStream: debugOptions.contains(.dumpTokenStream))
112+
printTokenStream: debugOptions.contains(.dumpTokenStream),
113+
whitespaceOnly: false)
113114
outputStream.write(printer.prettyPrint())
114115
}
115116
}

Sources/SwiftFormat/SwiftLinter.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ public final class SwiftLinter {
9292
context: context,
9393
operatorContext: operatorContext,
9494
node: syntax,
95-
printTokenStream: debugOptions.contains(.dumpTokenStream))
95+
printTokenStream: debugOptions.contains(.dumpTokenStream),
96+
whitespaceOnly: true)
9697
let formatted = printer.prettyPrint()
9798
let ws = WhitespaceLinter(user: syntax.description, formatted: formatted, context: context)
9899
ws.lint()

Sources/SwiftFormatPrettyPrint/PrettyPrint.swift

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ public class PrettyPrinter {
6666
/// If true, the token stream is printed to the console for debugging purposes.
6767
private var printTokenStream: Bool
6868

69+
/// Whether the pretty printer should restrict its changes to whitespace. When true, only
70+
/// whitespace (e.g. spaces, newlines) are modified. Otherwise, text changes (e.g. add/remove
71+
/// trailing commas) are performed in addition to whitespace.
72+
private let whitespaceOnly: Bool
73+
6974
/// Keeps track of the line numbers and indentation states of the open (and unclosed) breaks seen
7075
/// so far.
7176
private var activeOpenBreaks: [ActiveOpenBreak] = []
@@ -139,8 +144,10 @@ public class PrettyPrinter {
139144
/// - node: The node to be pretty printed.
140145
/// - printTokenStream: Indicates whether debug information about the token stream should be
141146
/// printed to standard output.
147+
/// - whitespaceOnly: Whether only whitespace changes should be made.
142148
public init(
143-
context: Context, operatorContext: OperatorContext, node: Syntax, printTokenStream: Bool
149+
context: Context, operatorContext: OperatorContext, node: Syntax, printTokenStream: Bool,
150+
whitespaceOnly: Bool
144151
) {
145152
self.context = context
146153
let configuration = context.configuration
@@ -149,6 +156,7 @@ public class PrettyPrinter {
149156
self.maxLineLength = configuration.lineLength
150157
self.spaceRemaining = self.maxLineLength
151158
self.printTokenStream = printTokenStream
159+
self.whitespaceOnly = whitespaceOnly
152160
}
153161

154162
/// Append the given string to the output buffer.
@@ -451,12 +459,20 @@ public class PrettyPrinter {
451459
case .commaDelimitedRegionStart:
452460
commaDelimitedRegionStack.append(openCloseBreakCompensatingLineNumber)
453461

454-
case .commaDelimitedRegionEnd:
462+
case .commaDelimitedRegionEnd(let hasTrailingComma, let position):
455463
guard let startLineNumber = commaDelimitedRegionStack.popLast() else {
456464
fatalError("Found trailing comma end with no corresponding start.")
457465
}
458466

459-
if startLineNumber != openCloseBreakCompensatingLineNumber {
467+
let shouldHaveTrailingComma = startLineNumber != openCloseBreakCompensatingLineNumber
468+
if shouldHaveTrailingComma && !hasTrailingComma {
469+
diagnose(.addTrailingComma, at: position)
470+
} else if !shouldHaveTrailingComma && hasTrailingComma {
471+
diagnose(.removeTrailingComma, at: position)
472+
}
473+
474+
let shouldWriteComma = whitespaceOnly ? hasTrailingComma : shouldHaveTrailingComma
475+
if shouldWriteComma {
460476
write(",")
461477
}
462478
}
@@ -679,4 +695,10 @@ extension Diagnostic.Message {
679695

680696
static let moveEndOfLineComment = Diagnostic.Message(
681697
.warning, "move end-of-line comment that exceeds the line length")
698+
699+
static let addTrailingComma = Diagnostic.Message(
700+
.warning, "add trailing comma to the last element in multiline collection literal")
701+
702+
static let removeTrailingComma = Diagnostic.Message(
703+
.warning, "remove trailing comma from the last element in single line collection literal")
682704
}

Sources/SwiftFormatPrettyPrint/Token.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ enum Token {
177177

178178
/// Marks the end of a comma delimited collection, where a trailing comma should be inserted
179179
/// if and only if the collection spans multiple lines.
180-
case commaDelimitedRegionEnd
180+
case commaDelimitedRegionEnd(hasTrailingComma: Bool, position: AbsolutePosition)
181181

182182
// Convenience overloads for the enum types
183183
static let open = Token.open(.inconsistent, 0)

Sources/SwiftFormatPrettyPrint/TokenStreamCreator.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -709,7 +709,9 @@ private final class TokenStreamCreator: SyntaxVisitor {
709709
insertTokens(.break(.same), betweenElementsOf: node)
710710
if let firstElement = node.first, let lastElement = node.last {
711711
before(firstElement.firstToken, tokens: .commaDelimitedRegionStart)
712-
after(lastElement.lastToken, tokens: .commaDelimitedRegionEnd)
712+
let endToken = Token.commaDelimitedRegionEnd(
713+
hasTrailingComma: lastElement.trailingComma != nil, position: lastElement.endPosition)
714+
after(lastElement.lastToken, tokens: endToken)
713715
if let existingTrailingComma = lastElement.trailingComma {
714716
ignoredTokens.insert(existingTrailingComma)
715717
}
@@ -733,7 +735,9 @@ private final class TokenStreamCreator: SyntaxVisitor {
733735
insertTokens(.break(.same), betweenElementsOf: node)
734736
if let firstElement = node.first, let lastElement = node.last {
735737
before(firstElement.firstToken, tokens: .commaDelimitedRegionStart)
736-
after(lastElement.lastToken, tokens: .commaDelimitedRegionEnd)
738+
let endToken = Token.commaDelimitedRegionEnd(
739+
hasTrailingComma: lastElement.trailingComma != nil, position: lastElement.endPosition)
740+
after(lastElement.lastToken, tokens: endToken)
737741
if let existingTrailingComma = lastElement.trailingComma {
738742
ignoredTokens.insert(existingTrailingComma)
739743
}

Tests/SwiftFormatPrettyPrintTests/ArrayDeclTests.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import SwiftSyntax
2+
3+
@testable import SwiftFormatPrettyPrint
4+
15
public class ArrayDeclTests: PrettyPrintTestCase {
26
public func testBasicArrays() {
37
let input =
@@ -60,4 +64,35 @@ public class ArrayDeclTests: PrettyPrintTestCase {
6064

6165
assertPrettyPrintEqual(input: input, expected: expected, linelength: 45)
6266
}
67+
68+
public func testWhitespaceOnlyDoesNotChangeTrailingComma() {
69+
let input =
70+
"""
71+
let a = [1, 2, 3,]
72+
let a: [String] = [
73+
"One", "Two", "Three", "Four", "Five",
74+
"Six", "Seven", "Eight"
75+
]
76+
"""
77+
78+
assertPrettyPrintEqual(
79+
input: input, expected: input + "\n", linelength: 45, whitespaceOnly: true)
80+
}
81+
82+
public func testTrailingCommaDiagnostics() {
83+
let input =
84+
"""
85+
let a = [1, 2, 3,]
86+
let a: [String] = [
87+
"One", "Two", "Three", "Four", "Five",
88+
"Six", "Seven", "Eight"
89+
]
90+
"""
91+
92+
assertPrettyPrintEqual(
93+
input: input, expected: input + "\n", linelength: 45, whitespaceOnly: true)
94+
95+
XCTAssertDiagnosed(Diagnostic.Message.removeTrailingComma, line: 1, column: 18)
96+
XCTAssertDiagnosed(Diagnostic.Message.addTrailingComma, line: 4, column: 26)
97+
}
6398
}

Tests/SwiftFormatPrettyPrintTests/DictionaryDeclTests.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import SwiftSyntax
2+
3+
@testable import SwiftFormatPrettyPrint
4+
15
public class DictionaryDeclTests: PrettyPrintTestCase {
26
public func testBasicDictionaries() {
37
let input =
@@ -43,4 +47,35 @@ public class DictionaryDeclTests: PrettyPrintTestCase {
4347

4448
assertPrettyPrintEqual(input: input, expected: expected, linelength: 50)
4549
}
50+
51+
public func testWhitespaceOnlyDoesNotChangeTrailingComma() {
52+
let input =
53+
"""
54+
let a = [1: "a", 2: "b", 3: "c",]
55+
let a: [Int: String] = [
56+
1: "a", 2: "b", 3: "c", 4: "d", 5: "e", 6: "f",
57+
7: "g", 8: "i"
58+
]
59+
"""
60+
61+
assertPrettyPrintEqual(
62+
input: input, expected: input + "\n", linelength: 50, whitespaceOnly: true)
63+
}
64+
65+
public func testTrailingCommaDiagnostics() {
66+
let input =
67+
"""
68+
let a = [1: "a", 2: "b", 3: "c",]
69+
let a: [Int: String] = [
70+
1: "a", 2: "b", 3: "c", 4: "d", 5: "e", 6: "f",
71+
7: "g", 8: "i"
72+
]
73+
"""
74+
75+
assertPrettyPrintEqual(
76+
input: input, expected: input + "\n", linelength: 50, whitespaceOnly: true)
77+
78+
XCTAssertDiagnosed(Diagnostic.Message.removeTrailingComma, line: 1, column: 33)
79+
XCTAssertDiagnosed(Diagnostic.Message.addTrailingComma, line: 4, column: 17)
80+
}
4681
}

Tests/SwiftFormatPrettyPrintTests/PrettyPrintTestCase.swift

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,37 +6,90 @@ import XCTest
66
@testable import SwiftFormatPrettyPrint
77

88
public class PrettyPrintTestCase: XCTestCase {
9+
/// A helper that will keep track of which diagnostics have been emitted, and their locations.
10+
private var consumer: DiagnosticTrackingConsumer? = nil
11+
12+
/// Implements `DiagnosticConsumer` to keep track which diagnostics have been raised and their
13+
/// locations.
14+
private class DiagnosticTrackingConsumer: DiagnosticConsumer {
15+
var registeredDiagnostics = [(String, line: Int?, column: Int?)]()
16+
17+
func handle(_ diagnostic: Diagnostic) {
18+
let loc = diagnostic.location
19+
registeredDiagnostics.append((diagnostic.message.text, line: loc?.line, column: loc?.column))
20+
}
21+
22+
func finalize() {}
23+
}
24+
25+
/// Asserts that a specific diagnostic message was emitted. This should be called to check for
26+
/// diagnostics after `assertPrettyPrintEqual`.
27+
///
28+
/// - Parameters:
29+
/// - message: The diagnostic message to check for.
30+
/// - line: The line number of the diagnotic message within the ueser's input text.
31+
/// - column: The column number of the diagnostic message within the user's input text.
32+
/// - file: The file the test resides in (defaults to the current caller's file).
33+
/// - sourceLine: The line the test resides in (defaults to the current caller's file).
34+
func XCTAssertDiagnosed(
35+
_ message: Diagnostic.Message,
36+
line: Int? = nil,
37+
column: Int? = nil,
38+
file: StaticString = #file,
39+
sourceLine: UInt = #line
40+
) {
41+
let maybeIdx = consumer?.registeredDiagnostics.firstIndex {
42+
$0 == (message.text, line: line, column: column)
43+
}
44+
45+
guard let idx = maybeIdx else {
46+
XCTFail("diagnostic '\(message.text)' not raised", file: file, line: sourceLine)
47+
return
48+
}
49+
50+
consumer?.registeredDiagnostics.remove(at: idx)
51+
}
952

1053
public func assertPrettyPrintEqual(
1154
input: String,
1255
expected: String,
1356
linelength: Int,
1457
configuration: Configuration = Configuration(),
58+
whitespaceOnly: Bool = false,
1559
file: StaticString = #file,
1660
line: UInt = #line
1761
) {
1862
var configuration = configuration
1963
configuration.lineLength = linelength
64+
let firstPassConsumer = DiagnosticTrackingConsumer()
65+
consumer = firstPassConsumer
2066

2167
// Assert that the input, when formatted, is what we expected.
22-
if let formatted = prettyPrintedSource(input, configuration: configuration) {
68+
if let formatted = prettyPrintedSource(
69+
input, configuration: configuration, whitespaceOnly: whitespaceOnly,
70+
consumer: firstPassConsumer)
71+
{
2372
XCTAssertEqual(
2473
expected, formatted,
2574
"Pretty-printed result was not what was expected",
2675
file: file, line: line)
2776

2877
// Idempotency check: Running the formatter multiple times should not change the outcome.
2978
// Assert that running the formatter again on the previous result keeps it the same.
30-
if let reformatted = prettyPrintedSource(formatted, configuration: configuration) {
79+
if let reformatted = prettyPrintedSource(
80+
formatted, configuration: configuration, whitespaceOnly: whitespaceOnly)
81+
{
3182
XCTAssertEqual(
3283
formatted, reformatted, "Pretty printer is not idempotent", file: file, line: line)
3384
}
3485
}
3586
}
3687

3788
/// Returns the given source code reformatted with the pretty printer.
38-
private func prettyPrintedSource(_ source: String, configuration: Configuration) -> String?
39-
{
89+
private func prettyPrintedSource(
90+
_ source: String, configuration: Configuration, whitespaceOnly: Bool,
91+
consumer: DiagnosticConsumer? = nil
92+
) -> String? {
4093
let sourceFileSyntax: SourceFileSyntax
4194
do {
4295
sourceFileSyntax = try SyntaxParser.parse(source: source)
@@ -47,15 +100,19 @@ public class PrettyPrintTestCase: XCTestCase {
47100

48101
let context = Context(
49102
configuration: configuration,
50-
diagnosticEngine: nil,
103+
diagnosticEngine: DiagnosticEngine(),
51104
fileURL: URL(fileURLWithPath: "/tmp/file.swift"),
52105
sourceFileSyntax: sourceFileSyntax)
106+
if let consumer = consumer {
107+
context.diagnosticEngine?.addConsumer(consumer)
108+
}
53109

54110
let printer = PrettyPrinter(
55111
context: context,
56112
operatorContext: OperatorContext.makeBuiltinOperatorContext(),
57113
node: sourceFileSyntax,
58-
printTokenStream: false)
114+
printTokenStream: false,
115+
whitespaceOnly: whitespaceOnly)
59116
return printer.prettyPrint()
60117
}
61118
}

0 commit comments

Comments
 (0)