Skip to content

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

Open
ojun9 wants to merge 19 commits into
swiftlang:mainfrom
ojun9:fix/macro-literal-contextual-type
Open

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

Conversation

@ojun9

@ojun9 ojun9 commented Mar 15, 2026

Copy link
Copy Markdown
Contributor

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

@grynspan grynspan left a comment

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.

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.

Comment thread Sources/TestingMacros/Support/AttributeDiscovery.swift
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

ojun9 commented Mar 17, 2026

Copy link
Copy Markdown
Contributor Author

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.

@grynspan grynspan left a comment

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

ojun9 commented Apr 20, 2026

Copy link
Copy Markdown
Contributor Author

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

@ojun9 ojun9 requested a review from grynspan April 20, 2026 14:30
@grynspan

grynspan commented May 1, 2026

Copy link
Copy Markdown
Contributor

If you see the "merge" button, please make sure to squash. Otherwise ping me and I'll click the button.

grynspan added a commit that referenced this pull request May 1, 2026
…airs`. (#1699)

This change ensures that test authors who explicitly pass collections of
type `KeyValuePairs<K, V>` as test arguments will get the same sort of
optimizations as they would if they passed a `Dictionary<K, V>` instead.

This change complements, but is not dependent on, #1627.

### Checklist:

- [x] Code and documentation should follow the style of the [Style
Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md).
- [x] If public symbols are renamed or modified, DocC references should
be updated.
return TypeSyntax(
MemberTypeSyntax(
baseType: IdentifierTypeSyntax(name: .identifier("Swift")),
name: .identifier("KeyValuePairs"),

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.

I'm surprised to see DictionaryExprSyntax being interpreted as KeyValuePairs. I see this comment which I'm guessing is what led to that:

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.

That changes the semantics some, though: elements with equal keys will run the test multiple times, and the order we dispatch test cases in will be stable, whereas with a dictionary the order is nondeterministic due to hashing.

Is switching to KeyValuePairs necessary for this overall PR, or is it more of an opportunistic performance improvement? It feels like a potentially user-observable behavior change.

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.

This was my suggestion, yes.

That changes the semantics some, though: elements with equal keys will run the test multiple times

Equal keys crash outright with a dictionary when it is initialized.

and the order we dispatch test cases in will be stable, whereas with a dictionary the order is nondeterministic due to hashing.

This change is more consistent with how we treat arrays, yes.

Is switching to KeyValuePairs necessary for this overall PR, or is it more of an opportunistic performance improvement? It feels like a potentially user-observable behavior change.

It isn't strictly necessary, but the only user-observable change is that the random order of elements is no longer random (which we never really intended it to be anyway).

@grynspan grynspan requested a review from stmontgomery May 1, 2026 19:43
(nil, 123),
("value1", nil),
("value2", nil),
] as [(String?, Int?)])

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.

Why does this test, and the other new one below, include an explicit as cast on the passed-in collection? I thought this PR was intended to remove the need to specify that at the point where @Test is used?

I just pulled the branch locally, and when I try this example mentioned in the PR description without an as cast and without <...> generic type parameters on @Test, it's still emitting an error:

@Test(arguments: [
  (nil, 2), // ❌ error: 'nil' cannot initialize specified type 'String'
  ("c", nil),
  ("d", nil)
])
func f(s: String?, i: Int?) {}

If the outcome of this PR is that a user still needs to either (a) use an explicit as cast or (b) include generic type parameters on @Test, then I'm not sure what the benefit is. Users can already do (a), and we recently began emitting a warning diagnostic for (b), and it's not meaningfully simpler than option (a) IMO anyway.

@stmontgomery stmontgomery left a comment

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.

I appreciate your effort on this @ojun9 — I really do want to see this problem resolved so that test authors don't need to provide type information for test arguments explicitly. In fact, I took a stab at it myself a while back in this PR.

I'm concerned though that as written, it doesn't really help much in practice towards that goal. Unless I'm missing something, it seems like it still requires specifying the type of the argument collection explicitly.

If that's not accurate, or if there are scenarios where this is helpful without explicit type info that I'm overlooking, could you please give an example of that?

@ojun9

ojun9 commented May 26, 2026

Copy link
Copy Markdown
Contributor Author

@stmontgomery

Sorry for the very delayed reply.

Thanks, I see your point.

The case this PR helps with is one where the type is already provided somehow before expansion, for example:

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

In this case, the original arguments: expression has enough context to type-check, but the generated closure loses that context unless the expansion adds as [(String?, Int?)].

That said, I agree this is still explicit type information at the use site. I don’t currently know of an example where this PR makes the literal work with neither an as cast nor a generic argument clause.

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.

3 participants