From c830b336b54c8d1a230a849cc5ef29dfa008ac5f Mon Sep 17 00:00:00 2001 From: Steffeeen Date: Wed, 22 Apr 2026 11:35:53 +0200 Subject: [PATCH 1/2] Add API to match function parameters to arguments at call sites This new API allows matching the function arguments passed at the call site to their corresponding parameters in the function declaration. This functionality is needed to implement different refactorings. For example function inlining and deleting a parameter of a function. --- Sources/SwiftRefactor/ArgumentMatching.swift | 520 ++++++++++++++++ Sources/SwiftRefactor/CMakeLists.txt | 1 + .../ArgumentMatchingTests.swift | 563 ++++++++++++++++++ 3 files changed, 1084 insertions(+) create mode 100644 Sources/SwiftRefactor/ArgumentMatching.swift create mode 100644 Tests/SwiftRefactorTest/ArgumentMatchingTests.swift diff --git a/Sources/SwiftRefactor/ArgumentMatching.swift b/Sources/SwiftRefactor/ArgumentMatching.swift new file mode 100644 index 00000000000..6d771400197 --- /dev/null +++ b/Sources/SwiftRefactor/ArgumentMatching.swift @@ -0,0 +1,520 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +import SwiftSyntax + +public struct ArgumentMatchResult { + public enum ParameterKind { + case fixed + case variadic + case pack + } + + public struct ParameterMatch { + public let parameter: FunctionParameterSyntax + /// Always either: + /// - A LabeledExprSyntax for regular arguments + /// - A ClosureExprSyntax for single trailing closures + /// - A MultipleTrailingClosureElementSyntax for additional trailing closures + public let arguments: [any SyntaxProtocol] + public var isDefaulted: Bool { + arguments.isEmpty + } + public var kind: ParameterKind { + parameter.argumentMatchingKind + } + } + + private let matches: [ParameterMatch] + + public init(matches: [ParameterMatch]) { + self.matches = matches + } + + /// Returns the match entry for a specific parameter index, if it exists. + /// + /// Note that this uses the parameter index (the position of the parameter in the parameter list) + /// at the declaration site rather than the argument index at the call site. + public func matchForParameter(at index: Int) -> ParameterMatch? { + matches[index] + } + + /// Returns the match entry for a specific parameter name, if it exists. + /// + /// Note that this uses the parameter name (the second name in the parameter syntax) + /// rather than the external argument label. This is because the parameter name is guaranteed + /// to be unique and always present, while external argument labels may be duplicated or omitted. + public func matchForParameter(named name: String) -> ParameterMatch? { + matches.first { $0.parameter.parameterName == name } + } + + /// Returns the match entry for a specific function parameter syntax node. + /// + /// This is useful when iterating over a `FunctionParameterListSyntax` and + /// querying the associated match result for each parameter. + /// + /// - Parameter parameter: the `FunctionParameterSyntax` node to find a match for + /// - Returns: the `ParameterMatch` associated with the given parameter + public func match(for parameter: FunctionParameterSyntax) -> ParameterMatch { + precondition( + matches.contains(where: { $0.parameter == parameter }), + "Given parameter does not belong to the declaration associated with this match result" + ) + return matches.first { $0.parameter == parameter }! + } +} + +extension FunctionCallExprSyntax { + /// Match call arguments to function parameters using a syntax-only heuristic. + /// + /// This intentionally mirrors compiler behavior for many common and tricky + /// cases (especially trailing-closure fallback behavior) without requiring + /// type checking. + public func matchArguments( + to parameters: FunctionParameterListSyntax + ) -> ArgumentMatchResult? { + if self.hasError || parameters.hasError { + // If there are any syntax errors in the arguments or parameters, we cannot reliably perform matching, so we return nil. + return nil + } + + var arguments: [any SyntaxProtocol] = [] + for argument in self.arguments { + arguments.append(argument) + } + if let trailingClosure { + arguments.append(trailingClosure) + } + for additionalTrailingClosure in self.additionalTrailingClosures { + arguments.append(additionalTrailingClosure) + } + + return matchArgumentsToParameters(parameters: parameters, arguments: arguments) + } +} + +private func matchArgumentsToParameters( + parameters: FunctionParameterListSyntax, + arguments: [any SyntaxProtocol] +) -> ArgumentMatchResult? { + // Lookahead helpers used by closure-specific compatibility logic. + // We intentionally keep these syntax-only and cheap. + func hasLaterClosureParameter(startingAfter index: Int) -> Bool { + for nextParameter in parameters.dropFirst(index + 1) { + if nextParameter.expectsClosureArgument { + return true + } + } + return false + } + + func hasLaterUnlabeledFunctionParameter(startingAfter index: Int) -> Bool { + for nextParameter in parameters.dropFirst(index + 1) { + if nextParameter.expectsClosureArgument && nextParameter.externalArgumentLabelText == nil { + return true + } + } + return false + } + + func firstLaterRequiredClosureParameter(startingAfter index: Int) -> FunctionParameterSyntax? { + for nextParameter in parameters.dropFirst(index + 1) { + guard + nextParameter.argumentMatchingKind == .fixed, + nextParameter.defaultValue == nil, + nextParameter.expectsClosureArgument + else { + continue + } + return nextParameter + } + return nil + } + + func hasInterveningClosureParameterBeforeFirstLaterRequiredClosure(startingAfter index: Int) -> Bool { + var sawClosureParameter = false + for nextParameter in parameters.dropFirst(index + 1) { + guard nextParameter.expectsClosureArgument else { + continue + } + + if nextParameter.argumentMatchingKind == .fixed && nextParameter.defaultValue == nil { + return sawClosureParameter + } + + sawClosureParameter = true + } + return false + } + + /// Returns whether an unlabeled trailing closure should be preserved for a later + /// required closure parameter instead of being consumed by the current parameter. + /// + /// Rationale: + /// - If the next labeled argument appears to target a different required closure + /// parameter later in the list, consuming the unlabeled trailing closure now can + /// produce a mismatch compared to compiler behavior. + /// - If there is an intervening closure parameter before that required one, keeping + /// the trailing closure is not always possible, so we allow consuming it now. + func shouldKeepUnlabeledTrailingClosureForLaterRequiredClosure( + startingAfter parameterIndex: Int, + nextArgumentLabel: String? + ) -> Bool { + guard + let firstLaterRequiredClosureParameter = firstLaterRequiredClosureParameter(startingAfter: parameterIndex), + firstLaterRequiredClosureParameter.externalArgumentLabelText != nextArgumentLabel, + !hasInterveningClosureParameterBeforeFirstLaterRequiredClosure(startingAfter: parameterIndex) + else { + return false + } + + return true + } + + func consumeArgument( + at index: inout Int, + into matchingArguments: inout [any SyntaxProtocol] + ) { + assert(index < arguments.endIndex) + matchingArguments.append(arguments[index]) + index = arguments.index(after: index) + } + + func numberOfRemainingParametersRequiringAValue(after parameterIndex: Int) -> Int { + var count = 0 + for nextParameter in parameters.dropFirst(parameterIndex + 1) { + if nextParameter.argumentMatchingKind == .fixed && nextParameter.defaultValue == nil { + count += 1 + } + } + return count + } + + func maxMatchEndIndex( + for parameterIndex: Int, + argumentIndex: Int + ) -> Int { + // Bound the number of arguments this parameter may consume so later + // required fixed parameters can still receive one argument each. + let maxNumberOfArgumentsToMatch = + arguments.distance(from: argumentIndex, to: arguments.endIndex) + - numberOfRemainingParametersRequiringAValue(after: parameterIndex) + return arguments.index(argumentIndex, offsetBy: maxNumberOfArgumentsToMatch) + } + + // Fixed parameters consume at most one argument. + // - Required fixed parameters must have a match within `maxMatchEndIndex`. + // - Defaulted fixed parameters may remain unmatched, and use compatibility + // lookahead to decide whether to consume unlabeled closure-shaped arguments + // now or leave them for later closure parameters. + func matchFixedParameter( + parameterIndex: Int, + parameter: FunctionParameterSyntax, + argumentIndex: inout Int, + matchingArguments: inout [any SyntaxProtocol], + maxMatchEndIndex: Int + ) -> Bool { + if parameter.defaultValue != nil { + // Defaulted parameters are optional. If consuming one argument here would + // starve later required parameters, leave this parameter defaulted. + guard argumentIndex < maxMatchEndIndex else { + return true + } + + let currentArgument = arguments[argumentIndex] + let isUnlabeledTrailingClosure = currentArgument.is(ClosureExprSyntax.self) + let hasAdditionalArguments = arguments.index(after: argumentIndex) < arguments.endIndex + + // Decide whether an unlabeled trailing closure should bind to this + // defaulted closure parameter or be deferred to a later closure + // parameter. This is where we encode most compatibility behavior. + let shouldConsumeUnlabeledTrailingClosure: Bool = { + guard isUnlabeledTrailingClosure, parameter.expectsClosureArgument else { + return false + } + + guard hasLaterClosureParameter(startingAfter: parameterIndex) else { + // No later closure parameter exists, so consume here. + return true + } + + guard hasAdditionalArguments else { + // Keep the trailing closure for a later closure parameter. + return false + } + + let nextArgument = arguments[arguments.index(after: argumentIndex)] + let nextArgumentLabel = nextArgument.labelAsString + + if shouldKeepUnlabeledTrailingClosureForLaterRequiredClosure( + startingAfter: parameterIndex, + nextArgumentLabel: nextArgumentLabel + ) { + return false + } + + return true + }() + + // Regular label-based matching for non-trailing-closure arguments. + let shouldConsumeLabeledArgument = + !isUnlabeledTrailingClosure + && currentArgument.labelAsString == parameter.externalArgumentLabelText + + if shouldConsumeLabeledArgument || shouldConsumeUnlabeledTrailingClosure { + consumeArgument(at: &argumentIndex, into: &matchingArguments) + } + return true + } + + guard argumentIndex < maxMatchEndIndex else { + return false + } + + let expectedLabel = parameter.externalArgumentLabelText + let actualLabel = arguments[argumentIndex].labelAsString + if expectedLabel == actualLabel || arguments[argumentIndex].is(ClosureExprSyntax.self) { + consumeArgument(at: &argumentIndex, into: &matchingArguments) + } + return true + } + + // Variadic parameters consume zero or more arguments. + // - If labeled, the first consumed argument must carry that label (with a + // compatibility exception for unlabeled trailing closures on closure + // variadics). + // - After the first element, we greedily consume unlabeled arguments until + // the bound `maxMatchEndIndex` or a lookahead rule says to keep an + // unlabeled trailing closure for a later closure parameter. + func matchVariadicParameter( + parameterIndex: Int, + parameter: FunctionParameterSyntax, + argumentIndex: inout Int, + matchingArguments: inout [any SyntaxProtocol], + maxMatchEndIndex: Int + ) -> Bool { + guard argumentIndex < maxMatchEndIndex else { + return true + } + + if let expectedLabel = parameter.externalArgumentLabelText { + if arguments[argumentIndex].labelAsString == expectedLabel { + // The label matches, so we consume this argument and then continue with the unlabeled greedy matching below. + consumeArgument(at: &argumentIndex, into: &matchingArguments) + } else if parameter.expectsClosureArgument, + arguments[argumentIndex].is(ClosureExprSyntax.self), + parameterIndex == parameters.count - 1 || arguments.index(after: argumentIndex) < arguments.endIndex + { + let nextIndex = arguments.index(after: argumentIndex) + let nextArgumentLabel = nextIndex < arguments.endIndex ? arguments[nextIndex].labelAsString : nil + if shouldKeepUnlabeledTrailingClosureForLaterRequiredClosure( + startingAfter: parameterIndex, + nextArgumentLabel: nextArgumentLabel + ) { + return true + } + + consumeArgument(at: &argumentIndex, into: &matchingArguments) + } else { + // The label does not match, and it's not an unlabeled trailing closure, so we don't consume any arguments. + return true + } + } + + let expectsClosures = parameter.expectsClosureArgument + + // Greedily consume unlabeled arguments for variadics. + // For closure variadics, we add lookahead checks so unlabeled trailing + // closures can be preserved for later closure parameters when needed. + while argumentIndex < maxMatchEndIndex && arguments[argumentIndex].labelAsString == nil { + if expectsClosures + && arguments[argumentIndex].is(ClosureExprSyntax.self) + { + let nextIndex = arguments.index(after: argumentIndex) + + if nextIndex < arguments.endIndex { + let nextArgumentLabel = arguments[nextIndex].labelAsString + if shouldKeepUnlabeledTrailingClosureForLaterRequiredClosure( + startingAfter: parameterIndex, + nextArgumentLabel: nextArgumentLabel + ) { + break + } + } + + if hasLaterClosureParameter(startingAfter: parameterIndex) + && nextIndex == arguments.endIndex + { + break + } + } + if !expectsClosures && arguments[argumentIndex].is(ClosureExprSyntax.self) { + // Non-closure variadics should not consume trailing closures + break + } + consumeArgument(at: &argumentIndex, into: &matchingArguments) + } + + return true + } + + // Pack parameters follow the same greedy unlabeled consumption strategy as variadics. + // However, as they don't have to deal with trailing closures their matching logic is simpler. + func matchPackParameter( + parameter: FunctionParameterSyntax, + argumentIndex: inout Int, + matchingArguments: inout [any SyntaxProtocol], + maxMatchEndIndex: Int + ) { + if let expectedLabel = parameter.externalArgumentLabelText { + guard argumentIndex < maxMatchEndIndex else { + return + } + guard arguments[argumentIndex].labelAsString == expectedLabel else { + return + } + + consumeArgument(at: &argumentIndex, into: &matchingArguments) + } + + while argumentIndex < maxMatchEndIndex && arguments[argumentIndex].labelAsString == nil { + consumeArgument(at: &argumentIndex, into: &matchingArguments) + } + } + + var argumentIndex = arguments.startIndex + var parameterMatches: [ArgumentMatchResult.ParameterMatch] = [] + + for (parameterIndex, parameter) in parameters.enumerated() { + var matchingArguments: [any SyntaxProtocol] = [] + let maxMatchEndIndex = maxMatchEndIndex(for: parameterIndex, argumentIndex: argumentIndex) + + switch parameter.argumentMatchingKind { + case .fixed: + guard + matchFixedParameter( + parameterIndex: parameterIndex, + parameter: parameter, + argumentIndex: &argumentIndex, + matchingArguments: &matchingArguments, + maxMatchEndIndex: maxMatchEndIndex + ) + else { + return nil + } + + case .variadic: + guard + matchVariadicParameter( + parameterIndex: parameterIndex, + parameter: parameter, + argumentIndex: &argumentIndex, + matchingArguments: &matchingArguments, + maxMatchEndIndex: maxMatchEndIndex + ) + else { + return nil + } + + case .pack: + matchPackParameter( + parameter: parameter, + argumentIndex: &argumentIndex, + matchingArguments: &matchingArguments, + maxMatchEndIndex: maxMatchEndIndex + ) + } + + let parameterMatch = ArgumentMatchResult.ParameterMatch( + parameter: parameter, + arguments: matchingArguments + ) + parameterMatches.append(parameterMatch) + } + + if argumentIndex < arguments.endIndex { + // If there are unmatched arguments remaining after processing all parameters, the match fails. + return nil + } + + return ArgumentMatchResult(matches: parameterMatches) +} + +extension SyntaxProtocol { + fileprivate var labelAsString: String? { + // Labels can come from regular arguments (`foo(a: 1)`) or additional + // trailing closures (`foo {} a: {}`), so we normalize both here. + if let labeledExpr = self.as(LabeledExprSyntax.self) { + return labeledExpr.label?.text + } else if let multipleTrailingClosure = self.as(MultipleTrailingClosureElementSyntax.self) { + return multipleTrailingClosure.label.text + } else { + return nil + } + } +} + +extension FunctionParameterSyntax { + fileprivate var externalArgumentLabelText: String? { + guard firstName.tokenKind != .wildcard else { + return nil + } + return firstName.text + } + + fileprivate var parameterName: String? { + if let secondName { + return secondName.text + } + return firstName.text + } + + fileprivate var argumentMatchingKind: ArgumentMatchResult.ParameterKind { + if type.is(PackExpansionTypeSyntax.self) { + return .pack + } + if ellipsis != nil { + return .variadic + } + return .fixed + } + + fileprivate var expectsClosureArgument: Bool { + // Syntax-only check for whether a parameter's type can accept closure + // expressions. This deliberately handles wrappers used in source + // (`(@Sendable () -> Void)?`, `( () -> Void )`, etc.). + func containsFunctionType(_ type: TypeSyntax) -> Bool { + if type.is(FunctionTypeSyntax.self) { + return true + } + if let attributed = type.as(AttributedTypeSyntax.self) { + return containsFunctionType(attributed.baseType) + } + if let someOrAny = type.as(SomeOrAnyTypeSyntax.self) { + return containsFunctionType(someOrAny.constraint) + } + if let optional = type.as(OptionalTypeSyntax.self) { + return containsFunctionType(optional.wrappedType) + } + if let implicitlyUnwrapped = type.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) { + return containsFunctionType(implicitlyUnwrapped.wrappedType) + } + if let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1 { + return containsFunctionType(tuple.elements.first!.type) + } + return false + } + + return containsFunctionType(type) + } +} diff --git a/Sources/SwiftRefactor/CMakeLists.txt b/Sources/SwiftRefactor/CMakeLists.txt index 603b10c2ee6..a8cb2c0747f 100644 --- a/Sources/SwiftRefactor/CMakeLists.txt +++ b/Sources/SwiftRefactor/CMakeLists.txt @@ -8,6 +8,7 @@ add_swift_syntax_library(SwiftRefactor AddSeparatorsToIntegerLiteral.swift + ArgumentMatching.swift CallLikeSyntax.swift CallToTrailingClosures.swift ConvertComputedPropertyToStored.swift diff --git a/Tests/SwiftRefactorTest/ArgumentMatchingTests.swift b/Tests/SwiftRefactorTest/ArgumentMatchingTests.swift new file mode 100644 index 00000000000..dfd2bfff2c9 --- /dev/null +++ b/Tests/SwiftRefactorTest/ArgumentMatchingTests.swift @@ -0,0 +1,563 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftParser +import SwiftRefactor +import SwiftSyntax +import SwiftSyntaxBuilder +import XCTest + +final class ArgumentMatchingTests: XCTestCase { + + func testStringBasedParsingHelper() throws { + let result = try matchResult( + declaration: "func foo(_ x: Int, y: Int) {}", + call: "foo(1, y: 2)" + ) + + assertMatch(result, named: "x", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "y", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testDefaultLabeledParameterAndDefaultNonLabeledParameter() throws { + let result = try matchResult( + declaration: "func decl_closure_22(a p0: () -> Int = { -1000 }, _ p1: () -> Int = { -1001 }) {}", + call: "decl_closure_22({ 1000 })" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 0) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testSingleTrailingClosure() throws { + let result = try matchResult( + declaration: "func foo(x: Int, body: () -> Void) {}", + call: "foo(x: 1) {}" + ) + + assertMatch(result, named: "x", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "body", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testMultipleTrailingClosures() throws { + let result = try matchResult( + declaration: "func foo(x: Int, first: () -> Void, second: () -> Void, third: () -> Void) {}", + call: "foo(x: 1) {} second: {} third: {}" + ) + + assertMatch(result, named: "x", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "first", expectedIndex: 1, expectedArgumentCount: 1) + assertMatch(result, named: "second", expectedIndex: 2, expectedArgumentCount: 1) + assertMatch(result, named: "third", expectedIndex: 3, expectedArgumentCount: 1) + } + + func testTwoDefaultClosureParameters() throws { + let result = try matchResult( + declaration: + #"func doubleTask(first: () -> Void = { print("Default 1") }, second: () -> Void = { print("Default 2") }) {}"#, + call: #"doubleTask { print("Hello, World!") }"# + ) + + assertMatch(result, named: "first", expectedIndex: 0, expectedArgumentCount: 0) + assertMatch(result, named: "second", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testTwoDefaultClosureParametersWithSameLabel() throws { + let result = try matchResult( + declaration: "func decl_closure_16(a p0: () -> Int = { -1000 }, a p1: () -> Int = { -1001 } ) {}", + call: "decl_closure_16() { 1000 } a: { 1001 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testTrailingClosureWithDefaultedLeadingParameter() throws { + let result = try matchResult( + declaration: "func foo(x: Int = 0, body: () -> Void) {}", + call: "foo {}" + ) + + assertMatch(result, named: "x", expectedIndex: 0, expectedArgumentCount: 0) + assertMatch(result, named: "body", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testTrailingClosureWithVariadicLeadingParameter() throws { + let result = try matchResult( + declaration: "func foo(values: Int..., body: () -> Void) {}", + call: "foo(values: 1, 2, 3) {}" + ) + + assertMatch(result, named: "values", expectedIndex: 0, expectedArgumentCount: 3) + assertMatch(result, named: "body", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testDefaultLabeledClosureAndUnlabeledVariadicWithTrailingClosure() throws { + let result = try matchResult( + declaration: "func decl_closure_23(a p0: () -> Int = { -1000 }, _ p1: (() -> Int)...) {}", + call: "decl_closure_23({ 1000 }) { 1001 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 0) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 2) + } + + func testMultipleTrailingClosuresWithDefaultAndVariadicParameters() throws { + let result = try matchResult( + declaration: "func foo(x: Int = 0, values: Int..., first: () -> Void, second: () -> Void) {}", + call: "foo(values: 1, 2) {} second: {}" + ) + + assertMatch(result, named: "x", expectedIndex: 0, expectedArgumentCount: 0) + assertMatch(result, named: "values", expectedIndex: 1, expectedArgumentCount: 2) + assertMatch(result, named: "first", expectedIndex: 2, expectedArgumentCount: 1) + assertMatch(result, named: "second", expectedIndex: 3, expectedArgumentCount: 1) + } + + func testClosuresWithDefaultParameter() throws { + let firstResult = try matchResult( + declaration: #"func test(x: () -> Void, y: () -> Void = { print("y") }, z: () -> Void) {}"#, + call: #"test(x: {}) {} z: {}"# + ) + + assertMatch(firstResult, named: "x", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(firstResult, named: "y", expectedIndex: 1, expectedArgumentCount: 1) + assertMatch(firstResult, named: "z", expectedIndex: 2, expectedArgumentCount: 1) + + let secondResult = try matchResult( + declaration: #"func test(x: () -> Void, y: () -> Void = { print("y") }, z: () -> Void) {}"#, + call: "test() {} z: {}" + ) + + assertMatch(secondResult, named: "x", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(secondResult, named: "y", expectedIndex: 1, expectedArgumentCount: 0) + assertMatch(secondResult, named: "z", expectedIndex: 2, expectedArgumentCount: 1) + } + + func testVariadicClosureParameterWithSingleTrailingClosure() throws { + let result = try matchResult( + declaration: "func test(x: () -> Void...) {}", + call: "test {}" + ) + + assertMatch(result, named: "x", expectedIndex: 0, expectedArgumentCount: 1) + } + + func testTrailingClosureWithVariadicParameter() throws { + let result = try matchResult( + declaration: "func test(x: () -> Void..., y: () -> Void) {}", + call: "test(x: {}, {}) {}" + ) + + assertMatch(result, named: "x", expectedIndex: 0, expectedArgumentCount: 2) + assertMatch(result, named: "y", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testTrailingClosureWithVariadicParameterAndDefaultedParameter() throws { + let result = try matchResult( + declaration: "func test(x: () -> Void..., y: () -> Void = {}) {}", + call: "test {}" + ) + + assertMatch(result, named: "x", expectedIndex: 0, expectedArgumentCount: 0) + assertMatch(result, named: "y", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testTrailingClosuresWithVariadicParametersSpreadAcrossTrailingClosure() throws { + let result = try matchResult( + declaration: "func test(x: () -> Void..., b: () -> Void) {}", + call: "test(x: {}) {} b: {}" + ) + + assertMatch(result, named: "x", expectedIndex: 0, expectedArgumentCount: 2) + assertMatch(result, named: "b", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testTrailingClosureWithDefaultClosureParameter() throws { + let result = try matchResult( + declaration: "func test(x: () -> Void, y: () -> Void = {}) {}", + call: "test {}" + ) + + assertMatch(result, named: "x", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "y", expectedIndex: 1, expectedArgumentCount: 0) + } + + func testMultipleTrailingClosuresWithVariadicAndIdentificalLabels() throws { + let result = try matchResult( + declaration: "func test(a p0:() -> Int...,a p1:() -> Int)", + call: "test() { 1000 } a: { 1001 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testMultipleTrailingClosuresWithDefaultValueAndIdentificalLabels() throws { + let result = try matchResult( + declaration: "func test(a p0:() -> Int...,a p1:() -> Int = {-1001})", + call: "test() { 1000 } a: { 1001 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testDefaultedAndRequiredParameterWithDuplicateLabels() throws { + let result = try matchResult( + declaration: "func test(a p0: Int = -1000, b p1: Int = -1001, a p2: Int) {}", + call: "test(a: 1000, b: 1001, a: 1002)" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 1) + } + + func testLabeledPackFollowedByRequiredParameter() throws { + let result = try matchResult( + declaration: "func test(b p0: repeat each T, c p1: Int) where repeat each T: BinaryInteger {}", + call: "test(b: 1, 2, c: 3)" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 2) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testUnlabeledPackFollowedByLabeledParameter() throws { + let result = try matchResult( + declaration: "func test(_ p0: repeat each T, c p1: Int) where repeat each T: BinaryInteger {}", + call: "test(1, 2, c: 3)" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 2) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testTwoPacksWithDistinctLabels() throws { + let result = try matchResult( + declaration: + "func test(a p0: repeat each T0, b p1: repeat each T1) where repeat each T0: BinaryInteger, repeat each T1: BinaryInteger {}", + call: "test(a: 1, 2, b: 3, 4)" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 2) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 2) + } + + func testPackContainingParenthesizedClosuresFollowedByClosureParameter() throws { + let result = try matchResult( + declaration: "func test(a p0: repeat each T, b p1: () -> Int) {}", + call: "test(a: ({ 1000 }), ({ 1001 }), b: ({ 1002 }))" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 2) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testTwoPacksContainingParenthesizedClosuresAndClosureParameter() throws { + let result = try matchResult( + declaration: + "func test(a p0: repeat each T0, b p1: repeat each T1, c p2: () -> Int) {}", + call: "test(a: ({ 1000 }), ({ 1001 }), b: ({ 1002 }), ({ 1003 }), c: ({ 1004 }))" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 2) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 2) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 1) + } + + func testPackDoesNotConsumeFinalTrailingClosureNeededByLaterClosureParameter() throws { + let result = try matchResult( + declaration: "func test(a p0: repeat each T, b p1: () -> Int) {}", + call: "test(a: ({ 1000 }), ({ 1001 })) { 1002 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 2) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testTrailingClosuresAroundVariadicClosureParameters() throws { + let result = try matchResult( + declaration: "func test(_ p0: (() -> Int)..., c p1: () -> Int, _ p2: (() -> Int)...) {}", + call: "test() { 1000 } c: { 1001 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 0) + } + + func testVariadicClosureAndDefaultedClosureWithSameLabel() throws { + let result = try matchResult( + declaration: "func test(a p0: (() -> Int)..., a p1: () -> Int = { -1001 }) {}", + call: "test(a: { 1000 }) { 1001 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testStackedVariadicClosuresWithDefaultedClosureAndTrailingClosures() throws { + let result = try matchResult( + declaration: "func test(a p0: () -> Int..., a p1: () -> Int..., a p2: () -> Int = { -1002 }) {}", + call: "test(a: { 1000 }, a: { 1002 }) { 1001 } a: { 1003 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 2) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 1) + } + + func testStackedVariadicClosuresWithSingleTrailingClosure() throws { + let result = try matchResult( + declaration: "func test(a p0: () -> Int..., a p1: () -> Int...) {}", + call: "test(a: { 1000 }) { 1001 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testUnlabeledClosureParameterAndDefaultClosureParameter() throws { + let result = try matchResult( + declaration: "func decl_closure_40(_ p0: () -> Int = { -1000 }, b p1: () -> Int = { -1001 }) {}", + call: "decl_closure_40() { 1000 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 0) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testRequiredClosureParameterBetweenDefaultedClosureParameters() throws { + let result = try matchResult( + declaration: + "func decl_closure_130(a p0: () -> Int = { -1000 }, a p1: () -> Int, b p2: () -> Int = { -1002 }) {}", + call: "decl_closure_130() { 1000 } b: { 1001 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 0) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 1) + } + + func testTwoDefaultClosuresFollowedByRequiredClosureCalledWithTwoClosures() throws { + let result = try matchResult( + declaration: + "func decl_closure_138(a p0: () -> Int = { -1000 }, a p1: () -> Int = { -1001 }, b p2: () -> Int) {}", + call: "decl_closure_138() { 1000 } b: { 1001 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 0) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 1) + } + + func testTwoDefaultClosuresFollowedByRequiredClosureCalledWithThreeClosures() throws { + let result = try matchResult( + declaration: + "func decl_closure_138(a p0: () -> Int = { -1000 }, a p1: () -> Int = { -1001 }, b p2: () -> Int) {}", + call: "decl_closure_138() { 1000 } a: { 1001 } b: { 1002 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 1) + } + + func testDefaultClosureFollowedByTwoRequiredClosures() throws { + let result = try matchResult( + declaration: "func decl_closure_129(a p0: () -> Int = { -1000 }, a p1: () -> Int, b p2: () -> Int) {}", + call: "decl_closure_129() { 1000 } a: { 1001 } b: { 1002 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 1) + } + + func testTwoDefaultClosuresFollowedByUnlabeledVariadic() throws { + let result = try matchResult( + declaration: + "func decl_closure_143(a p0: () -> Int = { -1000 }, a p1: () -> Int = { -1001 }, _ p2: (() -> Int)...) {}", + call: "decl_closure_143(({ 1000 })) { 1001 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 0) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 0) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 2) + } + + func testVariadicClosureFollowedByRequiredClosureFollowedByDefaultClosure() throws { + let result = try matchResult( + declaration: "func decl_closure_202(a p0: (() -> Int)..., a p1: () -> Int, b p2: () -> Int = { -1002 }) {}", + call: "decl_closure_202() { 1000 } b: { 1001 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 0) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 1) + } + + func testUnlabeledVariadicClosureFollowedByRequiredClosureFollowedByDefaultClosure() throws { + let result = try matchResult( + declaration: "func decl_closure_394(_ p0: () -> Int..., b p1: () -> Int, c p2: () -> Int = { -1002 }) {}", + call: "decl_closure_394() { 1000 } c: { 1001 }" + ) + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 0) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 1) + } + + func testParenthesizedClosureArgumentMatchesLabeledClosureParameter() throws { + let result = try matchResult( + declaration: "func decl_closure_parenthesized_1(a p0: () -> Int, b p1: () -> Int) {}", + call: "decl_closure_parenthesized_1(a: ({ 1000 }), b: ({ 1001 }))" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testParenthesizedClosureArgumentMatchesDefaultedClosureParameter() throws { + let result = try matchResult( + declaration: "func decl_closure_parenthesized_2(a p0: () -> Int = { -1000 }, b p1: () -> Int) {}", + call: "decl_closure_parenthesized_2(a: ({ 1000 }), b: ({ 1001 }))" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + } + + func testVariadicClosureFollowedByDefaultClosureFollowedByRequiredClosure() throws { + let result = try matchResult( + declaration: "func decl_closure_210(a p0: () -> Int..., a p1: () -> Int = { -1001 }, b p2: () -> Int) {}", + call: "decl_closure_210() { 1000 } a: { 1001 } b: { 1002 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 1) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 1) + } + + func testPackParametersWithVariadics() throws { + let result = try matchResult( + declaration: + "func decl_8012(_ p0: Int..., c p1: repeat each T1, c p2: Int..., c p3: Int) where repeat each T1: BinaryInteger {}", + call: "decl_8012(1000, c: 1001, 1002, c: 1003, 1004, c: 1005)" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 2) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 2) + assertMatch(result, named: "p3", expectedIndex: 3, expectedArgumentCount: 1) + } + + func testTwoPacksWithDefaultAndRequiredClosures() throws { + let result = try matchResult( + declaration: + "func test(a p0: repeat each T0, a p1: repeat each T1, b p2: () -> Int = { -1002 }, c p3: () -> Int) {}", + call: "test(a: ({1000}), ({1001}), a: ({1002}), ({1003}), c: ({1004}))" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 2) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 2) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 0) + assertMatch(result, named: "p3", expectedIndex: 3, expectedArgumentCount: 1) + } + + func testTwoPacksWithDefaultClosuresAndExplicitLabeledClosure() throws { + let result = try matchResult( + declaration: + "func test(a p0: repeat each T0, b p1: () -> Int = { -1001 }, c p2: repeat each T1, d p3: () -> Int = { -1003 }) {}", + call: "test(a: ({1000}), ({1001}), c: ({1002}), ({1003}), d: ({1004}))" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 2) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 0) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 2) + assertMatch(result, named: "p3", expectedIndex: 3, expectedArgumentCount: 1) + } + + func testTwoPacksWithDefaultClosureAndTrailingClosure() throws { + let result = try matchResult( + declaration: + "func test(a p0: repeat each T0, b p1: () -> Int = { -1001 }, c p2: repeat each T1, d p3: () -> Int) {}", + call: "test(a: ({1000}), c: ({1001})) { 1002 }" + ) + + assertMatch(result, named: "p0", expectedIndex: 0, expectedArgumentCount: 1) + assertMatch(result, named: "p1", expectedIndex: 1, expectedArgumentCount: 0) + assertMatch(result, named: "p2", expectedIndex: 2, expectedArgumentCount: 1) + assertMatch(result, named: "p3", expectedIndex: 3, expectedArgumentCount: 1) + } + + private func matchResult( + declaration: String, + call: String, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> ArgumentMatchResult { + let declarationTree = Parser.parse(source: declaration) + let functionDeclaration = try XCTUnwrap( + declarationTree.statements.first?.item.as(FunctionDeclSyntax.self), + "Expected a function declaration", + file: file, + line: line + ) + + let callTree = Parser.parse(source: call) + let functionCall = try XCTUnwrap( + callTree.statements.first?.item.as(FunctionCallExprSyntax.self), + "Expected a function call expression", + file: file, + line: line + ) + + return try XCTUnwrap( + functionCall.matchArguments(to: functionDeclaration.signature.parameterClause.parameters), + "Expected a match result", + file: file, + line: line + ) + } + + private func assertMatch( + _ matchResult: ArgumentMatchResult, + named name: String, + expectedIndex: Int, + expectedArgumentCount: Int, + file: StaticString = #filePath, + line: UInt = #line + ) { + let match = matchResult.matchForParameter(named: name) + let indexMatch = matchResult.matchForParameter(at: expectedIndex) + XCTAssertNotNil(match, "Expected a match for parameter '\(name)'", file: file, line: line) + XCTAssertEqual( + match?.parameter, + indexMatch?.parameter, + "Expected the match for parameter '\(name)' to be at index \(expectedIndex)", + file: file, + line: line + ) + XCTAssertEqual(match?.arguments.count, expectedArgumentCount, file: file, line: line) + } +} + +private extension FunctionParameterSyntax { + var externalArgumentLabelText: String? { + guard firstName.tokenKind != .wildcard else { + return nil + } + return firstName.text + } +} From cd0fae05280a6a17053f7994dcaf7064f5649cbc Mon Sep 17 00:00:00 2001 From: Steffeeen Date: Wed, 22 Apr 2026 19:53:48 +0200 Subject: [PATCH 2/2] TEMP: Implement exhaustive tests for ArgumentMatching --- .../ArgumentMatchingExhaustiveTests.swift | 1089 +++++++++++++++++ 1 file changed, 1089 insertions(+) create mode 100644 Tests/SwiftRefactorTest/ArgumentMatchingExhaustiveTests.swift diff --git a/Tests/SwiftRefactorTest/ArgumentMatchingExhaustiveTests.swift b/Tests/SwiftRefactorTest/ArgumentMatchingExhaustiveTests.swift new file mode 100644 index 00000000000..a8aaf892cb9 --- /dev/null +++ b/Tests/SwiftRefactorTest/ArgumentMatchingExhaustiveTests.swift @@ -0,0 +1,1089 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +import SwiftParser +import SwiftSyntax +import XCTest + +final class ArgumentMatchingExhaustiveTests: XCTestCase { + func testExhaustiveDifferentialAgainstCompiler() async throws { + guard ProcessInfo.processInfo.environment["ARG_MATCH_EXHAUSTIVE"] == "1" else { + throw XCTSkip("Set ARG_MATCH_EXHAUSTIVE=1 to run exhaustive argument matching differential tests") + } + + // the maximum number of parameters the generated functions can have. + let maxParameters = 4 + assert(maxParameters <= 6, "Exhaustive argument matching tests can grow very quickly with the number of parameters") + // the maximum number of arguments that will be generated for variadic parameters. + let maxVariadicCount = 2 + let includePacks = ProcessInfo.processInfo.environment["ARG_MATCH_EXHAUSTIVE_INCLUDE_PACKS"] != "0" + let includeTrailingClosureDeclarations = + ProcessInfo.processInfo.environment["ARG_MATCH_EXHAUSTIVE_INCLUDE_TRAILING_DECLS"] != "0" + let includeClosureDeclarations = + ProcessInfo.processInfo.environment["ARG_MATCH_EXHAUSTIVE_INCLUDE_CLOSURE_DECLS"] != "0" + let workerCount = min( + ProcessInfo.processInfo.activeProcessorCount, + max(1, ProcessInfo.processInfo.environment["ARG_MATCH_EXHAUSTIVE_WORKERS"].flatMap(Int.init) ?? Int.max) + ) + + var declarationAndCalls = generateTestData( + maxParameters: maxParameters, + maxVariadicCount: maxVariadicCount, + includePacks: includePacks, + includeTrailingClosureDeclarations: includeTrailingClosureDeclarations, + includeClosureDeclarations: includeClosureDeclarations + ) + + print("Generated \(declarationAndCalls.count) declarations with calls to test.") + + print("Running first compile in parallel with \(workerCount) workers...") + let firstPassChunks = chunked(declarationAndCalls, chunkCount: workerCount) + let firstPassResults = try await withThrowingTaskGroup( + of: (Int, [(ExhaustiveDeclaration, [ExhaustiveCall], lineRange: Range)]).self + ) { group in + for (chunkIndex, chunk) in firstPassChunks.enumerated() { + group.addTask { + var localChunk = withRecalculatedLineRanges(chunk) + let source = localChunk.map { (declaration, calls, _) in + declaration.executableSourceWithoutBody(for: calls) + "\n" + }.joined(separator: "\n") + + let runResult = try checkForErrors(source: source) + if runResult.exitCode != 0 { + try removeInvalidCalls(in: &localChunk, stderr: runResult.stderr) + } + + return (chunkIndex, localChunk) + } + } + + var collected: [(Int, [(ExhaustiveDeclaration, [ExhaustiveCall], lineRange: Range)])] = [] + for try await result in group { + collected.append(result) + } + return collected.sorted { $0.0 < $1.0 }.map { $0.1 } + } + declarationAndCalls = firstPassResults.flatMap { $0 } + + print("Running second compile in parallel...") + + let secondPassChunks = chunked(declarationAndCalls, chunkCount: workerCount) + let secondPassResults = try await withThrowingTaskGroup(of: (Int, ExhaustiveProcessResult).self) { group in + for (chunkIndex, chunk) in secondPassChunks.enumerated() { + group.addTask { + let source = chunk.map { (declaration, calls, _) in + declaration.executableSource(for: calls) + }.joined(separator: "\n") + return (chunkIndex, try compileAndRun(source: source)) + } + } + + var collected: [(Int, ExhaustiveProcessResult)] = [] + for try await result in group { + collected.append(result) + } + return collected.sorted { $0.0 < $1.0 }.map { $0.1 } + } + + if let firstFailed = secondPassResults.first(where: { $0.exitCode != 0 }) { + XCTFail("Could not compile one of the generated chunks:\n\(firstFailed.stderr)") + return + } + + print("Extracting mappings from compiler output...") + let compilerMappings = try secondPassResults.flatMap { try compilerMapping(from: $0.stdout) } + let syntaxMappings = try declarationAndCalls.flatMap { (declaration, calls, _) in + let declarationTree = Parser.parse(source: declaration.declarationWithoutBody) + let functionDeclaration = try XCTUnwrap( + declarationTree.statements.first?.item.as(FunctionDeclSyntax.self), + "Expected generated declaration to parse as FunctionDeclSyntax" + ) + return try calls.map { call in + try getSyntaxMapping(declaration: functionDeclaration, call: call.callString) + } + } + + print("Comparing mappings...") + + let compilerByID = Dictionary(grouping: compilerMappings, by: \.id) + let syntaxByID = Dictionary(grouping: syntaxMappings, by: \.id) + + for declaration in declarationAndCalls.map(\.0) { + let id = declaration.id + let compilerForID = compilerByID[id] ?? [] + let syntaxForID = syntaxByID[id] ?? [] + + if compilerForID.count != syntaxForID.count { + XCTFail( + "Count mismatch for \(declaration.declarationWithoutBody): compiler=\(compilerForID.count), syntax=\(syntaxForID.count)" + ) + } + + for index in compilerForID.indices { + if compilerForID[index].mapping != syntaxForID[index].mapping { + let call = syntaxForID[index].call ?? "" + XCTFail( + """ + Mismatch for declaration \(declaration.declarationWithoutBody) + with call \(call) + Compiler: \(compilerForID[index].mapping) + Swift-Syntax: \(syntaxForID[index].mapping) + """ + ) + } + } + } + + print("Verified \(compilerMappings.count) matching mappings") + } +} + +private func generateTestData( + maxParameters: Int, + maxVariadicCount: Int, + includePacks: Bool, + includeTrailingClosureDeclarations: Bool, + includeClosureDeclarations: Bool +) -> [(ExhaustiveDeclaration, [ExhaustiveCall], lineRange: Range)] { + let labels = Array(["a", "b", "c", "d", "e", "f"].prefix(max(1, maxParameters))) + var declarations = ExhaustiveGenerator.generateDeclarations( + maxParameters: maxParameters, + labels: labels, + includePacks: includePacks + ) + if includeTrailingClosureDeclarations { + declarations += ExhaustiveGenerator.generateTrailingClosureDeclarations( + maxParameters: maxParameters, + labels: labels + ) + } + if includeClosureDeclarations { + declarations += ExhaustiveGenerator.generateClosureDeclarations(maxParameters: maxParameters, labels: labels) + } + + var lineOffsetInFile = 1 // 1 for being 1-indexed as the compiler error messages are + var declarationAndCalls: [(ExhaustiveDeclaration, [ExhaustiveCall], lineRange: Range)] = [] + for declaration in declarations { + let calls = ExhaustiveGenerator.generateCalls(for: declaration, maxVariadicCount: maxVariadicCount) + + let range = lineOffsetInFile..<(lineOffsetInFile + 1 + calls.count) + declarationAndCalls.append((declaration, calls, lineRange: range)) + lineOffsetInFile += 1 // for the declaration + lineOffsetInFile += calls.count + lineOffsetInFile += 1 // for the empty line between declarations + } + + return declarationAndCalls +} + +private func chunked(_ array: [T], chunkCount: Int) -> [[T]] { + guard !array.isEmpty else { return [] } + let safeChunkCount = max(1, min(chunkCount, array.count)) + let baseSize = array.count / safeChunkCount + let remainder = array.count % safeChunkCount + + var chunks: [[T]] = [] + chunks.reserveCapacity(safeChunkCount) + + var start = array.startIndex + for chunkIndex in 0..)] +) -> [(ExhaustiveDeclaration, [ExhaustiveCall], lineRange: Range)] { + var lineOffset = 1 + var result: [(ExhaustiveDeclaration, [ExhaustiveCall], lineRange: Range)] = [] + result.reserveCapacity(declarations.count) + + for (declaration, calls, _) in declarations { + let range = lineOffset..<(lineOffset + 1 + calls.count) + result.append((declaration, calls, lineRange: range)) + lineOffset += 1 + lineOffset += calls.count + lineOffset += 1 + } + + return result +} + +private func removeInvalidCalls( + in declarationAndCalls: inout [(ExhaustiveDeclaration, [ExhaustiveCall], lineRange: Range)], + stderr: String +) throws { + guard !declarationAndCalls.isEmpty else { + return + } + + var declarationIndex = declarationAndCalls.startIndex + let regex = try Regex("Case\\.swift:(\\d+):\\d+: error:", as: (Substring, Substring).self) + var offset = 0 + var processedLineNumbers = Set() + + for error in stderr.split(separator: "\n\n") { + guard let match = error.firstMatch(of: regex) else { + continue + } + let lineNumber = Int(match.1)! + + if processedLineNumbers.contains(lineNumber) { + continue + } + processedLineNumbers.insert(lineNumber) + + while declarationIndex < declarationAndCalls.endIndex, + declarationAndCalls[declarationIndex].2.upperBound <= lineNumber + { + declarationIndex = declarationAndCalls.index(after: declarationIndex) + offset = 0 + } + + guard declarationIndex < declarationAndCalls.endIndex else { + continue + } + + let callsOffset = lineNumber - declarationAndCalls[declarationIndex].2.lowerBound - 1 + let index = callsOffset - offset + + if index < 0 || index >= declarationAndCalls[declarationIndex].1.count { + continue + } + + declarationAndCalls[declarationIndex].1.remove(at: index) + offset += 1 + } +} + +struct Mapping { + let id: String + let call: String? + let mapping: [[Int]] +} + +private func getSyntaxMapping(declaration: FunctionDeclSyntax, call: String) throws -> Mapping { + let callTree = Parser.parse(source: call) + let functionCall = try XCTUnwrap( + callTree.statements.first?.item.as(FunctionCallExprSyntax.self), + "Expected generated call to parse as FunctionCallExprSyntax" + ) + + let parameters = declaration.signature.parameterClause.parameters + guard let result = functionCall.matchArguments(to: parameters) else { + print("Failed to match arguments for call: \(call) to declaration: \(declaration.description)") + return Mapping(id: declaration.name.text, call: call, mapping: []) + } + + let m: [[Int]] = try parameters.map { parameter in + let arguments = try XCTUnwrap(result.match(for: parameter).arguments) + return arguments.compactMap { argument in + if let labeledExpr = argument.as(LabeledExprSyntax.self) { + if let value = Int(labeledExpr.expression.trimmedDescription) { + return value + } + if let tupleExpr = labeledExpr.expression.as(TupleExprSyntax.self), + let closureExpr = tupleExpr.elements.first?.expression.as(ClosureExprSyntax.self) + { + return trailingClosureValue(closureExpr) + } + return nil + } + if let closureExpr = argument.as(ClosureExprSyntax.self) { + return trailingClosureValue(closureExpr) + } + if let trailingClosure = argument.as(MultipleTrailingClosureElementSyntax.self) { + return trailingClosureValue(trailingClosure.closure) + } + return nil + } + } + return Mapping(id: declaration.name.text, call: call, mapping: m) +} + +private func trailingClosureValue(_ closure: ClosureExprSyntax) -> Int? { + guard + let integerExpr = closure.statements.first?.item.as(IntegerLiteralExprSyntax.self) + else { + return nil + } + return Int(integerExpr.literal.text) +} + +private func compilerMapping(from stdout: String) throws -> [Mapping] { + var mappings: [Mapping] = [] + for line in stdout.split(separator: "\n") { + // MATCH|id|mapping + let components = line.split(separator: "|", maxSplits: 2, omittingEmptySubsequences: false) + + let id = String(components[1]) + let m: [[Int]] = components[2].split(separator: ";", omittingEmptySubsequences: false).map { segment in + if segment.isEmpty { + return [] + } + return segment.split(separator: ",").compactMap { Int($0) } + } + mappings.append(Mapping(id: id, call: nil, mapping: m)) + } + return mappings +} + +private func compileAndRun(source: String) throws -> ExhaustiveProcessResult { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "arg-match-exhaustive-compile-and-run-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let sourceFile = directory.appendingPathComponent("Case.swift") + let executable = directory.appendingPathComponent("CaseExec") + try source.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compileResult = try runProcess( + executable: URL(fileURLWithPath: "/usr/bin/env"), + arguments: ["swiftc", sourceFile.path, "-o", executable.path] + ) + guard compileResult.exitCode == 0 else { + return compileResult + } + + return try runProcess(executable: executable, arguments: []) +} + +private func checkForErrors(source: String) throws -> ExhaustiveProcessResult { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "arg-match-exhaustive-typecheck-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let sourceFile = directory.appendingPathComponent("Case.swift") + try source.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compileResult = try runProcess( + executable: URL(fileURLWithPath: "/usr/bin/env"), + arguments: ["swiftc", "-typecheck", sourceFile.path] + ) + + return compileResult +} + +private func runProcess(executable: URL, arguments: [String]) throws -> ExhaustiveProcessResult { + let process = Process() + process.executableURL = executable + process.arguments = arguments + + let outputDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( + "arg-match-exhaustive-process-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: outputDirectory) } + + let stdoutFile = outputDirectory.appendingPathComponent("stdout.txt") + let stderrFile = outputDirectory.appendingPathComponent("stderr.txt") + FileManager.default.createFile(atPath: stdoutFile.path, contents: nil) + FileManager.default.createFile(atPath: stderrFile.path, contents: nil) + + let stdoutHandle = try FileHandle(forWritingTo: stdoutFile) + let stderrHandle = try FileHandle(forWritingTo: stderrFile) + defer { + try? stdoutHandle.close() + try? stderrHandle.close() + } + + process.standardOutput = stdoutHandle + process.standardError = stderrHandle + + try process.run() + process.waitUntilExit() + + let stdout = try String(contentsOf: stdoutFile, encoding: .utf8) + let stderr = try String(contentsOf: stderrFile, encoding: .utf8) + + return ExhaustiveProcessResult(exitCode: Int(process.terminationStatus), stdout: stdout, stderr: stderr) +} + +private struct ExhaustiveProcessResult { + let exitCode: Int + let stdout: String + let stderr: String +} + +private enum ExhaustiveGenerator { + static func generateDeclarations(maxParameters: Int, labels: [String], includePacks: Bool) -> [ExhaustiveDeclaration] + { + var all: [ExhaustiveDeclaration] = [] + var declarationCounter = 0 + + for parameterCount in 1...maxParameters { + var current: [ExhaustiveParameter] = [] + + func recurse(parameterIndex: Int, labelIndex: Int, previousWasVariadicLike: Bool) { + if parameterIndex == parameterCount { + let declaration = ExhaustiveDeclaration(id: "decl_\(declarationCounter)", parameters: current) + declarationCounter += 1 + all.append(declaration) + return + } + + var labelOptions: [ExhaustiveLabel] = [] + labelOptions.append(.named(labels[labelIndex])) + if parameterIndex > 0 && labelIndex + 1 < labels.count { + labelOptions.append(.named(labels[labelIndex + 1])) + } + if !previousWasVariadicLike { + labelOptions.append(.unlabeled) + } + + for label in labelOptions { + // Int parameters: fixed/variadic + optional defaults for fixed. + for isVariadic in [false, true] { + let defaultOptions = isVariadic ? [false] : [false, true] + for isDefaulted in defaultOptions { + let parameter = ExhaustiveParameter( + label: label, + kind: .int, + localName: "p\(parameterIndex)", + isDefaulted: isDefaulted, + isVariadic: isVariadic, + defaultSentinel: -1000 - parameterIndex + ) + current.append(parameter) + let newLabelIndex = label == .unlabeled ? labelIndex + 1 : labelIndex + recurse( + parameterIndex: parameterIndex + 1, + labelIndex: newLabelIndex, + previousWasVariadicLike: isVariadic + ) + _ = current.popLast() + } + } + + if includePacks { + // Pack parameters are variadic-like for following-label rules. + let packParameter = ExhaustiveParameter( + label: label, + kind: .pack, + localName: "p\(parameterIndex)", + isDefaulted: false, + isVariadic: false, + defaultSentinel: -1000 - parameterIndex + ) + current.append(packParameter) + let newLabelIndex = label == .unlabeled ? labelIndex + 1 : labelIndex + recurse( + parameterIndex: parameterIndex + 1, + labelIndex: newLabelIndex, + previousWasVariadicLike: true + ) + _ = current.popLast() + } + } + } + + recurse(parameterIndex: 0, labelIndex: 0, previousWasVariadicLike: false) + } + + return all + } + + static func generateTrailingClosureDeclarations(maxParameters: Int, labels: [String]) -> [ExhaustiveDeclaration] { + var all: [ExhaustiveDeclaration] = [] + var declarationCounter = 0 + + for closureCount in 1...2 { + let intCount = maxParameters - closureCount + var current: [ExhaustiveParameter] = [] + + func recurseIntParameters(parameterIndex: Int, labelIndex: Int) { + if parameterIndex == intCount { + recurseClosureParameters(closureIndex: 0, labelIndex: labelIndex) + return + } + + let labelOptions: [ExhaustiveLabel] = [.named(labels[labelIndex]), .unlabeled] + for label in labelOptions { + current.append( + ExhaustiveParameter( + label: label, + kind: .int, + localName: "p\(parameterIndex)", + isDefaulted: false, + isVariadic: false, + defaultSentinel: -1000 - parameterIndex + ) + ) + let newLabelIndex = label == .unlabeled ? min(labelIndex + 1, labels.count - 1) : labelIndex + recurseIntParameters(parameterIndex: parameterIndex + 1, labelIndex: newLabelIndex) + _ = current.popLast() + } + } + + func recurseClosureParameters(closureIndex: Int, labelIndex: Int) { + if closureIndex == closureCount { + all.append(ExhaustiveDeclaration(id: "decl_trailing_\(declarationCounter)", parameters: current)) + declarationCounter += 1 + return + } + + let parameterIndex = intCount + closureIndex + let labelOptions: [ExhaustiveLabel] + if closureIndex == 0 { + labelOptions = [.named(labels[labelIndex]), .unlabeled] + } else { + let next = min(labelIndex + 1, labels.count - 1) + labelOptions = [.named(labels[labelIndex]), .named(labels[next])] + } + + for label in labelOptions { + current.append( + ExhaustiveParameter( + label: label, + kind: .trailingClosure, + localName: "p\(parameterIndex)", + isDefaulted: false, + isVariadic: false, + defaultSentinel: -1000 - parameterIndex + ) + ) + let newLabelIndex: Int + if case .named = label { + newLabelIndex = labelIndex + } else { + newLabelIndex = min(labelIndex + 1, labels.count - 1) + } + recurseClosureParameters(closureIndex: closureIndex + 1, labelIndex: newLabelIndex) + _ = current.popLast() + } + } + + recurseIntParameters(parameterIndex: 0, labelIndex: 0) + } + + return all + } + + static func generateClosureDeclarations(maxParameters: Int, labels: [String]) -> [ExhaustiveDeclaration] { + // Closure-heavy declarations, generated systematically to mirror manual + // closure/default/variadic/trailing scenarios while keeping runtime bounded. + let closureParameterCount = min(3, maxParameters) + var all: [ExhaustiveDeclaration] = [] + var declarationCounter = 0 + + for parameterCount in 1...closureParameterCount { + var current: [ExhaustiveParameter] = [] + + func recurse(parameterIndex: Int, labelIndex: Int, previousWasVariadic: Bool) { + if parameterIndex == parameterCount { + all.append(ExhaustiveDeclaration(id: "decl_closure_\(declarationCounter)", parameters: current)) + declarationCounter += 1 + return + } + + var labelOptions: [ExhaustiveLabel] = [] + labelOptions.append(.named(labels[labelIndex])) + if parameterIndex > 0 && labelIndex + 1 < labels.count { + labelOptions.append(.named(labels[labelIndex + 1])) + } + if !previousWasVariadic { + labelOptions.append(.unlabeled) + } + + for label in labelOptions { + for isVariadic in [false, true] { + let defaultOptions = isVariadic ? [false] : [false, true] + for isDefaulted in defaultOptions { + current.append( + ExhaustiveParameter( + label: label, + kind: .closure, + localName: "p\(parameterIndex)", + isDefaulted: isDefaulted, + isVariadic: isVariadic, + defaultSentinel: -1000 - parameterIndex + ) + ) + let newLabelIndex = label == .unlabeled ? min(labelIndex + 1, labels.count - 1) : labelIndex + recurse(parameterIndex: parameterIndex + 1, labelIndex: newLabelIndex, previousWasVariadic: isVariadic) + _ = current.popLast() + } + } + } + } + + recurse(parameterIndex: 0, labelIndex: 0, previousWasVariadic: false) + } + + return all + } + + static func generateCalls(for declaration: ExhaustiveDeclaration, maxVariadicCount: Int) -> [ExhaustiveCall] { + var calls: [ExhaustiveCall] = [] + + func recurse( + parameterIndex: Int, + nextValue: Int, + accumulated: [String], + firstTrailingClosure: String?, + additionalTrailingClosures: [(String, String)] + ) { + if parameterIndex == declaration.parameters.count { + calls.append( + ExhaustiveCall( + functionName: declaration.id, + callArguments: accumulated, + firstTrailingClosure: firstTrailingClosure, + additionalTrailingClosures: additionalTrailingClosures + ) + ) + return + } + + let parameter = declaration.parameters[parameterIndex] + + if parameter.kind == .trailingClosure { + let body = "{ \(nextValue) }" + if firstTrailingClosure == nil { + recurse( + parameterIndex: parameterIndex + 1, + nextValue: nextValue + 1, + accumulated: accumulated, + firstTrailingClosure: body, + additionalTrailingClosures: additionalTrailingClosures + ) + } else { + guard case .named(let label) = parameter.label else { + return + } + recurse( + parameterIndex: parameterIndex + 1, + nextValue: nextValue + 1, + accumulated: accumulated, + firstTrailingClosure: firstTrailingClosure, + additionalTrailingClosures: additionalTrailingClosures + [(label, body)] + ) + } + return + } + + if parameter.kind == .closure { + func makeArgumentClosureExpr(_ value: Int) -> String { + "({ \(value) })" + } + + func makeTrailingClosureExpr(_ value: Int) -> String { + "{ \(value) }" + } + + if parameter.isVariadic { + for variadicCount in 0...maxVariadicCount { + var arguments = accumulated + var value = nextValue + for variadicIndex in 0.. Int)..." : "() -> Int" + case .pack: + typeText = "repeat each T\(packTypeNumberByParameterIndex[index]!)" + } + let defaultText: String + if parameter.isDefaulted { + switch parameter.kind { + case .int: + defaultText = " = \(parameter.defaultSentinel)" + case .trailingClosure, .closure: + defaultText = " = { \(parameter.defaultSentinel) }" + case .pack: + defaultText = "" + } + } else { + defaultText = "" + } + return "\(labelText) \(parameter.localName): \(typeText)\(defaultText)" + }.joined(separator: ", ") + } + + private var genericClauseText: String { + let packNumbers = parameters.enumerated().compactMap { index, parameter in + parameter.kind == .pack ? packTypeNumberByParameterIndex[index] : nil + } + guard !packNumbers.isEmpty else { + return "" + } + + let genericParams = packNumbers.map { "each T\($0)" }.joined(separator: ", ") + return "<\(genericParams)>" + } + + private var whereClauseText: String { + let packNumbers = parameters.enumerated().compactMap { index, parameter in + parameter.kind == .pack ? packTypeNumberByParameterIndex[index] : nil + } + guard !packNumbers.isEmpty else { + return "" + } + + let requirements = packNumbers.map { "repeat each T\($0): BinaryInteger" }.joined(separator: ", ") + return "where \(requirements)" + } + + var declarationString: String { + return "func \(id)\(genericClauseText)(\(params)) \(whereClauseText) { \(runtimeMappingBody) }" + } + + var declarationWithoutBody: String { + return "func \(id)\(genericClauseText)(\(params)) \(whereClauseText) {}" + } + + var declarationSource: String { + return """ + import Foundation + \(declarationString) + """ + } + + var shape: String { + parameters.map { parameter in + let labelPart: String + switch parameter.label { + case .unlabeled: labelPart = "_" + case .named: labelPart = "l" + } + let defaultPart = parameter.isDefaulted ? "d" : "r" + let variadicPart = parameter.isVariadic ? "v" : "f" + return "\(labelPart)\(defaultPart)\(variadicPart)" + }.joined(separator: "-") + } + + func executableSource(for call: ExhaustiveCall) -> String { + return """ + \(declarationString) + \(call.callString) + """ + } + + func executableSource(for calls: [ExhaustiveCall]) -> String { + let callsSource = calls.map { $0.callString }.joined(separator: "\n") + return """ + \(declarationString) + \(callsSource) + """ + } + + func executableSourceWithoutBody(for calls: [ExhaustiveCall]) -> String { + let callsSource = calls.map { $0.callString }.joined(separator: "\n") + return """ + \(declarationWithoutBody) + \(callsSource) + """ + } + + private var runtimeMappingBody: String { + let bindings = parameters.map { parameter -> String in + if parameter.kind == .trailingClosure { + return "let m_\(parameter.localName): [Int] = [\(parameter.localName)()]" + } + if parameter.kind == .closure { + if parameter.isVariadic { + return "let m_\(parameter.localName): [Int] = \(parameter.localName).map { $0() }" + } + if parameter.isDefaulted { + return + "let m_\(parameter.localName): [Int] = \(parameter.localName)() == \(parameter.defaultSentinel) ? [] : [\(parameter.localName)()]" + } + return "let m_\(parameter.localName): [Int] = [\(parameter.localName)()]" + } + if parameter.kind == .pack { + return + "var m_\(parameter.localName): [Int] = []; for v in repeat each \(parameter.localName) { m_\(parameter.localName).append(Int(v)) }" + } + if parameter.isVariadic { + return "let m_\(parameter.localName): [Int] = \(parameter.localName)" + } + if parameter.isDefaulted { + return + "let m_\(parameter.localName): [Int] = \(parameter.localName) == \(parameter.defaultSentinel) ? [] : [\(parameter.localName)]" + } + return "let m_\(parameter.localName): [Int] = [\(parameter.localName)]" + }.joined(separator: "; ") + + let mappings = parameters.map { parameter in + return + #"let m_\#(parameter.localName)_mapped: String = m_\#(parameter.localName).map(String.init).joined(separator: ",")"# + }.joined(separator: "; ") + + let mappedVariableNames = parameters.map { "m_\($0.localName)_mapped" }.joined(separator: ", ") + let matchesVariable = "let matches: [String] = [\(mappedVariableNames)]" + let matchString = "let matchString = matches.joined(separator: \";\")" + + return #"\#(bindings); \#(mappings); \#(matchesVariable); \#(matchString); print("MATCH|\#(id)|" + matchString)"# + } +} + +private struct ExhaustiveCall { + let functionName: String + let callArguments: [String] + let firstTrailingClosure: String? + let additionalTrailingClosures: [(String, String)] + + var callString: String { + let base = "\(functionName)(\(callArguments.joined(separator: ", ")))" + guard let firstTrailingClosure else { + return base + } + let additional = additionalTrailingClosures.map { label, closure in + "\(label): \(closure)" + }.joined(separator: " ") + + if additional.isEmpty { + return "\(base) \(firstTrailingClosure)" + } + return "\(base) \(firstTrailingClosure) \(additional)" + } +} + +private struct ExhaustiveParameter { + let label: ExhaustiveLabel + let kind: ExhaustiveParameterKind + let localName: String + let isDefaulted: Bool + let isVariadic: Bool + let defaultSentinel: Int +} + +private enum ExhaustiveParameterKind { + case int + case trailingClosure + case closure + case pack +} + +private enum ExhaustiveLabel: Equatable { + case unlabeled + case named(String) +}