diff --git a/Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift b/Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift index 8065d299e..1fe4577c2 100644 --- a/Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift +++ b/Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift @@ -174,12 +174,17 @@ extension FunctionParameterSyntax { } extension FunctionParameterSyntax { - /// The base type name of this parameter. - var baseTypeName: String { + /// The underlying type of this parameter with any attributed type wrappers + /// (such as `inout`) removed. + var baseType: TypeSyntax { // Discard any specifiers such as `inout` or `borrowing`, since we're only // trying to obtain the base type to reference it in an expression. - let baseType = type.as(AttributedTypeSyntax.self)?.baseType ?? type - return baseType.trimmedDescription + type.as(AttributedTypeSyntax.self)?.baseType ?? type + } + + /// The base type name of this parameter. + var baseTypeName: String { + baseType.trimmedDescription } } diff --git a/Sources/TestingMacros/Support/AttributeDiscovery.swift b/Sources/TestingMacros/Support/AttributeDiscovery.swift index c3d19543b..c2cf74141 100644 --- a/Sources/TestingMacros/Support/AttributeDiscovery.swift +++ b/Sources/TestingMacros/Support/AttributeDiscovery.swift @@ -169,9 +169,23 @@ struct AttributeInfo { // If there are any parameterized test function arguments, wrap each in a // closure so they may be evaluated lazily at runtime. if let testFunctionArguments { - arguments += testFunctionArguments.map { argument in + arguments += testFunctionArguments.enumerated().map { index, argument in var copy = argument - copy.expression = .init(ClosureExprSyntax { argument.expression.trimmed }) + var expr = copy.expression.trimmed + if let contextualType = _contextualTypeForLiteralArgument( + at: index, + for: expr, + among: testFunctionArguments + ) { + expr = ExprSyntax( + AsExprSyntax( + expression: expr, + asKeyword: .keyword(.as, leadingTrivia: .space, trailingTrivia: .space), + type: contextualType.trimmed + ) + ) + } + copy.expression = .init(ClosureExprSyntax { expr }) return copy } } @@ -180,4 +194,83 @@ struct AttributeInfo { return LabeledExprListSyntax(arguments) } + + /// The contextual type to explicitly apply to a literal `arguments:` + /// expression after it is wrapped in a closure for lazy evaluation. + /// + /// Parameterized `@Test` declarations are modeled in terms of the collection + /// type supplied to the macro, but macro expansion only sees source syntax. + /// When the `arguments:` parameter is supplied as an array literal, derive + /// the corresponding array type from the test function's parameters so the + /// literal retains enough contextual type information after lazy wrapping. + /// + /// This applies to both the single-collection form and the overloads where + /// each `arguments:` expression corresponds directly to one parameter. + /// + /// - Parameters: + /// - index: The position of `expression` within `testFunctionArguments`. + /// - expression: The argument expression being wrapped for lazy evaluation. + /// - testFunctionArguments: The full list of argument expressions supplied + /// to the parameterized `@Test`. + /// + /// - Returns: The array type to apply to `expression`, or `nil` if no + /// contextual type reconstruction is needed. + private func _contextualTypeForLiteralArgument( + at index: Int, + for expression: ExprSyntax, + among testFunctionArguments: [Argument] + ) -> TypeSyntax? { + guard let functionDecl = declaration.as(FunctionDeclSyntax.self) else { + return nil + } + + let parameters = Array(functionDecl.signature.parameterClause.parameters) + if parameters.isEmpty { + return nil + } + + if expression.is(ArrayExprSyntax.self) { + if testFunctionArguments.count == parameters.count { + let parameter = parameters[index] + return TypeSyntax( + ArrayTypeSyntax(element: parameter.baseType.trimmed) + ) + } + + if testFunctionArguments.count == 1 { + if parameters.count == 1, let parameter = parameters.first { + // A single-parameter test expects collection elements of the parameter + // type itself, not tuple-shaped elements. + return TypeSyntax( + ArrayTypeSyntax(element: parameter.baseType.trimmed) + ) + } + let elementType = TypeSyntax( + TupleTypeSyntax(elements: TupleTypeElementListSyntax { + for parameter in parameters { + TupleTypeElementSyntax(type: parameter.baseType.trimmed) + } + }) + ) + return TypeSyntax(ArrayTypeSyntax(element: elementType)) + } + } else if expression.is(DictionaryExprSyntax.self) { + if testFunctionArguments.count == 1, parameters.count == 2 { + return TypeSyntax( + MemberTypeSyntax( + baseType: IdentifierTypeSyntax(name: .identifier("Swift")), + name: .identifier("KeyValuePairs"), + genericArgumentClause: GenericArgumentClauseSyntax( + arguments: GenericArgumentListSyntax { + GenericArgumentSyntax(argument: .type(parameters[0].baseType.trimmed)) + GenericArgumentSyntax(argument: .type(parameters[1].baseType.trimmed)) + } + ) + ) + ) + } + } + + return nil + } } diff --git a/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift b/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift index 4247e4e79..916c15548 100644 --- a/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift +++ b/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift @@ -533,6 +533,69 @@ struct TestDeclarationMacroTests { } } + static var parameterizedArgumentTypePreservationInputs: [(String, String)] { + [ + ( + """ + @Test(arguments: []) + func f(i: Int) {} + """, + #"arguments:{[]as[Int]}"# + ), + ( + """ + @Test(arguments: [], []) + func f(i: Int, s: String) {} + """, + #"arguments:{[]as[Int]},{[]as[String]}"# + ), + ( + """ + @Test(arguments: [nil], [nil]) + func f(i: Int?, s: String?) {} + """, + #"arguments:{[nil]as[Int?]},{[nil]as[String?]}"# + ), + ( + """ + @Test(arguments: [ + (nil, 1), + ("a", nil), + ("b", nil) + ]) + func f(s: String?, i: Int?) {} + """, + #"arguments:{[(nil,1),("a",nil),("b",nil)]as[(String?,Int?)]}"# + ), + ( + """ + @Test(arguments: ["value": 123]) + func f(s: String, i: Int) {} + """, + #"arguments:{["value":123]asSwift.KeyValuePairs}"# + ), + ] + } + + @Test("Literal arguments preserve contextual types after lazy wrapping", arguments: parameterizedArgumentTypePreservationInputs) + func preservesParameterizedArgumentTypes(input: String, expectedOutput: String) throws { + let (output, _) = try parse(input, removeWhitespace: true) + #expect(output.contains(expectedOutput)) + } + + @Test("Non-literal parameterized arguments are left unchanged") + func nonLiteralParameterizedArgumentsRemainUncast() throws { + let input = """ + let ints = [1, 2] + let strings = ["a", "b"] + @Test(arguments: ints, strings) + func f(i: Int, s: String) {} + """ + let (output, _) = try parse(input, removeWhitespace: true) + #expect(output.contains(#"arguments:{ints},{strings}"#)) + #expect(!output.contains(#"arguments:{intsas[Int]},{stringsas[String]}"#)) + } + @Test("Display name is preserved", arguments: [ #"@Test("Display Name") func f() {}"#, diff --git a/Tests/TestingTests/Test.Case.ArgumentTests.swift b/Tests/TestingTests/Test.Case.ArgumentTests.swift index 4ea9925d6..17b7d2225 100644 --- a/Tests/TestingTests/Test.Case.ArgumentTests.swift +++ b/Tests/TestingTests/Test.Case.ArgumentTests.swift @@ -165,11 +165,24 @@ struct ParameterizedTests { @Test(.hidden, arguments: [("value", 123)]) func one2TupleParameter(x: (String, Int)) {} + @Test(.hidden, arguments: [ + (nil, 123), + ("value1", nil), + ("value2", nil), + ] as [(String?, Int?)]) + func contextualArrayLiteral(x: String?, y: Int?) {} + @Test(.hidden, arguments: ["value": 123]) func twoDictionaryElementParameters(x: String, y: Int) {} @Test(.hidden, arguments: ["value": 123]) func oneDictionaryElementTupleParameter(x: (key: String, value: Int)) {} + @Test(.hidden, arguments: [ + "value1": nil, + "value2": 123, + ] as KeyValuePairs) + func contextualDictionaryLiteral(key: String, value: Int?) {} + @Test(.disabled(), arguments: [1, 2, 3]) func disabled(x: Int) {} }