mirror of
https://github.com/nicklockwood/SwiftFormat.git
synced 2026-06-16 10:34:34 +00:00
Fix indentation for closure parameters on separate lines (#2379)
Co-authored-by: calda <1811727+calda@users.noreply.github.com>
This commit is contained in:
committed by
Cal Stephens
co-authored by
calda
parent
1490513172
commit
a9ef5e4cdc
+190
-23
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user