Skip to content

Commit

Permalink
Add multiline function chains rule (#1981)
Browse files Browse the repository at this point in the history
  • Loading branch information
erichoracek authored Feb 24, 2025
1 parent 9c04587 commit 718a731
Show file tree
Hide file tree
Showing 9 changed files with 575 additions and 6 deletions.
22 changes: 22 additions & 0 deletions Rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
* [wrapConditionalBodies](#wrapConditionalBodies)
* [wrapEnumCases](#wrapEnumCases)
* [wrapMultilineConditionalAssignment](#wrapMultilineConditionalAssignment)
* [wrapMultilineFunctionChains](#wrapMultilineFunctionChains)
* [wrapSwitchCases](#wrapSwitchCases)

# Deprecated Rules (do not use)
Expand Down Expand Up @@ -3493,6 +3494,27 @@ Wrap multiline conditional assignment expressions after the assignment operator.
</details>
<br/>

## wrapMultilineFunctionChains

Wraps chained function calls to either all on the same line, or one per line.

<details>
<summary>Examples</summary>

```diff
let evenSquaresSum = [20, 17, 35, 4]
- .filter { $0 % 2 == 0 }.map { $0 * $0 }
.reduce(0, +)

let evenSquaresSum = [20, 17, 35, 4]
+ .filter { $0 % 2 == 0 }
+ .map { $0 * $0 }
.reduce(0, +)
```

</details>
<br/>

## wrapMultilineStatementBraces

Wrap the opening brace of multiline statements.
Expand Down
1 change: 1 addition & 0 deletions Sources/RuleRegistry.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ let ruleRegistry: [String: FormatRule] = [
"wrapEnumCases": .wrapEnumCases,
"wrapLoopBodies": .wrapLoopBodies,
"wrapMultilineConditionalAssignment": .wrapMultilineConditionalAssignment,
"wrapMultilineFunctionChains": .wrapMultilineFunctionChains,
"wrapMultilineStatementBraces": .wrapMultilineStatementBraces,
"wrapSingleLineComments": .wrapSingleLineComments,
"wrapSwitchCases": .wrapSwitchCases,
Expand Down
218 changes: 218 additions & 0 deletions Sources/Rules/WrapMultilineFunctionChains.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
//
// WrapMultilineFunctionChains.swift
// SwiftFormat
//
// Created by Eric Horacek on 2/20/2025
// Copyright © 2025 Nick Lockwood. All rights reserved.
//

import Foundation

public extension FormatRule {
static let wrapMultilineFunctionChains = FormatRule(
help: "Wraps chained function calls to either all on the same line, or one per line.",
disabledByDefault: true,
orderAfter: [.braces, .indent],
sharedOptions: ["linebreaks"]
) { formatter in
formatter.forEach(.operator(".", .infix)) { operatorIndex, _ in
if formatter.isInReturnType(at: operatorIndex) {
return
}

var foundFunctionCall = false
var dots: [Int] = []
let chainStartIndex = formatter.chainStartIndex(forOperatorAtIndex: operatorIndex, foundFunctionCall: &foundFunctionCall, dots: &dots)
dots.append(operatorIndex)
let chainEndIndex = formatter.chainEndIndex(forOperatorAtIndex: operatorIndex, foundFunctionCall: &foundFunctionCall, dots: &dots)

// Ensure we have at least one function call in the chain and two dots.
guard foundFunctionCall, dots.count > 1 else {
return
}

// Only wrap function chains that start on a new line from their base. If the token
// preceding the chain’s start is on the same line, we assume this is a single line
// chain.
let startOfLine = formatter.startOfLine(at: chainStartIndex)
if dots.allSatisfy({ formatter.startOfLine(at: $0) == startOfLine }) {
return
}

// If a closing scope immediately precedes this operator on the same line, insert a
// line break
if let previousNonSpaceIndex = formatter.index(of: .nonSpaceOrComment, before: operatorIndex),
previousNonSpaceIndex > chainStartIndex,
case .endOfScope = formatter.token(at: previousNonSpaceIndex),
formatter.onSameLine(previousNonSpaceIndex, operatorIndex)
{
formatter.insertLinebreak(at: operatorIndex)
return
}

if let nextOperatorIndex = formatter.index(of: .operator(".", .infix), after: operatorIndex),
nextOperatorIndex < chainEndIndex,
formatter.onSameLine(operatorIndex, nextOperatorIndex)
{
formatter.insertLinebreak(at: nextOperatorIndex)
}
}
} examples: {
"""
```diff
let evenSquaresSum = [20, 17, 35, 4]
- .filter { $0 % 2 == 0 }.map { $0 * $0 }
.reduce(0, +)
let evenSquaresSum = [20, 17, 35, 4]
+ .filter { $0 % 2 == 0 }
+ .map { $0 * $0 }
.reduce(0, +)
```
"""
}
}

extension Formatter {
func chainStartIndex(forOperatorAtIndex operatorIndex: Int, foundFunctionCall: inout Bool, dots: inout [Int]) -> Int {
var chainStartIndex = operatorIndex
var penultimateToken: Token?
walk: while let prevIndex = index(of: .nonSpaceOrCommentOrLinebreak, before: chainStartIndex),
let prevToken = token(at: prevIndex)
{
defer { penultimateToken = prevToken }

switch (prevToken, penultimateToken) {
case (.endOfScope, .identifier),
(.endOfScope, .number),
(.identifier, .number),
(.number, .identifier),
(.identifier, .identifier),
(.number, .number):
break walk
default:
break
}

switch prevToken {
case .endOfScope(")"):
// Function call: jump to the matching opening parenthesis.
if let openParenIndex = index(of: .startOfScope("("), before: prevIndex) {
chainStartIndex = openParenIndex
foundFunctionCall = true
continue
} else {
break walk
}

case .endOfScope("]"):
// Subscript call: jump to the matching opening bracket.
if let openBracketIndex = index(of: .startOfScope("["), before: prevIndex) {
chainStartIndex = openBracketIndex
continue
} else {
break walk
}

case .endOfScope("}"):
// Trailing closure end: jump to the matching opening brace.
if let openBraceIndex = index(of: .startOfScope("{"), before: prevIndex) {
chainStartIndex = openBraceIndex
foundFunctionCall = true
continue
} else {
break walk
}

case let .operator(op, opType) where (op == "." && opType == .infix) || (op == "?" && opType == .postfix):
// Property access or infix chaining operator.
if op == "." {
dots.append(prevIndex)
}
chainStartIndex = prevIndex
continue

case .identifier, .number:
// Identifiers and numbers may form the base of a chain.
chainStartIndex = prevIndex
continue

default:
// Any other token ends the backward walk.
break walk
}
}
return chainStartIndex
}

func chainEndIndex(forOperatorAtIndex operatorIndex: Int, foundFunctionCall: inout Bool, dots: inout [Int]) -> Int {
var chainEndIndex = operatorIndex
var previousToken: Token?
walk: while let nextIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: chainEndIndex),
let nextToken = token(at: nextIndex)
{
defer { previousToken = nextToken }

switch (previousToken, nextToken) {
case (.startOfScope, .identifier),
(.startOfScope, .number),
(.identifier, .number),
(.number, .identifier),
(.identifier, .identifier),
(.number, .number):
break walk
default:
break
}

switch nextToken {
case .startOfScope("("):
// Function call: jump to the matching closing parenthesis.
if let closeParenIndex = index(of: .endOfScope(")"), after: nextIndex) {
chainEndIndex = closeParenIndex
foundFunctionCall = true
continue
} else {
break walk
}

case .startOfScope("["):
// Subscript call: jump to the matching closing bracket.
if let closeBracketIndex = index(of: .endOfScope("]"), after: nextIndex) {
chainEndIndex = closeBracketIndex
continue
} else {
break walk
}

case .startOfScope("{"):
// Trailing closure: jump to the matching closing brace.
if let closeBraceIndex = index(of: .endOfScope("}"), after: nextIndex) {
chainEndIndex = closeBraceIndex
foundFunctionCall = true
continue
} else {
break walk
}

case let .operator(op, opType) where (op == "." && opType == .infix) || (op == "?" && opType == .postfix):
if op == "." {
dots.append(nextIndex)
}
// Property access or infix chaining operator.
chainEndIndex = nextIndex
continue

case .identifier, .number:
// Identifiers and numbers may form the base of a chain.
chainEndIndex = nextIndex
continue

default:
// Any other token ends the forwards walk.
break walk
}
}
return chainEndIndex
}
}
14 changes: 14 additions & 0 deletions SwiftFormat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,11 @@
9BDB4F212C94780200C93995 /* PrivateStateVariables.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BDB4F1C2C94773600C93995 /* PrivateStateVariables.swift */; };
A3DF48252620E03600F45A5F /* JSONReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3DF48242620E03600F45A5F /* JSONReporter.swift */; };
A3DF48262620E03600F45A5F /* JSONReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3DF48242620E03600F45A5F /* JSONReporter.swift */; };
A64BDD1B2D68640F008C9B8A /* WrapMultilineFunctionChains.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64BDD1A2D68640F008C9B8A /* WrapMultilineFunctionChains.swift */; };
A64BDD1C2D68640F008C9B8A /* WrapMultilineFunctionChains.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64BDD1A2D68640F008C9B8A /* WrapMultilineFunctionChains.swift */; };
A64BDD1D2D68640F008C9B8A /* WrapMultilineFunctionChains.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64BDD1A2D68640F008C9B8A /* WrapMultilineFunctionChains.swift */; };
A64BDD1E2D68640F008C9B8A /* WrapMultilineFunctionChains.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64BDD1A2D68640F008C9B8A /* WrapMultilineFunctionChains.swift */; };
A64BDD202D68641B008C9B8A /* WrapMultilineFunctionChainsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64BDD1F2D68641B008C9B8A /* WrapMultilineFunctionChainsTests.swift */; };
ABC11AF82CC082D300556471 /* EnvironmentEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC11AF72CC082D300556471 /* EnvironmentEntryTests.swift */; };
ABC4BA2C2CB9B094002C6874 /* EnvironmentEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC4BA2B2CB9B094002C6874 /* EnvironmentEntry.swift */; };
ABC4BA2D2CB9B094002C6874 /* EnvironmentEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC4BA2B2CB9B094002C6874 /* EnvironmentEntry.swift */; };
Expand Down Expand Up @@ -1067,6 +1072,8 @@
9BDB4F1A2C94760000C93995 /* PrivateStateVariablesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrivateStateVariablesTests.swift; sourceTree = "<group>"; };
9BDB4F1C2C94773600C93995 /* PrivateStateVariables.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateStateVariables.swift; sourceTree = "<group>"; };
A3DF48242620E03600F45A5F /* JSONReporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONReporter.swift; sourceTree = "<group>"; };
A64BDD1A2D68640F008C9B8A /* WrapMultilineFunctionChains.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrapMultilineFunctionChains.swift; sourceTree = "<group>"; };
A64BDD1F2D68641B008C9B8A /* WrapMultilineFunctionChainsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrapMultilineFunctionChainsTests.swift; sourceTree = "<group>"; };
ABC11AF72CC082D300556471 /* EnvironmentEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentEntryTests.swift; sourceTree = "<group>"; };
ABC4BA2B2CB9B094002C6874 /* EnvironmentEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentEntry.swift; sourceTree = "<group>"; };
B9C4F55B2387FA3E0088DBEE /* SupportedContentUTIs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedContentUTIs.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1378,6 +1385,7 @@
2E2BABE32C57F6DD00590239 /* WrapLoopBodies.swift */,
2E2BAB952C57F6DD00590239 /* WrapMultilineConditionalAssignment.swift */,
2E2BABD12C57F6DD00590239 /* WrapMultilineStatementBraces.swift */,
A64BDD1A2D68640F008C9B8A /* WrapMultilineFunctionChains.swift */,
2E2BABBB2C57F6DD00590239 /* WrapSingleLineComments.swift */,
2E2BABDF2C57F6DD00590239 /* WrapSwitchCases.swift */,
2E2BABF92C57F6DD00590239 /* YodaConditions.swift */,
Expand Down Expand Up @@ -1500,6 +1508,7 @@
2E8DE69B2C57FEB30032BF25 /* WrapLoopBodiesTests.swift */,
2E8DE6CD2C57FEB30032BF25 /* WrapMultilineConditionalAssignmentTests.swift */,
2E8DE6DA2C57FEB30032BF25 /* WrapMultilineStatementBracesTests.swift */,
A64BDD1F2D68641B008C9B8A /* WrapMultilineFunctionChainsTests.swift */,
2E8DE6F72C57FEB30032BF25 /* WrapSingleLineCommentsTests.swift */,
2E8DE6F12C57FEB30032BF25 /* WrapSwitchCasesTests.swift */,
2E8DE6A42C57FEB30032BF25 /* WrapTests.swift */,
Expand Down Expand Up @@ -1921,6 +1930,7 @@
01D3B28624E9C9C700888DE0 /* FormattingHelpers.swift in Sources */,
2E2BAD532C57F6DD00590239 /* TrailingCommas.swift in Sources */,
2E2BAD5F2C57F6DD00590239 /* RedundantBackticks.swift in Sources */,
A64BDD1B2D68640F008C9B8A /* WrapMultilineFunctionChains.swift in Sources */,
2E2BAC272C57F6DD00590239 /* RedundantNilInit.swift in Sources */,
2E2BACDB2C57F6DD00590239 /* SpaceAroundBraces.swift in Sources */,
2E2BAC772C57F6DD00590239 /* SpaceAroundComments.swift in Sources */,
Expand Down Expand Up @@ -2133,6 +2143,7 @@
2E8DE7222C57FEB30032BF25 /* SortDeclarationsTests.swift in Sources */,
2E8DE7122C57FEB30032BF25 /* BlankLinesAtEndOfScopeTests.swift in Sources */,
2E8DE7252C57FEB30032BF25 /* BracesTests.swift in Sources */,
A64BDD202D68641B008C9B8A /* WrapMultilineFunctionChainsTests.swift in Sources */,
08CC3AD82D656259005BFABE /* SwiftTestingTestCaseNamesTests.swift in Sources */,
2E8DE74B2C57FEB30032BF25 /* LeadingDelimitersTests.swift in Sources */,
2E8DE7552C57FEB30032BF25 /* RedundantTypedThrowsTests.swift in Sources */,
Expand Down Expand Up @@ -2217,6 +2228,7 @@
08CC3AD62D655C56005BFABE /* SwiftTestingTestCaseNames.swift in Sources */,
E4FABAD6202FEF060065716E /* OptionDescriptor.swift in Sources */,
2E2BACCC2C57F6DD00590239 /* IsEmpty.swift in Sources */,
A64BDD1C2D68640F008C9B8A /* WrapMultilineFunctionChains.swift in Sources */,
D52F6A652A82E04600FE1448 /* GitFileInfo.swift in Sources */,
2E2BAD382C57F6DD00590239 /* WrapSwitchCases.swift in Sources */,
2E2BAC882C57F6DD00590239 /* LeadingDelimiters.swift in Sources */,
Expand Down Expand Up @@ -2352,6 +2364,7 @@
2E2BAC752C57F6DD00590239 /* EmptyBraces.swift in Sources */,
ABC4BA2C2CB9B094002C6874 /* EnvironmentEntry.swift in Sources */,
2E2BACF92C57F6DD00590239 /* RedundantStaticSelf.swift in Sources */,
A64BDD1D2D68640F008C9B8A /* WrapMultilineFunctionChains.swift in Sources */,
2E2BAC512C57F6DD00590239 /* SortImports.swift in Sources */,
2E2BAC112C57F6DD00590239 /* WrapMultilineConditionalAssignment.swift in Sources */,
015243E32B04B0A600F65221 /* Singularize.swift in Sources */,
Expand Down Expand Up @@ -2504,6 +2517,7 @@
2E2BAC762C57F6DD00590239 /* EmptyBraces.swift in Sources */,
ABC4BA2D2CB9B094002C6874 /* EnvironmentEntry.swift in Sources */,
2E2BACFA2C57F6DD00590239 /* RedundantStaticSelf.swift in Sources */,
A64BDD1E2D68640F008C9B8A /* WrapMultilineFunctionChains.swift in Sources */,
2E2BAC522C57F6DD00590239 /* SortImports.swift in Sources */,
2E2BAC122C57F6DD00590239 /* WrapMultilineConditionalAssignment.swift in Sources */,
015243E42B04B0A700F65221 /* Singularize.swift in Sources */,
Expand Down
4 changes: 2 additions & 2 deletions Tests/Rules/IndentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1280,7 +1280,7 @@ class IndentTests: XCTestCase {
baz()
}
"""
testFormatting(for: input, output, rule: .indent)
testFormatting(for: input, output, rule: .indent, exclude: [.wrapMultilineFunctionChains])
}

func testChainedClosureIndentsAfterVarDeclaration() {
Expand Down Expand Up @@ -4127,7 +4127,7 @@ class IndentTests: XCTestCase {
"""

let options = FormatOptions(indentCase: true)
testFormatting(for: input, rule: .indent, options: options, exclude: [.wrap])
testFormatting(for: input, rule: .indent, options: options, exclude: [.wrap, .wrapMultilineFunctionChains])
}

func testGuardElseIndentAfterParenthesizedExpression() {
Expand Down
2 changes: 1 addition & 1 deletion Tests/Rules/MarkTypesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -941,7 +941,7 @@ class MarkTypesTests: XCTestCase {
]: Hashable {}
"""

testFormatting(for: input, output, rule: .markTypes)
testFormatting(for: input, output, rule: .markTypes, exclude: [.wrapMultilineFunctionChains])
}

func testSupportsUncheckedSendable() {
Expand Down
3 changes: 2 additions & 1 deletion Tests/Rules/WrapArgumentsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1990,7 +1990,8 @@ class WrapArgumentsTests: XCTestCase {
} else {
return false
}
}).isEmpty,
})
.isEmpty,
let bar = unwrappedFoo.bar,
let baz = unwrappedFoo.bar?
.first(where: { $0.isBaz }),
Expand Down
Loading

0 comments on commit 718a731

Please sign in to comment.