Skip to content

Commit 18b1b9b

Browse files
committed
Prevent trailing commas from overflowing max line length.
The implementation of trailing commas, using a workaround of the length calculation algorithm, resulted in edge cases where the trailing comma could overflow the max line length. This edge case happens for elements that should break internally to create additional space for the trailing comma (e.g. arrays, dictionaries, tuples). For example, an array that contains arrays could have its final element overflow the line length if the last element + correct indentation is exactly `maxLineLength`. After the trailing comma is added, the last array element *should* have broken up onto multiple lines to make space for the comma. The problem happens because the comma token was being added at the wrong syntax level, outside of the element's group, and its length wasn't propagating. Not propagating the comma's length was intentional to work around the fact that the trailing comma is only present when the comma delimited collection uses multiple lines. Unfortunately, moving the comma deeper into the element so that it can cause elements to break internally means its length has to be propagate as a normal token to also cause breaks at different syntax levels to fire too. - Array/dictionary elements won't be allowed to overflow the configured line length. - Arrays/dictionaries that could fit on 1 line without a trailing comma will be forced onto multiple lines with a trailing comma *if* they fit exactly at `maxLineLength` (without a trailing comma).
1 parent 634a112 commit 18b1b9b

File tree

5 files changed

+188
-46
lines changed

5 files changed

+188
-46
lines changed

Sources/SwiftFormatPrettyPrint/PrettyPrint.swift

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -651,15 +651,12 @@ public class PrettyPrinter {
651651
lengths.append(0)
652652

653653
case .commaDelimitedRegionEnd:
654-
// The trailing comma needs to be included in the length of the preceding break, but is not
655-
// included in the length of the enclosing group. A trailing comma cannot cause the group
656-
// to break onto multiple lines, because the comma isn't printed for a single line group.
657-
if let index = delimIndexStack.last, case .break = tokens[index] {
658-
lengths[index] += 1
659-
}
660-
// If the closest delimiter token is an open, instead of a break, then adding the comma's
661-
// length isn't necessary. In that case, the comma is printed if the preceding break fires.
662-
654+
// The token's length is only necessary when a comma will be printed, but it's impossible to
655+
// know at this point whether the region-start token will be on the same line as this token.
656+
// Without adding this length to the total, it would be possible for this comma to be
657+
// printed in column `maxLineLength`. Unfortunately, this can cause breaks to fire
658+
// unnecessarily when the enclosed tokens comma would fit within `maxLineLength`.
659+
total += 1
663660
lengths.append(1)
664661
}
665662
}

