Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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.trimmed
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.

Avoid trimming both here and in the call sites as it's somewhat expensive. I would leave it untrimmed here so that it can still be used for diagnostic attribution, but either choice is fine.

}

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

Expand Down
85 changes: 83 additions & 2 deletions Sources/TestingMacros/Support/AttributeDiscovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,24 @@ 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
)
)
}
let lazyExpression = expr.trimmed
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.

Already trimmed by this point. (New nodes created programmatically are de facto trimmed if you don't explicitly specify trivia).

copy.expression = .init(ClosureExprSyntax { lazyExpression })
return copy
}
}
Expand All @@ -180,4 +195,70 @@ 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 expression.is(ArrayExprSyntax.self) else {
return nil
}

guard let functionDecl = declaration.as(FunctionDeclSyntax.self) else {
return nil
}

let parameters = Array(functionDecl.signature.parameterClause.parameters)
if parameters.isEmpty {
return nil
}

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

return nil
}
}
56 changes: 56 additions & 0 deletions Tests/TestingMacrosTests/TestDeclarationMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,62 @@ 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("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
Loading