diff --git a/Sources/ParsingHelpers.swift b/Sources/ParsingHelpers.swift index 9c462686..8895fe8f 100644 --- a/Sources/ParsingHelpers.swift +++ b/Sources/ParsingHelpers.swift @@ -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? + /// 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? + /// The indices of individual argument identifiers + let argumentIndices: [Int] + /// The range of the return type `-> Type` if present + let returnTypeRange: ClosedRange? + /// 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? + 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? + var returnTypeRange: ClosedRange? + + // 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, diff --git a/Sources/Rules/Indent.swift b/Sources/Rules/Indent.swift index 72778961..ee0c431c 100644 --- a/Sources/Rules/Indent.swift +++ b/Sources/Rules/Indent.swift @@ -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) } diff --git a/Tests/ParsingHelpersTests.swift b/Tests/ParsingHelpersTests.swift index a0c68093..72bcfd0d 100644 --- a/Tests/ParsingHelpersTests.swift +++ b/Tests/ParsingHelpersTests.swift @@ -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")) + } } diff --git a/Tests/Rules/IndentTests.swift b/Tests/Rules/IndentTests.swift index b10f487c..6fb8ebd3 100644 --- a/Tests/Rules/IndentTests.swift +++ b/Tests/Rules/IndentTests.swift @@ -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() {