Fix indentation for closure parameters on separate lines (#2379)

Co-authored-by: calda <1811727+calda@users.noreply.github.com>
This commit is contained in:
Copilot
2026-03-03 08:09:28 -08:00
committed by Cal Stephens
co-authored by calda
parent 1490513172
commit a9ef5e4cdc
4 changed files with 378 additions and 26 deletions
+190 -23
View File
@@ -771,33 +771,20 @@ extension Formatter {
return true
}
/// Returns true if the index is within a closure's argument list (between `{` and `in`).
func isInClosureArguments(at i: Int) -> Bool {
var i = i
while let token = token(at: i) {
switch token {
case .keyword("in"), .keyword("throws"), .keyword("rethrows"), .identifier("async"):
guard let scopeIndex = index(of: .startOfScope, before: i, if: {
$0 == .startOfScope("{")
}), isStartOfClosure(at: scopeIndex) else {
// Find the enclosing `{` scope, walking past any nested scopes
var scopeStart = i
while let startIndex = startOfScope(at: scopeStart) {
if tokens[startIndex] == .startOfScope("{") {
guard isStartOfClosure(at: startIndex),
let closureArgs = parseClosureArguments(at: startIndex)
else {
return false
}
if token != .keyword("in"),
let arrowIndex = index(of: .operator("->", .infix), after: i),
next(.keyword, after: arrowIndex) != .keyword("in")
{
return false
}
return true
case .startOfScope("("), .startOfScope("["), .startOfScope("<"),
.endOfScope(")"), .endOfScope("]"), .endOfScope(">"),
.keyword where token.isAttribute || token.isMacro, _ where token.isComment:
break
case .keyword, .startOfScope, .endOfScope:
return false
default:
break
return i > startIndex && i <= closureArgs.inKeywordIndex
}
i += 1
scopeStart = startIndex
}
return false
}
@@ -2779,6 +2766,186 @@ extension Formatter {
return (argumentNames: argumentNames, inKeywordIndex: inKeywordIndex)
}
/// A fully parsed closure arguments list
struct ClosureArguments {
/// The range of the capture list `[...]` if present
let captureListRange: ClosedRange<Int>?
/// The index of any global actor attribute like `@MainActor`
let globalActorIndex: Int?
/// The range of the parameters (either bare identifiers or parenthesized list)
let parametersRange: ClosedRange<Int>?
/// The indices of individual argument identifiers
let argumentIndices: [Int]
/// The range of the return type `-> Type` if present
let returnTypeRange: ClosedRange<Int>?
/// The index of the `in` keyword
let inKeywordIndex: Int
}
/// Parses closure arguments from the `{` start of closure through to the `in` keyword.
/// Returns nil if the closure has no arguments or if parsing fails.
func parseClosureArguments(at closureStartIndex: Int) -> ClosureArguments? {
assert(tokens[closureStartIndex] == .startOfScope("{"))
var currentIndex = closureStartIndex
// Check for global actor like @MainActor (can appear before capture list)
var globalActorIndex: Int?
if let nextToken = index(of: .nonSpaceOrCommentOrLinebreak, after: currentIndex),
tokens[nextToken].isAttribute
{
globalActorIndex = nextToken
currentIndex = nextToken
}
// Parse optional capture list [weak self, unowned bar]
var captureListRange: ClosedRange<Int>?
if let firstToken = index(of: .nonSpaceOrCommentOrLinebreak, after: currentIndex),
tokens[firstToken] == .startOfScope("["),
let captureListEnd = endOfScope(at: firstToken)
{
captureListRange = firstToken ... captureListEnd
currentIndex = captureListEnd
}
// Check for global actor after capture list (if not found before)
if globalActorIndex == nil,
let nextToken = index(of: .nonSpaceOrCommentOrLinebreak, after: currentIndex),
tokens[nextToken].isAttribute
{
globalActorIndex = nextToken
currentIndex = nextToken
}
// Now look for arguments - either bare identifiers or parenthesized list
guard let firstParamToken = index(of: .nonSpaceOrCommentOrLinebreak, after: currentIndex) else {
return nil
}
var argumentIndices: [Int] = []
var parametersRange: ClosedRange<Int>?
var returnTypeRange: ClosedRange<Int>?
// Case 1: Parenthesized parameters like { (foo: Int, bar: String) in }
if tokens[firstParamToken] == .startOfScope("(") {
guard let paramsEnd = endOfScope(at: firstParamToken) else {
return nil
}
parametersRange = firstParamToken ... paramsEnd
// Parse arguments inside parens
var argIndex = firstParamToken + 1
while argIndex < paramsEnd {
if let nextNonSpace = index(of: .nonSpaceOrCommentOrLinebreak, in: argIndex ..< paramsEnd),
tokens[nextNonSpace].isIdentifierOrKeyword
{
argumentIndices.append(nextNonSpace)
// Skip to next comma or end of scope
if let nextComma = index(of: .delimiter(","), in: nextNonSpace ..< paramsEnd) {
argIndex = nextComma + 1
} else {
break
}
} else {
break
}
}
currentIndex = paramsEnd
// Skip past throws/rethrows/async keywords and return type
if let nextTokenIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: currentIndex) {
var idx = nextTokenIndex
// Skip throws/rethrows/async (including typed throws like throws(Foo))
while [.keyword("throws"), .keyword("rethrows"), .identifier("async")].contains(tokens[idx]) {
// Handle typed throws: throws(ErrorType)
if let parenStart = index(of: .nonSpaceOrCommentOrLinebreak, after: idx),
tokens[parenStart] == .startOfScope("("),
let parenEnd = endOfScope(at: parenStart),
let next = index(of: .nonSpaceOrCommentOrLinebreak, after: parenEnd)
{
idx = next
} else if let next = index(of: .nonSpaceOrCommentOrLinebreak, after: idx) {
idx = next
} else {
break
}
}
// Skip return type (-> Type)
if tokens[idx] == .operator("->", .infix),
let returnTypeStart = index(of: .nonSpaceOrCommentOrLinebreak, after: idx),
let returnType = parseType(at: returnTypeStart)
{
returnTypeRange = nextTokenIndex ... returnType.range.upperBound
currentIndex = returnType.range.upperBound
} else if idx != nextTokenIndex {
// Had throws/rethrows/async keywords - advance past them
currentIndex = index(of: .nonSpaceOrCommentOrLinebreak, before: idx) ?? currentIndex
}
}
}
// Case 2: Bare identifiers like { foo, bar in }
else if tokens[firstParamToken].isIdentifier {
let paramsStart = firstParamToken
var paramsEnd = firstParamToken
// Parse bare identifier list
var argIndex = firstParamToken
while argIndex < tokens.count {
if tokens[argIndex].isIdentifier {
argumentIndices.append(argIndex)
paramsEnd = argIndex
// Check what comes after this identifier
if let nextNonSpace = index(of: .nonSpaceOrCommentOrLinebreak, after: argIndex) {
if tokens[nextNonSpace] == .delimiter(",") {
// Continue to next parameter
argIndex = nextNonSpace + 1
continue
} else if tokens[nextNonSpace] == .keyword("in") {
// Found the end of parameters
break
} else {
// Unexpected token
return nil
}
} else {
break
}
} else if tokens[argIndex].isSpaceOrCommentOrLinebreak {
argIndex += 1
} else {
// Unexpected token
return nil
}
}
if !argumentIndices.isEmpty {
parametersRange = paramsStart ... paramsEnd
}
currentIndex = paramsEnd
}
// Must find 'in' keyword
guard let inKeywordIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: currentIndex),
tokens[inKeywordIndex] == .keyword("in")
else {
return nil
}
return ClosureArguments(
captureListRange: captureListRange,
globalActorIndex: globalActorIndex,
parametersRange: parametersRange,
argumentIndices: argumentIndices,
returnTypeRange: returnTypeRange,
inKeywordIndex: inKeywordIndex
)
}
/// Get the type of the declaration starting at the index of the declaration keyword
func declarationType(at index: Int) -> String? {
guard let token = token(at: index), token.isDeclarationTypeKeyword,
+3 -1
View File
@@ -614,7 +614,9 @@ public extension FormatRule {
} else if !formatter.options.xcodeIndentation || !formatter.isWrappedDeclaration(at: i) {
indent += formatter.linewrapIndent(at: i)
}
} else if !formatter.options.xcodeIndentation || !formatter.isWrappedDeclaration(at: i) {
} else if (!formatter.options.xcodeIndentation || !formatter.isWrappedDeclaration(at: i)),
!formatter.isInClosureArguments(at: i)
{
indent += formatter.linewrapIndent(at: i)
}
+150
View File
@@ -3308,4 +3308,154 @@ final class ParsingHelpersTests: XCTestCase {
let properties = typeDecl.body.filter { $0.keyword == "var" || $0.keyword == "let" }
XCTAssertEqual(properties.count, 3, "Should find 3 properties: sizeClass, actionBar, title")
}
// MARK: parseClosureArguments
func testParseClosureArgumentsSimpleBareIdentifiers() {
let input = "foo { bar, baz in print(bar + baz) }"
let formatter = Formatter(tokenize(input))
guard let braceIndex = formatter.index(of: .startOfScope("{"), after: -1),
let closureArgs = formatter.parseClosureArguments(at: braceIndex)
else {
XCTFail("Failed to parse closure arguments")
return
}
XCTAssertNil(closureArgs.captureListRange)
XCTAssertNil(closureArgs.globalActorIndex)
XCTAssertNil(closureArgs.returnTypeRange)
XCTAssertEqual(closureArgs.argumentIndices.count, 2)
XCTAssertEqual(formatter.tokens[closureArgs.inKeywordIndex], .keyword("in"))
}
func testParseClosureArgumentsWithParens() {
let input = "foo { (bar, baz) in print(bar + baz) }"
let formatter = Formatter(tokenize(input))
guard let braceIndex = formatter.index(of: .startOfScope("{"), after: -1),
let closureArgs = formatter.parseClosureArguments(at: braceIndex)
else {
XCTFail("Failed to parse closure arguments")
return
}
XCTAssertNil(closureArgs.captureListRange)
XCTAssertNil(closureArgs.globalActorIndex)
XCTAssertNotNil(closureArgs.parametersRange)
XCTAssertNil(closureArgs.returnTypeRange)
XCTAssertEqual(closureArgs.argumentIndices.count, 2)
XCTAssertEqual(formatter.tokens[closureArgs.inKeywordIndex], .keyword("in"))
}
func testParseClosureArgumentsWithExplicitTypes() {
let input = "foo { (bar: Int, baz: String) -> Bool in return true }"
let formatter = Formatter(tokenize(input))
guard let braceIndex = formatter.index(of: .startOfScope("{"), after: -1),
let closureArgs = formatter.parseClosureArguments(at: braceIndex)
else {
XCTFail("Failed to parse closure arguments")
return
}
XCTAssertNil(closureArgs.captureListRange)
XCTAssertNil(closureArgs.globalActorIndex)
XCTAssertNotNil(closureArgs.parametersRange)
XCTAssertNotNil(closureArgs.returnTypeRange)
XCTAssertEqual(closureArgs.argumentIndices.count, 2)
XCTAssertEqual(formatter.tokens[closureArgs.inKeywordIndex], .keyword("in"))
}
func testParseClosureArgumentsCaptureListNoParams() {
let input = "foo { [weak self, unowned bar] in self?.doSomething() }"
let formatter = Formatter(tokenize(input))
guard let braceIndex = formatter.index(of: .startOfScope("{"), after: -1),
let closureArgs = formatter.parseClosureArguments(at: braceIndex)
else {
XCTFail("Failed to parse closure arguments")
return
}
XCTAssertNotNil(closureArgs.captureListRange)
XCTAssertNil(closureArgs.globalActorIndex)
XCTAssertNil(closureArgs.returnTypeRange)
XCTAssertEqual(closureArgs.argumentIndices.count, 0)
XCTAssertEqual(formatter.tokens[closureArgs.inKeywordIndex], .keyword("in"))
}
func testParseClosureArgumentsCaptureListWithBareParams() {
let input = "foo { [weak self] bar in self?.process(bar) }"
let formatter = Formatter(tokenize(input))
guard let braceIndex = formatter.index(of: .startOfScope("{"), after: -1),
let closureArgs = formatter.parseClosureArguments(at: braceIndex)
else {
XCTFail("Failed to parse closure arguments")
return
}
XCTAssertNotNil(closureArgs.captureListRange)
XCTAssertNil(closureArgs.globalActorIndex)
XCTAssertNil(closureArgs.returnTypeRange)
XCTAssertEqual(closureArgs.argumentIndices.count, 1)
XCTAssertEqual(formatter.tokens[closureArgs.inKeywordIndex], .keyword("in"))
}
func testParseClosureArgumentsGlobalActorNoParams() {
let input = "foo { @MainActor in print(\"test\") }"
let formatter = Formatter(tokenize(input))
guard let braceIndex = formatter.index(of: .startOfScope("{"), after: -1),
let closureArgs = formatter.parseClosureArguments(at: braceIndex)
else {
XCTFail("Failed to parse closure arguments")
return
}
XCTAssertNil(closureArgs.captureListRange)
XCTAssertNotNil(closureArgs.globalActorIndex)
XCTAssertNil(closureArgs.returnTypeRange)
XCTAssertEqual(closureArgs.argumentIndices.count, 0)
XCTAssertEqual(formatter.tokens[closureArgs.inKeywordIndex], .keyword("in"))
}
func testParseClosureArgumentsGlobalActorWithParams() {
let input = "foo { @MainActor (bar: Int) in print(bar) }"
let formatter = Formatter(tokenize(input))
guard let braceIndex = formatter.index(of: .startOfScope("{"), after: -1),
let closureArgs = formatter.parseClosureArguments(at: braceIndex)
else {
XCTFail("Failed to parse closure arguments")
return
}
XCTAssertNil(closureArgs.captureListRange)
XCTAssertNotNil(closureArgs.globalActorIndex)
XCTAssertNotNil(closureArgs.parametersRange)
XCTAssertNil(closureArgs.returnTypeRange)
XCTAssertEqual(closureArgs.argumentIndices.count, 1)
XCTAssertEqual(formatter.tokens[closureArgs.inKeywordIndex], .keyword("in"))
}
func testParseClosureArgumentsThrowsReturnType() {
let input = "foo { (x: Int, y: Int) throws -> Int in x + y }"
let formatter = Formatter(tokenize(input))
guard let braceIndex = formatter.index(of: .startOfScope("{"), after: -1),
let closureArgs = formatter.parseClosureArguments(at: braceIndex)
else {
XCTFail("Failed to parse closure arguments")
return
}
XCTAssertNil(closureArgs.captureListRange)
XCTAssertNil(closureArgs.globalActorIndex)
XCTAssertNotNil(closureArgs.parametersRange)
XCTAssertNotNil(closureArgs.returnTypeRange)
XCTAssertEqual(closureArgs.argumentIndices.count, 2)
XCTAssertEqual(formatter.tokens[closureArgs.inKeywordIndex], .keyword("in"))
}
}
+35 -2
View File
@@ -459,6 +459,22 @@ final class IndentTests: XCTestCase {
testFormatting(for: input, rule: .indent)
}
func testIndentMultilineClosureParametersWithoutParens() {
let input = """
let observable = Observable.combineLatest(
relay1.asObservable(),
relay2.asObservable(),
relay3.asObservable()
) {
test1,
test2,
test3 in
test1 && test2 && test3
}
"""
testFormatting(for: input, rule: .indent, exclude: [.propertyTypes])
}
func testIndentWrappedClosureCaptureList() {
let input = """
foo { [
@@ -472,7 +488,6 @@ final class IndentTests: XCTestCase {
testFormatting(for: input, rule: .indent)
}
// TODO: add `unwrap` rule to improve this case
func testIndentWrappedClosureCaptureList2() {
let input = """
class A {}
@@ -492,7 +507,25 @@ final class IndentTests: XCTestCase {
return x + y
}
"""
testFormatting(for: input, rule: .indent, exclude: [.propertyTypes])
let output = """
class A {}
let a = A()
let f = { [
weak a
]
(
x: Int,
y: Int
)
throws
->
Int
in
print("Hello, World! " + String(x + y))
return x + y
}
"""
testFormatting(for: input, output, rule: .indent, exclude: [.propertyTypes])
}
func testIndentWrappedClosureCaptureListWithUnwrappedParameters() {