Skip to content

Commit a3de557

Browse files
committed
Introduce trailingCommasInMultilineLists configuration
1 parent cbe4886 commit a3de557

File tree

8 files changed

+947
-43
lines changed

8 files changed

+947
-43
lines changed

Documentation/Configuration.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,22 @@ switch someValue {
194194

195195
---
196196

197+
### `trailingCommasInMultilineLists`
198+
**type:** `string`
199+
200+
**description:** Determines how trailing commas in comma-separated lists should be handled during formatting.
201+
202+
- If set to `"always"`, a trailing comma is always added in multi-line lists.
203+
- If set to `"never"`, trailing commas are removed even in multi-line contexts.
204+
- If set to `"ignore"` (the default), existing commas are preserved as-is, and for collections, the behavior falls back to the `multiElementCollectionTrailingCommas`.
205+
206+
This option takes precedence over `multiElementCollectionTrailingCommas`, unless it is set to `"ignore"`.
207+
208+
209+
**default:** `"ignore"`
210+
211+
---
212+
197213
### `multiElementCollectionTrailingCommas`
198214
**type:** boolean
199215

Sources/SwiftFormat/API/Configuration+Default.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ extension Configuration {
3939
self.indentSwitchCaseLabels = false
4040
self.spacesAroundRangeFormationOperators = false
4141
self.noAssignmentInExpressions = NoAssignmentInExpressionsConfiguration()
42+
self.trailingCommasInMultilineLists = .ignore
4243
self.multiElementCollectionTrailingCommas = true
4344
self.reflowMultilineStringLiterals = .never
4445
self.indentBlankLines = false

Sources/SwiftFormat/API/Configuration.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public struct Configuration: Codable, Equatable {
4444
case rules
4545
case spacesAroundRangeFormationOperators
4646
case noAssignmentInExpressions
47+
case trailingCommasInMultilineLists
4748
case multiElementCollectionTrailingCommas
4849
case reflowMultilineStringLiterals
4950
case indentBlankLines
@@ -173,6 +174,22 @@ public struct Configuration: Codable, Equatable {
173174
/// Contains exceptions for the `NoAssignmentInExpressions` rule.
174175
public var noAssignmentInExpressions: NoAssignmentInExpressionsConfiguration
175176

177+
/// Determines how trailing commas in comma-separated lists should be handled during formatting.
178+
public enum TrailingCommasInMultilineLists: String, Codable {
179+
case always
180+
case never
181+
case ignore
182+
}
183+
184+
/// Determines how trailing commas in comma-separated lists are handled during formatting.
185+
///
186+
/// This setting takes precedence over `multiElementCollectionTrailingCommas`.
187+
/// If set to `.ignore` (the default), the formatter defers to `multiElementCollectionTrailingCommas`
188+
/// for collections only. In all other cases, existing trailing commas are preserved as-is and not modified.
189+
/// If set to `.always` or `.never`, that behavior is applied uniformly across all list types,
190+
/// regardless of `multiElementCollectionTrailingCommas`.
191+
public var trailingCommasInMultilineLists: TrailingCommasInMultilineLists
192+
176193
/// Determines if multi-element collection literals should have trailing commas.
177194
///
178195
/// When `true` (default), the correct form is:
@@ -384,6 +401,9 @@ public struct Configuration: Codable, Equatable {
384401
forKey: .noAssignmentInExpressions
385402
)
386403
?? defaults.noAssignmentInExpressions
404+
self.trailingCommasInMultilineLists =
405+
try container.decodeIfPresent(TrailingCommasInMultilineLists.self, forKey: .trailingCommasInMultilineLists)
406+
?? defaults.trailingCommasInMultilineLists
387407
self.multiElementCollectionTrailingCommas =
388408
try container.decodeIfPresent(
389409
Bool.self,

Sources/SwiftFormat/Core/SyntaxTraits.swift

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,81 @@ extension ExprSyntax {
6565
return self.asProtocol(KeywordModifiedExprSyntaxProtocol.self) != nil
6666
}
6767
}
68+
69+
/// Common protocol implemented by comma-separated lists whose elements
70+
/// support a `trailingComma`.
71+
protocol CommaSeparatedListSyntaxProtocol: SyntaxCollection where Element: WithTrailingCommaSyntax & Equatable {
72+
/// Indicates whether the list should be treated as a collection for formatting.
73+
/// If `true`, the list is affected by the `multiElementCollectionTrailingCommas` configuration.
74+
var isCollection: Bool { get }
75+
/// The node used for trailing comma handling; inserted immediately after this node.
76+
var lastNodeForTrailingComma: SyntaxProtocol? { get }
77+
}
78+
79+
extension ArrayElementListSyntax: CommaSeparatedListSyntaxProtocol {
80+
var isCollection: Bool { true }
81+
var lastNodeForTrailingComma: SyntaxProtocol? { last?.expression }
82+
}
83+
extension DictionaryElementListSyntax: CommaSeparatedListSyntaxProtocol {
84+
var isCollection: Bool { true }
85+
var lastNodeForTrailingComma: SyntaxProtocol? { last }
86+
}
87+
extension LabeledExprListSyntax: CommaSeparatedListSyntaxProtocol {
88+
var isCollection: Bool { false }
89+
var lastNodeForTrailingComma: SyntaxProtocol? { last?.expression }
90+
}
91+
extension ClosureCaptureListSyntax: CommaSeparatedListSyntaxProtocol {
92+
var isCollection: Bool { false }
93+
var lastNodeForTrailingComma: SyntaxProtocol? {
94+
if let initializer = last?.initializer {
95+
return initializer
96+
} else {
97+
return last?.name
98+
}
99+
}
100+
}
101+
extension EnumCaseParameterListSyntax: CommaSeparatedListSyntaxProtocol {
102+
var isCollection: Bool { false }
103+
var lastNodeForTrailingComma: SyntaxProtocol? {
104+
if let defaultValue = last?.defaultValue {
105+
return defaultValue
106+
} else {
107+
return last?.type
108+
}
109+
}
110+
}
111+
extension FunctionParameterListSyntax: CommaSeparatedListSyntaxProtocol {
112+
var isCollection: Bool { false }
113+
var lastNodeForTrailingComma: SyntaxProtocol? {
114+
if let defaultValue = last?.defaultValue {
115+
return defaultValue
116+
} else if let ellipsis = last?.ellipsis {
117+
return ellipsis
118+
} else {
119+
return last?.type
120+
}
121+
}
122+
}
123+
extension GenericParameterListSyntax: CommaSeparatedListSyntaxProtocol {
124+
var isCollection: Bool { false }
125+
var lastNodeForTrailingComma: SyntaxProtocol? {
126+
if let inheritedType = last?.inheritedType {
127+
return inheritedType
128+
} else {
129+
return last?.name
130+
}
131+
}
132+
}
133+
extension TuplePatternElementListSyntax: CommaSeparatedListSyntaxProtocol {
134+
var isCollection: Bool { false }
135+
var lastNodeForTrailingComma: SyntaxProtocol? { last?.pattern }
136+
}
137+
138+
extension SyntaxProtocol {
139+
func asProtocol(_: (any CommaSeparatedListSyntaxProtocol).Protocol) -> (any CommaSeparatedListSyntaxProtocol)? {
140+
return Syntax(self).asProtocol(SyntaxProtocol.self) as? (any CommaSeparatedListSyntaxProtocol)
141+
}
142+
func isProtocol(_: (any CommaSeparatedListSyntaxProtocol).Protocol) -> Bool {
143+
return self.asProtocol((any CommaSeparatedListSyntaxProtocol).self) != nil
144+
}
145+
}

Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,7 @@ public class PrettyPrinter {
501501
case .commaDelimitedRegionStart:
502502
commaDelimitedRegionStack.append(openCloseBreakCompensatingLineNumber)
503503

504-
case .commaDelimitedRegionEnd(let hasTrailingComma, let isSingleElement):
504+
case .commaDelimitedRegionEnd(let isCollection, let hasTrailingComma, let isSingleElement):
505505
guard let startLineNumber = commaDelimitedRegionStack.popLast() else {
506506
fatalError("Found trailing comma end with no corresponding start.")
507507
}
@@ -511,17 +511,30 @@ public class PrettyPrinter {
511511
// types) from a literal (where the elements are the contents of a collection instance).
512512
// We never want to add a trailing comma in an initializer so we disable trailing commas on
513513
// single element collections.
514-
let shouldHaveTrailingComma =
515-
startLineNumber != openCloseBreakCompensatingLineNumber && !isSingleElement
516-
&& configuration.multiElementCollectionTrailingCommas
517-
if shouldHaveTrailingComma && !hasTrailingComma {
518-
diagnose(.addTrailingComma, category: .trailingComma)
519-
} else if !shouldHaveTrailingComma && hasTrailingComma {
520-
diagnose(.removeTrailingComma, category: .trailingComma)
521-
}
514+
let shouldHandleCommaDelimitedRegion: Bool? =
515+
switch configuration.trailingCommasInMultilineLists {
516+
case .always:
517+
true
518+
case .never:
519+
false
520+
case .ignore:
521+
isCollection ? configuration.multiElementCollectionTrailingCommas : nil
522+
}
523+
if let shouldHandleCommaDelimitedRegion {
524+
let shouldHaveTrailingComma =
525+
startLineNumber != openCloseBreakCompensatingLineNumber && !isSingleElement
526+
&& shouldHandleCommaDelimitedRegion
527+
if shouldHaveTrailingComma && !hasTrailingComma {
528+
diagnose(.addTrailingComma, category: .trailingComma)
529+
} else if !shouldHaveTrailingComma && hasTrailingComma {
530+
diagnose(.removeTrailingComma, category: .trailingComma)
531+
}
522532

523-
let shouldWriteComma = whitespaceOnly ? hasTrailingComma : shouldHaveTrailingComma
524-
if shouldWriteComma {
533+
let shouldWriteComma = whitespaceOnly ? hasTrailingComma : shouldHaveTrailingComma
534+
if shouldWriteComma {
535+
outputBuffer.write(",")
536+
}
537+
} else if hasTrailingComma {
525538
outputBuffer.write(",")
526539
}
527540

@@ -686,15 +699,28 @@ public class PrettyPrinter {
686699
case .commaDelimitedRegionStart:
687700
lengths.append(0)
688701

689-
case .commaDelimitedRegionEnd(_, let isSingleElement):
702+
case .commaDelimitedRegionEnd(let isCollection, _, let isSingleElement):
690703
// The token's length is only necessary when a comma will be printed, but it's impossible to
691704
// know at this point whether the region-start token will be on the same line as this token.
692705
// Without adding this length to the total, it would be possible for this comma to be
693706
// printed in column `maxLineLength`. Unfortunately, this can cause breaks to fire
694707
// unnecessarily when the enclosed tokens comma would fit within `maxLineLength`.
695-
let length = isSingleElement ? 0 : 1
696-
total += length
697-
lengths.append(length)
708+
let shouldHandleCommaDelimitedRegion: Bool? =
709+
switch configuration.trailingCommasInMultilineLists {
710+
case .always:
711+
true
712+
case .never:
713+
false
714+
case .ignore:
715+
isCollection ? configuration.multiElementCollectionTrailingCommas : nil
716+
}
717+
if let shouldHandleCommaDelimitedRegion, shouldHandleCommaDelimitedRegion {
718+
let length = isSingleElement ? 0 : 1
719+
total += length
720+
lengths.append(length)
721+
} else {
722+
lengths.append(0)
723+
}
698724

699725
case .enableFormatting, .disableFormatting:
700726
// no effect on length calculations

Sources/SwiftFormat/PrettyPrint/Token.swift

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

195195
/// Marks the end of a comma delimited collection, where a trailing comma should be inserted
196196
/// if and only if the collection spans multiple lines and has multiple elements.
197-
case commaDelimitedRegionEnd(hasTrailingComma: Bool, isSingleElement: Bool)
197+
case commaDelimitedRegionEnd(isCollection: Bool, hasTrailingComma: Bool, isSingleElement: Bool)
198198

199199
/// Starts a scope where `contextual` breaks have consistent behavior.
200200
case contextualBreakingStart

Sources/SwiftFormat/PrettyPrint/TokenStreamCreator.swift

Lines changed: 46 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -922,9 +922,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor {
922922
}
923923

924924
override func visit(_ node: LabeledExprListSyntax) -> SyntaxVisitorContinueKind {
925-
// Intentionally do nothing here. Since `TupleExprElement`s are used both in tuple expressions
926-
// and function argument lists, which need to be formatted, differently, those nodes manually
927-
// loop over the nodes and arrange them in those contexts.
925+
markCommaDelimitedRegion(node)
928926
return .visitChildren
929927
}
930928

@@ -967,18 +965,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor {
967965
}
968966
}
969967

970-
if let lastElement = node.last {
971-
if let trailingComma = lastElement.trailingComma {
972-
ignoredTokens.insert(trailingComma)
973-
}
974-
before(node.first?.firstToken(viewMode: .sourceAccurate), tokens: .commaDelimitedRegionStart)
975-
let endToken =
976-
Token.commaDelimitedRegionEnd(
977-
hasTrailingComma: lastElement.trailingComma != nil,
978-
isSingleElement: node.first == lastElement
979-
)
980-
after(lastElement.expression.lastToken(viewMode: .sourceAccurate), tokens: [endToken])
981-
}
968+
markCommaDelimitedRegion(node)
982969
return .visitChildren
983970
}
984971

@@ -1011,18 +998,7 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor {
1011998
}
1012999
}
10131000

1014-
if let lastElement = node.last {
1015-
if let trailingComma = lastElement.trailingComma {
1016-
ignoredTokens.insert(trailingComma)
1017-
}
1018-
before(node.first?.firstToken(viewMode: .sourceAccurate), tokens: .commaDelimitedRegionStart)
1019-
let endToken =
1020-
Token.commaDelimitedRegionEnd(
1021-
hasTrailingComma: lastElement.trailingComma != nil,
1022-
isSingleElement: node.first == node.last
1023-
)
1024-
after(lastElement.lastToken(viewMode: .sourceAccurate), tokens: endToken)
1025-
}
1001+
markCommaDelimitedRegion(node)
10261002
return .visitChildren
10271003
}
10281004

@@ -1291,6 +1267,11 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor {
12911267
return .visitChildren
12921268
}
12931269

1270+
override func visit(_ node: ClosureCaptureListSyntax) -> SyntaxVisitorContinueKind {
1271+
markCommaDelimitedRegion(node)
1272+
return .visitChildren
1273+
}
1274+
12941275
override func visit(_ node: ClosureCaptureSyntax) -> SyntaxVisitorContinueKind {
12951276
before(node.firstToken(viewMode: .sourceAccurate), tokens: .open)
12961277
after(node.specifier?.lastToken(viewMode: .sourceAccurate), tokens: .break)
@@ -1405,6 +1386,11 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor {
14051386
return .visitChildren
14061387
}
14071388

1389+
override func visit(_ node: EnumCaseParameterListSyntax) -> SyntaxVisitorContinueKind {
1390+
markCommaDelimitedRegion(node)
1391+
return .visitChildren
1392+
}
1393+
14081394
override func visit(_ node: FunctionParameterClauseSyntax) -> SyntaxVisitorContinueKind {
14091395
// Prioritize keeping ") throws -> <return_type>" together. We can only do this if the function
14101396
// has arguments.
@@ -1417,6 +1403,11 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor {
14171403
return .visitChildren
14181404
}
14191405

1406+
override func visit(_ node: FunctionParameterListSyntax) -> SyntaxVisitorContinueKind {
1407+
markCommaDelimitedRegion(node)
1408+
return .visitChildren
1409+
}
1410+
14201411
override func visit(_ node: ClosureParameterSyntax) -> SyntaxVisitorContinueKind {
14211412
before(node.firstToken(viewMode: .sourceAccurate), tokens: .open)
14221413
arrangeAttributeList(node.attributes)
@@ -1722,6 +1713,11 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor {
17221713
return .visitChildren
17231714
}
17241715

1716+
override func visit(_ node: GenericParameterListSyntax) -> SyntaxVisitorContinueKind {
1717+
markCommaDelimitedRegion(node)
1718+
return .visitChildren
1719+
}
1720+
17251721
override func visit(_ node: PrimaryAssociatedTypeClauseSyntax) -> SyntaxVisitorContinueKind {
17261722
after(node.leftAngle, tokens: .break(.open, size: 0), .open(argumentListConsistency()))
17271723
before(node.rightAngle, tokens: .break(.close, size: 0), .close)
@@ -1772,6 +1768,11 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor {
17721768
return .visitChildren
17731769
}
17741770

1771+
override func visit(_ node: TuplePatternElementListSyntax) -> SyntaxVisitorContinueKind {
1772+
markCommaDelimitedRegion(node)
1773+
return .visitChildren
1774+
}
1775+
17751776
override func visit(_ node: TryExprSyntax) -> SyntaxVisitorContinueKind {
17761777
before(
17771778
node.expression.firstToken(viewMode: .sourceAccurate),
@@ -4283,6 +4284,24 @@ fileprivate final class TokenStreamCreator: SyntaxVisitor {
42834284
let hasCompoundExpression = !expr.is(DeclReferenceExprSyntax.self)
42844285
return (hasCompoundExpression, false)
42854286
}
4287+
4288+
/// Marks a comma-delimited region for the given list, inserting start/end tokens
4289+
/// and recording the last element’s trailing comma (if any) to be ignored.
4290+
private func markCommaDelimitedRegion<Node: CommaSeparatedListSyntaxProtocol>(_ node: Node) {
4291+
if let lastElement = node.last {
4292+
if let trailingComma = lastElement.trailingComma {
4293+
ignoredTokens.insert(trailingComma)
4294+
}
4295+
before(node.first?.firstToken(viewMode: .sourceAccurate), tokens: .commaDelimitedRegionStart)
4296+
let endToken =
4297+
Token.commaDelimitedRegionEnd(
4298+
isCollection: node.isCollection,
4299+
hasTrailingComma: lastElement.trailingComma != nil,
4300+
isSingleElement: node.first == lastElement
4301+
)
4302+
after(node.lastNodeForTrailingComma?.lastToken(viewMode: .sourceAccurate), tokens: [endToken])
4303+
}
4304+
}
42864305
}
42874306

42884307
private func isNestedInPostfixIfConfig(node: Syntax) -> Bool {

0 commit comments

Comments
 (0)