Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,18 @@ 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
return baseType
Comment thread
ojun9 marked this conversation as resolved.
Outdated
}

/// The base type name of this parameter.
var baseTypeName: String {
Comment thread
grynspan marked this conversation as resolved.
baseType.trimmedDescription
}
}

Expand Down
98 changes: 96 additions & 2 deletions Sources/TestingMacros/Support/AttributeDiscovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -180,4 +194,84 @@ 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.
///
Comment thread
ojun9 marked this conversation as resolved.
/// 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))
}
}

if expression.is(DictionaryExprSyntax.self) {
Comment thread
ojun9 marked this conversation as resolved.
Outdated
if testFunctionArguments.count == 1, parameters.count == 2 {
return TypeSyntax(
IdentifierTypeSyntax(
name: .identifier("KeyValuePairs"),
Comment thread
grynspan marked this conversation as resolved.
genericArgumentClause: GenericArgumentClauseSyntax(
arguments: GenericArgumentListSyntax {
GenericArgumentSyntax(argument: .type(parameters[0].baseType.trimmed))
GenericArgumentSyntax(argument: .type(parameters[1].baseType.trimmed))
}
)
)
)
}
}

return nil
}
}
63 changes: 63 additions & 0 deletions Tests/TestingMacrosTests/TestDeclarationMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,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]asKeyValuePairs<String,Int>}"#
),
]
}

@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() {}"#,
Expand Down
13 changes: 13 additions & 0 deletions Tests/TestingTests/Test.Case.ArgumentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,24 @@ struct ParameterizedTests {
@Test(.hidden, arguments: [("value", 123)])
func one2TupleParameter(x: (String, Int)) {}

@Test<[(String?, Int?)]>(.hidden, arguments: [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@Test<[(String?, Int?)]>(.hidden, arguments: [
@Test(.hidden, arguments: [

Generic parameters on @Test are not supported and will diagnose if used.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the unsupported generic argument clause from @test.

The remaining type annotation is expressed as an explicit cast on the arguments: expression.
This preserves contextual type information (particularly for nil literals) and is consistent with the macro’s supported expansion semantics.

(nil, 123),
("value1", nil),
("value2", nil),
])
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<KeyValuePairs<String, Int?>>(.hidden, arguments: [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@Test<KeyValuePairs<String, Int?>>(.hidden, arguments: [
@Test(.hidden, arguments: [

Generic parameters on @Test are not supported and will diagnose if used.

"value1": nil,
"value2": 123,
])
func contextualDictionaryLiteral(key: String, value: Int?) {}

@Test(.disabled(), arguments: [1, 2, 3]) func disabled(x: Int) {}
}