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/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) +} 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 + } +}