Sources/SwiftFormatPrettyPrint/TokenStreamCreator.swift

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -728,28 +728,33 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor {
728728
override func visit(_ node: ArrayElementListSyntax) -> SyntaxVisitorContinueKind {
729729
insertTokens(.break(.same), betweenElementsOf: node)
730730

731-
// The syntax library can't distinguish an array initializer (where the elements are types) from
732-
// an array literal (where the elements are the contents of the array). We never want to add a
733-
// trailing comma in the initializer case and there's always exactly 1 element, so we never add
734-
// a trailing comma to 1 element arrays.
735-
if let firstElement = node.first, let lastElement = node.last, firstElement != lastElement {
736-
before(firstElement.firstToken, tokens: .commaDelimitedRegionStart)
737-
let endToken =
738-
Token.commaDelimitedRegionEnd(hasTrailingComma: lastElement.trailingComma != nil)
739-
after(lastElement.lastToken, tokens: endToken)
740-
if let existingTrailingComma = lastElement.trailingComma {
741-
ignoredTokens.insert(existingTrailingComma)
731+
for element in node {
732+
before(element.firstToken, tokens: .open)
733+
after(element.lastToken, tokens: .close)
734+
if let trailingComma = element.trailingComma {
735+
closingDelimiterTokens.insert(trailingComma)
736+
}
737+
}
738+
739+
if let lastElement = node.last {
740+
if let trailingComma = lastElement.trailingComma {
741+
ignoredTokens.insert(trailingComma)
742+
}
743+
// The syntax library can't distinguish an array initializer (where the elements are types)
744+
// from an array literal (where the elements are the contents of the array). We never want to
745+
// add a trailing comma in the initializer case and there's always exactly 1 element, so we
746+
// never add a trailing comma to 1 element arrays.
747+
if let firstElement = node.first, firstElement != lastElement {
748+
before(firstElement.firstToken, tokens: .commaDelimitedRegionStart)
749+
let endToken =
750+
Token.commaDelimitedRegionEnd(hasTrailingComma: lastElement.trailingComma != nil)
751+
after(lastElement.expression.lastToken, tokens: [endToken])
742752
}
743753
}
744754
return .visitChildren
745755
}
746756

747757
override func visit(_ node: ArrayElementSyntax) -> SyntaxVisitorContinueKind {
748-
before(node.firstToken, tokens: .open)
749-
after(node.lastToken, tokens: .close)
750-
if let trailingComma = node.trailingComma {
751-
closingDelimiterTokens.insert(trailingComma)
752-
}
753758
return .visitChildren
754759
}
755760

@@ -762,17 +767,28 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor {
762767
override func visit(_ node: DictionaryElementListSyntax) -> SyntaxVisitorContinueKind {
763768
insertTokens(.break(.same), betweenElementsOf: node)
764769

765-
// The syntax library can't distinguish a dictionary initializer (where the elements are types)
766-
// from a dictionary literal (where the elements are the contents of the dictionary). We never
767-
// want to add a trailing comma in the initializer case and there's always exactly 1 element,
768-
// so we never add a trailing comma to 1 element dictionaries.
769-
if let firstElement = node.first, let lastElement = node.last, firstElement != lastElement {
770-
before(firstElement.firstToken, tokens: .commaDelimitedRegionStart)
771-
let endToken =
772-
Token.commaDelimitedRegionEnd(hasTrailingComma: lastElement.trailingComma != nil)
773-
after(lastElement.lastToken, tokens: endToken)
774-
if let existingTrailingComma = lastElement.trailingComma {
775-
ignoredTokens.insert(existingTrailingComma)
770+
for element in node {
771+
before(element.firstToken, tokens: .open)
772+
after(element.colon, tokens: .break)
773+
after(element.lastToken, tokens: .close)
774+
if let trailingComma = element.trailingComma {
775+
closingDelimiterTokens.insert(trailingComma)
776+
}
777+
}
778+
779+
if let lastElement = node.last {
780+
if let trailingComma = lastElement.trailingComma {
781+
ignoredTokens.insert(trailingComma)
782+
}
783+
// The syntax library can't distinguish a dictionary initializer (where the elements are
784+
// types) from a dictionary literal (where the elements are the contents of the dictionary).
785+
// We never want to add a trailing comma in the initializer case and there's always exactly 1
786+
// element, so we never add a trailing comma to 1 element dictionaries.
787+
if let firstElement = node.first, let lastElement = node.last, firstElement != lastElement {
788+
before(firstElement.firstToken, tokens: .commaDelimitedRegionStart)
789+
let endToken =
790+
Token.commaDelimitedRegionEnd(hasTrailingComma: lastElement.trailingComma != nil)
791+
after(lastElement.lastToken, tokens: endToken)
776792
}
777793
}
778794
return .visitChildren
@@ -784,12 +800,6 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor {
784800
}
785801

786802
override func visit(_ node: DictionaryElementSyntax) -> SyntaxVisitorContinueKind {
787-
before(node.firstToken, tokens: .open)
788-
after(node.colon, tokens: .break)
789-
after(node.lastToken, tokens: .close)
790-
if let trailingComma = node.trailingComma {
791-
closingDelimiterTokens.insert(trailingComma)
792-
}
793803
return .visitChildren
794804
}
795805

Tests/SwiftFormatPrettyPrintTests/ArrayDeclTests.swift

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ final class ArrayDeclTests: PrettyPrintTestCase {
77
"""
88
let a = [1, 2, 3,]
99
let a: [Bool] = [false, true, true, false]
10-
let a = [11111111, 2222222, 33333333, 444444]
1110
let a = [11111111, 2222222, 33333333, 4444444]
1211
let a: [String] = ["One", "Two", "Three", "Four"]
1312
let a: [String] = ["One", "Two", "Three", "Four", "Five", "Six", "Seven"]
@@ -16,13 +15,13 @@ final class ArrayDeclTests: PrettyPrintTestCase {
1615
"One", "Two", "Three", "Four", "Five",
1716
"Six", "Seven", "Eight",
1817
]
18+
let a = [11111111, 2222222, 33333333, 444444]
1919
"""
2020

2121
let expected =
2222
"""
2323
let a = [1, 2, 3]
2424
let a: [Bool] = [false, true, true, false]
25-
let a = [11111111, 2222222, 33333333, 444444]
2625
let a = [
2726
11111111, 2222222, 33333333, 4444444,
2827
]
@@ -43,6 +42,15 @@ final class ArrayDeclTests: PrettyPrintTestCase {
4342
]
4443
4544
"""
45+
// Ideally, this array would be left on 1 line without a trailing comma. We don't know if the
46+
// comma is required when calculating the length of array elements, so the comma's length is
47+
// always added to last element and that 1 character causes the newlines inside of the array.
48+
+ """
49+
let a = [
50+
11111111, 2222222, 33333333, 444444,
51+
]
52+
53+
"""
4654

4755
assertPrettyPrintEqual(input: input, expected: expected, linelength: 45)
4856
}
@@ -134,4 +142,59 @@ final class ArrayDeclTests: PrettyPrintTestCase {
134142

135143
assertPrettyPrintEqual(input: input, expected: expected, linelength: 32)
136144
}
145+
146+
func testInnerElementBreakingFromComma() {
147+
let input =
148+
"""
149+
let a = [("abc", "def", "xyz"),("this ", "string", "is long"),]
150+
let a = [("abc", "def", "xyz"),("this ", "string", "is long")]
151+
let a = [("this ", "string", "is long"),]
152+
let a = [("this ", "string", "is long")]
153+
let a = ["this ", "string", "is longer",]
154+
let a = [("this", "str"), ("is", "lng")]
155+
a = [("az", "by"), ("cf", "de")]
156+
"""
157+
158+
let expected =
159+
"""
160+
let a = [
161+
("abc", "def", "xyz"),
162+
(
163+
"this ", "string", "is long"
164+
),
165+
]
166+
let a = [
167+
("abc", "def", "xyz"),
168+
(
169+
"this ", "string", "is long"
170+
),
171+
]
172+
let a = [
173+
("this ", "string", "is long")
174+
]
175+
let a = [
176+
("this ", "string", "is long")
177+
]
178+
let a = [
179+
"this ", "string",
180+
"is longer",
181+
]
182+
let a = [
183+
("this", "str"),
184+
("is", "lng"),
185+
]
186+
187+
"""
188+
// Ideally, this array would be left on 1 line without a trailing comma. We don't know if the
189+
// comma is required when calculating the length of array elements, so the comma's length is
190+
// always added to last element and that 1 character causes the newlines inside of the array.
191+
+ """
192+
a = [
193+
("az", "by"), ("cf", "de"),
194+
]
195+
196+
"""
197+
198+
assertPrettyPrintEqual(input: input, expected: expected, linelength: 32)
199+
}
137200
}

Tests/SwiftFormatPrettyPrintTests/DictionaryDeclTests.swift

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ final class DictionaryDeclTests: PrettyPrintTestCase {
77
"""
88
let a = [1: "a", 2: "b", 3: "c",]
99
let a: [Int: String] = [1: "a", 2: "b", 3: "c"]
10-
let a = [10000: "abc", 20000: "def", 30000: "ghi"]
1110
let a = [10000: "abc", 20000: "def", 30000: "ghij"]
1211
let a: [Int: String] = [1: "a", 2: "b", 3: "c", 4: "d"]
1312
let a: [Int: String] = [1: "a", 2: "b", 3: "c", 4: "d", 5: "e", 6: "f", 7: "g"]
@@ -16,13 +15,13 @@ final class DictionaryDeclTests: PrettyPrintTestCase {
1615
1: "a", 2: "b", 3: "c", 4: "d", 5: "e", 6: "f",
1716
7: "g", 8: "i",
1817
]
18+
let a = [10000: "abc", 20000: "def", 30000: "ghi"]
1919
"""
2020

2121
let expected =
2222
"""
2323
let a = [1: "a", 2: "b", 3: "c"]
2424
let a: [Int: String] = [1: "a", 2: "b", 3: "c"]
25-
let a = [10000: "abc", 20000: "def", 30000: "ghi"]
2625
let a = [
2726
10000: "abc", 20000: "def", 30000: "ghij",
2827
]
@@ -43,6 +42,16 @@ final class DictionaryDeclTests: PrettyPrintTestCase {
4342
]
4443
4544
"""
45+
// Ideally, this dictionary would be left on 1 line without a trailing comma. We don't know if
46+
// the comma is required when calculating the length of elements, so the comma's length is
47+
// always added to last element and that 1 character causes the newlines inside of the
48+
// dictionary.
49+
+ """
50+
let a = [
51+
10000: "abc", 20000: "def", 30000: "ghi",
52+
]
53+
54+
"""
4655

4756
assertPrettyPrintEqual(input: input, expected: expected, linelength: 50)
4857
}
@@ -175,4 +184,65 @@ final class DictionaryDeclTests: PrettyPrintTestCase {
175184

176185
assertPrettyPrintEqual(input: input, expected: expected, linelength: 32)
177186
}
187+
188+
func testInnerElementBreakingFromComma() {
189+
let input =
190+
"""
191+
let a = [key1: ("abc", "def", "xyz"),key2: ("this ", "string", "is long"),]
192+
let a = [key1: ("abc", "def", "xyz"),key2: ("this ", "string", "is long")]
193+
let a = [key2: ("this ", "string", "is long")]
194+
let a = [key2: ("this ", "string", "is long"),]
195+
let a = [key2: ("this ", "string", "is long ")]
196+
let a = [key1: ("a", "z"), key2: ("b ", "y")]
197+
let a = [key1: ("ab", "z"), key2: ("b ", "y")]
198+
a = [k1: ("ab", "z"), k2: ("bc", "y")]
199+
"""
200+
201+
let expected =
202+
"""
203+
let a = [
204+
key1: ("abc", "def", "xyz"),
205+
key2: (
206+
"this ", "string", "is long"
207+
),
208+
]
209+
let a = [
210+
key1: ("abc", "def", "xyz"),
211+
key2: (
212+
"this ", "string", "is long"
213+
),
214+
]
215+
let a = [
216+
key2: ("this ", "string", "is long")
217+
]
218+
let a = [
219+
key2: ("this ", "string", "is long")
220+
]
221+
let a = [
222+
key2: (
223+
"this ", "string", "is long "
224+
)
225+
]
226+
let a = [
227+
key1: ("a", "z"), key2: ("b ", "y"),
228+
]
229+
let a = [
230+
key1: ("ab", "z"),
231+
key2: ("b ", "y"),
232+
]
233+
234+
"""
235+
// Ideally, this dictionary would be left on 1 line without a trailing comma. We don't know if
236+
// the comma is required when calculating the length of elements, so the comma's length is
237+
// always added to last element and that 1 character causes the newlines inside of the
238+
// dictionary.
239+
+ """
240+
a = [
241+
k1: ("ab", "z"), k2: ("bc", "y"),
242+
]
243+
244+
"""
245+
246+
assertPrettyPrintEqual(input: input, expected: expected, linelength: 38)
247+
}
178248
}

Tests/SwiftFormatPrettyPrintTests/XCTestManifests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ extension ArrayDeclTests {
2525
("testArrayOfFunctions", testArrayOfFunctions),
2626
("testBasicArrays", testBasicArrays),
2727
("testGroupsTrailingComma", testGroupsTrailingComma),
28+
("testInnerElementBreakingFromComma", testInnerElementBreakingFromComma),
2829
("testNoTrailingCommasInTypes", testNoTrailingCommasInTypes),
2930
("testTrailingCommaDiagnostics", testTrailingCommaDiagnostics),
3031
("testWhitespaceOnlyDoesNotChangeTrailingComma", testWhitespaceOnlyDoesNotChangeTrailingComma),
@@ -196,6 +197,7 @@ extension DictionaryDeclTests {
196197
("testBasicDictionaries", testBasicDictionaries),
197198
("testDiscretionaryNewlineAfterColon", testDiscretionaryNewlineAfterColon),
198199
("testGroupsTrailingComma", testGroupsTrailingComma),
200+
("testInnerElementBreakingFromComma", testInnerElementBreakingFromComma),
199201
("testNoTrailingCommasInTypes", testNoTrailingCommasInTypes),
200202
("testTrailingCommaDiagnostics", testTrailingCommaDiagnostics),
201203
("testWhitespaceOnlyDoesNotChangeTrailingComma", testWhitespaceOnlyDoesNotChangeTrailingComma),

0 commit comments

Comments
 (0)