Skip to content

Preserve contextual type for literal arguments: expressions in @Test#1627

Open
ojun9 wants to merge 17 commits intoswiftlang:mainfrom
ojun9:fix/macro-literal-contextual-type
Open

Preserve contextual type for literal arguments: expressions in @Test#1627
ojun9 wants to merge 17 commits intoswiftlang:mainfrom
ojun9:fix/macro-literal-contextual-type

Conversation

@ojun9
Copy link
Copy Markdown
Contributor

@ojun9 ojun9 commented Mar 15, 2026

Preserve contextual type for literal arguments: expressions in @Test

Motivation:

Parameterized tests using @Test(arguments:) may fail to compile when the collection is written as an array literal containing values that rely on contextual type inference.

For example:

@Test<[(String?, Int?)]>(arguments: [
  (nil, 2),
  ("c", nil),
  ("d", nil)
])
func f(s: String?, i: Int?) {}

During macro expansion, the arguments: expression is wrapped in a closure so the collection can be evaluated lazily:

arguments: { [...] }

When the array literal is evaluated inside this closure, the contextual type that would normally be provided by the test function parameters is no longer available. As a result, values such as nil cannot be inferred and the code fails to type-check.

The same code works if the array literal is explicitly cast:

@Test(arguments: [
  (nil, 1),
  ("a", nil)
] as [(String?, Int?)])
func f(s: String?, i: Int?) {}

In this case the generated code becomes:

arguments: { [...] as [(String?, Int?)] }

The explicit cast preserves the contextual type inside the closure and allows the literals to type-check.

Modifications:

When arguments: is supplied as a single array literal, the macro derives the expected array type from the test function parameter list and applies it as an explicit cast inside the generated closure.

For example, the generated expression becomes:

arguments: { [...] as [(String?, Int?)] }

This preserves the contextual type required for literal inference while keeping the existing lazy evaluation behavior.

Macro expansion only has access to source syntax and does not observe the inferred collection type directly. For this reason, the array type is derived from the test function parameter list. The change is intentionally limited to the case where arguments: is a single array literal so that other overloads remain unaffected.

Additional tests verify that contextual types are preserved for empty array literals and tuple literals containing optionals.

Checklist:

  • Code and documentation should follow the style of the Style Guide.
  • If public symbols are renamed or modified, DocC references should be updated.

@grynspan
Copy link
Copy Markdown
Contributor

Type checking of arguments to the @Test macro occurs before the expansion itself. Did you test this change to confirm it actually works? The added test only checks the macro expansion (without any type checking).

Copy link
Copy Markdown
Contributor

@grynspan grynspan left a comment

Choose a reason for hiding this comment

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

Additional test coverage is needed too.

return nil
}

if testFunctionArguments.count == 1, expression.is(ArrayExprSyntax.self) {
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.

It should be safe to generalize this so that the argument count only needs to match the parameter count.

}

let parameters = functionDecl.signature.parameterClause.parameters
guard !parameters.isEmpty else {
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
guard !parameters.isEmpty else {
if parameters.isEmpty {

private func _contextualTypeForLiteralArgument(
for expression: ExprSyntax,
among testFunctionArguments: [Argument]
) -> String? {
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
) -> String? {
) -> TypeSyntax? {

// type itself, not tuple-shaped elements.
return "[\(parameter.baseTypeName)]"
}
let elementType = parameters.map(\.baseTypeName).joined(separator: ", ")
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.

Can we construct an ArrayTypeSyntax here instead and leave the string interpolation to swift-syntax?

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 "[\(parameter.baseTypeName)]"
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.

Tokens from the original source need to be trimmed.


/// The contextual type to explicitly apply to a literal `arguments:`
/// expression after it is wrapped in a closure for lazy evaluation.
///
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.

Need parameter and return callouts.

arguments += testFunctionArguments.map { argument in
var copy = argument
copy.expression = .init(ClosureExprSyntax { argument.expression.trimmed })
let argumentExpr = argument.expression.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.

We can simplify this part of the diff by modifying argumentExpr before creating the closure wrapper.

@ojun9
Copy link
Copy Markdown
Contributor Author

ojun9 commented Mar 17, 2026

Thanks for the question.

You're right that the existing test only verifies the macro expansion.
The change is not intended to make the following case compile:

@Test(arguments: [
  (nil, 1),
  ("a", nil)
])
func f(s: String?, i: Int?) {}

This case fails with Type of expression is ambiguous without a type annotation even without the macro, because the array literal lacks a contextual type.

The change addresses the situation where the collection type is already provided, for example via a generic parameter:

@Test<[(String?, Int?)]>(arguments: [
  (nil, 2),
  ("c", nil),
  ("d", nil)
])
func f(s: String?, i: Int?) {}

In this situation the collection element type is known, but wrapping the literal inside the generated closure removes the contextual type required for literal inference.

The added cast restores that contextual type and allows the code to compile.
I confirmed that this generic-parameter case compiles correctly with the change.

@ojun9 ojun9 requested a review from grynspan March 17, 2026 13:10
@grynspan grynspan added bug 🪲 Something isn't working macros 🔭 Related to Swift macros such as @Test or #expect labels Mar 19, 2026
@grynspan grynspan added this to the Swift 6.4.0 (main) milestone Mar 19, 2026
@grynspan
Copy link
Copy Markdown
Contributor

I haven't forgotten about this PR, but I have been tied up with other work.

We should make sure to handle dictionary literals correctly as well, and can improve test efficiency by inferring them as instances of KeyValuePairs rather than Dictionary. Note that tuple desugaring on dictionary elements is always to exactly two test function arguments and does not occur if there are multiple argument collections in the @Test macro.

Copy link
Copy Markdown
Contributor

@grynspan grynspan left a comment

Choose a reason for hiding this comment

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

We still need some fixture tests in the TestingTests target. See NonSendableTests for an example of how to write one (empty test that triggers this macro expansion code when we compile our own tests so that if the code isn't working, we fail to build). Those tests should go in the TestingTests target.

// 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.

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

@ojun9 ojun9 requested a review from grynspan March 26, 2026 14:47
Comment thread Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift Outdated
Comment thread Sources/TestingMacros/Support/AttributeDiscovery.swift Outdated
Comment thread Sources/TestingMacros/Support/AttributeDiscovery.swift
@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.

@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.

@grynspan
Copy link
Copy Markdown
Contributor

Sorry for the delay! I was out sick.

@grynspan
Copy link
Copy Markdown
Contributor

Let me know when you're ready for another review.

@ojun9
Copy link
Copy Markdown
Contributor Author

ojun9 commented Apr 20, 2026

@grynspan
No worries, hope you're feeling better!
Ready for another review!

@ojun9 ojun9 requested a review from grynspan April 20, 2026 14:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug 🪲 Something isn't working macros 🔭 Related to Swift macros such as @Test or #expect

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants