Skip to content

Commit 89d887a

Browse files
committed
Introduce multilineTrailingCommaBehavior configuration
1 parent cbe4886 commit 89d887a

File tree

8 files changed

+938
-43
lines changed

8 files changed

+938
-43
lines changed

Documentation/Configuration.md

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

195195
---
196196

197+
### `multilineTrailingCommaBehavior`
198+
**type:** `string`
199+
200+
**description:** Determines how trailing commas in comma-separated lists should be handled during formatting.
201+
202+
- If set to `"alwaysUsed"`, a trailing comma is always added in multi-line lists.
203+
- If set to `"neverUsed"`, trailing commas are removed even in multi-line lists.
204+
- If set to `"keptAsWritten"` (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 `"keptAsWritten"`.
207+
208+
**default:** `"keptAsWritten"`
209+
210+
---
211+
197212
### `multiElementCollectionTrailingCommas`
198213
**type:** boolean
199214

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.multilineTrailingCommaBehavior = .keptAsWritten
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 multilineTrailingCommaBehavior
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 MultilineTrailingCommaBehavior: String, Codable {
179+
case alwaysUsed
180+
case neverUsed
181+
case keptAsWritten
182+
}
183+
184+
/// Determines how trailing commas in multiline comma-separated lists are handled during formatting.
185+
///
186+
/// This setting takes precedence over `multiElementCollectionTrailingCommas`.
187+
/// If set to `.keptAsWritten` (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 `.alwaysUsed` or `.neverUsed`, that behavior is applied uniformly across all list types,
190+
/// regardless of `multiElementCollectionTrailingCommas`.
191+
public var multilineTrailingCommaBehavior: MultilineTrailingCommaBehavior
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.multilineTrailingCommaBehavior =
405+
try container.decodeIfPresent(MultilineTrailingCommaBehavior.self, forKey: .multilineTrailingCommaBehavior)
406+
?? defaults.multilineTrailingCommaBehavior
387407
self.multiElementCollectionTrailingCommas =
388408
try container.decodeIfPresent(
389409
Bool.self,

Sources/SwiftFormat/Core/SyntaxTraits.swift

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,70 @@ 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+
/// The node used for trailing comma handling; inserted immediately after this node.
73+
var lastNodeForTrailingComma: SyntaxProtocol? { get }
74+
}
75+
76+
extension ArrayElementListSyntax: CommaSeparatedListSyntaxProtocol {
77+
var lastNodeForTrailingComma: SyntaxProtocol? { last?.expression }
78+
}
79+
extension DictionaryElementListSyntax: CommaSeparatedListSyntaxProtocol {
80+
var lastNodeForTrailingComma: SyntaxProtocol? { last }
81+
}
82+
extension LabeledExprListSyntax: CommaSeparatedListSyntaxProtocol {
83+
var lastNodeForTrailingComma: SyntaxProtocol? { last?.expression }
84+
}
85+
extension ClosureCaptureListSyntax: CommaSeparatedListSyntaxProtocol {
86+
var lastNodeForTrailingComma: SyntaxProtocol? {
87+
if let initializer = last?.initializer {
88+
return initializer
89+
} else {
90+
return last?.name
91+
}
92+
}
93+
}
94+
extension EnumCaseParameterListSyntax: CommaSeparatedListSyntaxProtocol {
95+
var lastNodeForTrailingComma: SyntaxProtocol? {
96+
if let defaultValue = last?.defaultValue {
97+
return defaultValue
98+
} else {
99+
return last?.type
100+
}
101+
}
102+
}
103+
extension FunctionParameterListSyntax: CommaSeparatedListSyntaxProtocol {
104+
var lastNodeForTrailingComma: SyntaxProtocol? {
105+
if let defaultValue = last?.defaultValue {
106+
return defaultValue
107+
} else if let ellipsis = last?.ellipsis {
108+
return ellipsis
109+
} else {
110+
return last?.type
111+
}
112+
}
113+
}
114+
extension GenericParameterListSyntax: CommaSeparatedListSyntaxProtocol {
115+
var lastNodeForTrailingComma: SyntaxProtocol? {
116+
if let inheritedType = last?.inheritedType {
117+
return inheritedType
118+
} else {
119+
return last?.name
120+
}
121+
}
122+
}
123+
extension TuplePatternElementListSyntax: CommaSeparatedListSyntaxProtocol {
124+
var lastNodeForTrailingComma: SyntaxProtocol? { last?.pattern }
125+
}
126+
127+
extension SyntaxProtocol {
128+
func asProtocol(_: (any CommaSeparatedListSyntaxProtocol).Protocol) -> (any CommaSeparatedListSyntaxProtocol)? {
129+
return Syntax(self).asProtocol(SyntaxProtocol.self) as? (any CommaSeparatedListSyntaxProtocol)
130+
}
131+
func isProtocol(_: (any CommaSeparatedListSyntaxProtocol).Protocol) -> Bool {
132+
return self.asProtocol((any CommaSeparatedListSyntaxProtocol).self) != nil
133+
}
134+
}

Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift

Lines changed: 36 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,21 @@ 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+
if let shouldHandleCommaDelimitedRegion = shouldHandleCommaDelimitedRegion(isCollection: isCollection) {
515+
let shouldHaveTrailingComma =
516+
startLineNumber != openCloseBreakCompensatingLineNumber && !isSingleElement
517+
&& shouldHandleCommaDelimitedRegion
518+
if shouldHaveTrailingComma && !hasTrailingComma {
519+
diagnose(.addTrailingComma, category: .trailingComma)
520+
} else if !shouldHaveTrailingComma && hasTrailingComma {
521+
diagnose(.removeTrailingComma, category: .trailingComma)
522+
}
522523

523-
let shouldWriteComma = whitespaceOnly ? hasTrailingComma : shouldHaveTrailingComma
524-
if shouldWriteComma {
524+
let shouldWriteComma = whitespaceOnly ? hasTrailingComma : shouldHaveTrailingComma
525+
if shouldWriteComma {
526+
outputBuffer.write(",")
527+
}
528+
} else if hasTrailingComma {
525529
outputBuffer.write(",")
526530
}
527531

@@ -686,15 +690,19 @@ public class PrettyPrinter {
686690
case .commaDelimitedRegionStart:
687691
lengths.append(0)
688692

689-
case .commaDelimitedRegionEnd(_, let isSingleElement):
693+
case .commaDelimitedRegionEnd(let isCollection, _, let isSingleElement):
690694
// The token's length is only necessary when a comma will be printed, but it's impossible to
691695
// know at this point whether the region-start token will be on the same line as this token.
692696
// Without adding this length to the total, it would be possible for this comma to be
693697
// printed in column `maxLineLength`. Unfortunately, this can cause breaks to fire
694698
// unnecessarily when the enclosed tokens comma would fit within `maxLineLength`.
695-
let length = isSingleElement ? 0 : 1
696-
total += length
697-
lengths.append(length)
699+
if shouldHandleCommaDelimitedRegion(isCollection: isCollection) == true {
700+
let length = isSingleElement ? 0 : 1
701+
total += length
702+
lengths.append(length)
703+
} else {
704+
lengths.append(0)
705+
}
698706

699707
case .enableFormatting, .disableFormatting:
700708
// no effect on length calculations
@@ -723,6 +731,19 @@ public class PrettyPrinter {
723731
return outputBuffer.output
724732
}
725733

734+
/// Returns whether trailing comma insertion or removal should be performed for the given comma-delimited region,
735+
/// or `nil` to keep as written.
736+
private func shouldHandleCommaDelimitedRegion(isCollection: Bool) -> Bool? {
737+
switch configuration.multilineTrailingCommaBehavior {
738+
case .alwaysUsed:
739+
return true
740+
case .neverUsed:
741+
return false
742+
case .keptAsWritten:
743+
return isCollection ? configuration.multiElementCollectionTrailingCommas : nil
744+
}
745+
}
746+
726747
/// Used to track the indentation level for the debug token stream output.
727748
var debugIndent: [Indent] = []
728749

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: 54 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, isCollectionLiteral: false)
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, isCollectionLiteral: true)
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, isCollectionLiteral: true)
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, isCollectionLiteral: false)
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, isCollectionLiteral: false)
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, isCollectionLiteral: false)
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, isCollectionLiteral: false)
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, isCollectionLiteral: false)
1773+
return .visitChildren
1774+
}
1775+
17751776
override func visit(_ node: TryExprSyntax) -> SyntaxVisitorContinueKind {
17761777
before(
17771778
node.expression.firstToken(viewMode: .sourceAccurate),
@@ -4283,6 +4284,32 @@ 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+
///
4291+
/// - Parameters:
4292+
/// - node: The comma-separated list syntax node.
4293+
/// - isCollectionLiteral: Indicates whether the list should be treated as a collection literal during formatting.
4294+
/// If `true`, the list is affected by the `multiElementCollectionTrailingCommas` configuration.
4295+
private func markCommaDelimitedRegion<Node: CommaSeparatedListSyntaxProtocol>(
4296+
_ node: Node,
4297+
isCollectionLiteral: Bool
4298+
) {
4299+
if let lastElement = node.last {
4300+
if let trailingComma = lastElement.trailingComma {
4301+
ignoredTokens.insert(trailingComma)
4302+
}
4303+
before(node.first?.firstToken(viewMode: .sourceAccurate), tokens: .commaDelimitedRegionStart)
4304+
let endToken =
4305+
Token.commaDelimitedRegionEnd(
4306+
isCollection: isCollectionLiteral,
4307+
hasTrailingComma: lastElement.trailingComma != nil,
4308+
isSingleElement: node.first == lastElement
4309+
)
4310+
after(node.lastNodeForTrailingComma?.lastToken(viewMode: .sourceAccurate), tokens: [endToken])
4311+
}
4312+
}
42864313
}
42874314

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

0 commit comments

Comments
 (0)