Skip to content

[await?] Semantic model and IOperation tests#83236

Draft
CyrusNajmabadi wants to merge 58 commits intodotnet:features/null-conditional-awaitfrom
CyrusNajmabadi:null-conditional-await-semantic-model
Draft

[await?] Semantic model and IOperation tests#83236
CyrusNajmabadi wants to merge 58 commits intodotnet:features/null-conditional-awaitfrom
CyrusNajmabadi:null-conditional-await-semantic-model

Conversation

@CyrusNajmabadi
Copy link
Copy Markdown
Contributor

@CyrusNajmabadi CyrusNajmabadi commented Apr 18, 2026

Tests for the public SemanticModel surface on await? e (GetTypeInfo, GetAwaitExpressionInfo, GetSymbolInfo, GetOperation, GetDeclaredSymbol, GetConversion, AnalyzeDataFlow, AnalyzeControlFlow, GetEnclosingSymbol, LookupSymbols, GetSpeculativeTypeInfo) plus IAwaitOperation tree-shape and CFG tests in IOperationTests_IAwaitExpression.cs. Test-only PR — no compiler changes.

Part of the await? / null-conditional-await language feature implementation.


Stacked:

  1. [await?] Syntax and parsing #83233
  2. [await?] Binding #83234
  3. [await?] Lowering #83235
  4. [await?] Semantic model and IOperation tests #83236 (this PR)

Cyrus Najmabadi added 2 commits April 18, 2026 16:17
Extends AwaitExpressionSyntax with an optional QuestionToken child, and
teaches the parser to eat the `?` that immediately follows `await`. No
binder or lowering changes: await? t is currently bound identically to
await t, ignoring the ?. Feature-availability gating and real semantics
come in a follow-up.
Tests live in src/Compilers/CSharp/Test/CSharp15/NullConditionalAwaitParsingTests.cs.
Adds file-links for ParsingTests and SyntaxExtensions so the CSharp15 test project
can use UsingTree / UsingExpression / N-walks like the existing Syntax/Parsing tests.

Coverage:
- Tree-shape tests for await / await? in async, sync, and top-level-statements contexts.
- Whitespace and trivia between `await` and `?`.
- Every positive case in the non-async IsAwaitExpression lookahead switch, both with and
  without `?`, including the peek-past-? path.
- Negative cases: `with` identifier, tokens not in the switch (`;`, `(`, `[`, unary ops,
  sizeof/stackalloc/switch/throw), and after-`?` tokens that fall out of the switch.
- @await escaped identifier (sync and async contexts).
- field / value contextual-keyword operands inside property accessors.
- Precedence / disambiguation: ternary interactions, member-access, binary ops, unary !,
  nested await? await?, pattern + ternary.
- Error recovery: missing operand, double question.
- Regression: `await foreach` / `await using` (no `?`) still parse as their statement
  forms; `await? foreach` / `await? using` do NOT steal those forms, they fall through
  to expression-statement parsing.
- Expression-bodied async method, async lambda, async local function, parenthesized
  operand in async.
- All assertions roundtrip-check that the tree's ToFullString equals the source.
@dotnet-policy-service dotnet-policy-service Bot added Community The pull request was submitted by a contributor who is not a Microsoft employee. Needs API Review Needs to be reviewed by the API review council labels Apr 18, 2026
Removes references to internal parser helpers (IsAwaitExpression, IsInAsync)
from test comments and region headers. The observable behavior (what the
parser produces in async vs non-async context for each operand kind) is
unchanged; the comments now describe that behavior directly.
@CyrusNajmabadi CyrusNajmabadi force-pushed the null-conditional-await-semantic-model branch 3 times, most recently from 6c79262 to 57d139d Compare April 18, 2026 19:36
@CyrusNajmabadi CyrusNajmabadi changed the base branch from main to features/null-conditional-await April 18, 2026 19:40
@CyrusNajmabadi CyrusNajmabadi changed the title initial semantic model testing work for null conditional await. Semantic model and IOperation tests for await? Apr 18, 2026
@CyrusNajmabadi CyrusNajmabadi force-pushed the null-conditional-await-semantic-model branch 2 times, most recently from 4f3eff5 to 09b4597 Compare April 23, 2026 16:41
The lookahead is deliberately limited to `await <tok>`, not `await ? <tok>`.
In non-async context `await ? expr1 : expr2` is a legal ternary on the
identifier `await`, and one token of lookahead can't tell the two
forms apart (the disambiguator is the matching `:`, arbitrarily far
away). Pessimistically leaving `await ?` to parse as a ternary is the
only correct choice without committing to much deeper lookahead.
@CyrusNajmabadi CyrusNajmabadi force-pushed the null-conditional-await-semantic-model branch from 09b4597 to 57e4f9d Compare April 24, 2026 10:23
Cyrus Najmabadi added 12 commits April 24, 2026 12:31
A user writing `await?` who naturally extends it to `await? foreach` or
`await? using` got a cascade of several expression-parsing errors: the
`await?` would commit to an expression statement with a missing operand,
a semicolon-expected error would fire on the following keyword, and then
the remaining tokens would parse as a separate (non-await) foreach/using
statement.

Detect `await ? foreach` / `await ? using` at the same statement-start
lookahead that already drives `await foreach` and `await using`. Eat the
stray `?` with a single ERR_UnexpectedToken diagnostic, attach it as
trailing skipped syntax on the `await` keyword, and parse the rest as a
normal await-foreach / await-using statement. Covers the parenthesized
using form (`await? using (d) { }`) and the declaration form
(`await? using var d = x;`) via ParseLocalDeclarationStatement.

Updates the two existing AwaitQuestion_ForEach / AwaitQuestion_Using
parsing tests to pin the new single-diagnostic shape, and adds a third
covering `await? using var`.
Extends BindAwait to handle the `await? e` form. The operand-nullability
rule rejects non-nullable value-type operands (concrete structs, T:struct,
T:unmanaged) at the ? token, mirroring BindConditionalAccessReceiver.
The awaitable pattern is resolved against the underlying type U (V when
the operand is Nullable<V>, otherwise the operand type). The result type
is lifted per the same rule as `?.`: non-nullable value-type R becomes
Nullable<R>; already-nullable, reference, or dynamic R stays unchanged;
unconstrained-T-at-result-position produces ERR_CannotBeMadeNullable when
the result is used. The lifting logic moves into a shared helper that
both BindConditionalAccessExpression and BindAwait call.

BoundAwaitExpression gets an IsNullConditional bool field. The nullable
flow walker uses node.Type for the lifted result (MaybeDefault state) and
strips Nullable<> off the placeholder replacement so the AwaitableInfo
placeholder's type invariant holds when the operand is Nullable<V>.

Phase 2 produces the correct bound tree and semantic model; emit and
state-machine lowering for the new form come in phase 3.
Covers:
- Feature availability (preview vs C#14 rejection).
- Operand-nullability errors: concrete ValueTask/ValueTask<T> and user
  structs, T:struct, T:unmanaged.
- Operand-nullability successes and result-type rule across the cross
  product: Task<int>, Task<string>, Nullable<ValueTask>,
  Nullable<ValueTask<int>>, Task<int?>, Task<T> for T:class and T:struct,
  unconstrained T result types (both value and statement position),
  dynamic.
- Existing await-context restrictions: non-async parse-as-ternary, lock,
  unsafe, missing GetAwaiter.
- Interactions: extension GetAwaiter picked up on the underlying type,
  bare ConfigureAwait rejected, t?.ConfigureAwait(false) accepted.

Each success case declares `var v = await? <operand>;` and asserts the
inferred type of `v` via the semantic model, the user-visible observation
of the result-type rule.
Removes references to internal helpers (ComputeConditionalAccessResultType,
ResultIsUsed, IsRestrictedType, etc.) and a verbatim quote of a source comment
from the binder. The observable assertions — diagnostics, inferred result
types, NRT annotations — are unchanged; comments now describe them in
spec-level terms.
The nullable walker was reporting `WRN_NullReferenceReceiver` when `await?`
was applied to an operand the flow analysis considered possibly null — e.g.
`await? (Task<int>?)t`, a nested `await? await? t`, or any call result that
evaluates to a `MaybeNull` reference. But `await?` is specifically designed
to accept a null receiver; the GetAwaiter call only ever runs on the non-null
branch, so the warning was always wrong.

Force the awaitable-info placeholder's flow state to NotNull whenever
IsNullConditional, for both reference-type operands and `Nullable<V>` operands
(the existing Nullable<V> branch already did this, just not for refs).

Five binding tests that were pinning the spurious warning as "expected" are
updated to assert no warning on the NRT-on side. The NRT-off CS8632 warnings
(which flag the `?` annotation on the parameter type, not the await) stay.
The previous commit forced the awaitable placeholder's flow state to NotNull
so the GetAwaiter receiver check doesn't warn. This test confirms the
suppression is scoped correctly: passing a null literal to a non-nullable
reference-type parameter of the operand expression still produces CS8625.
If we ever over-broaden the suppression, this test will fail loudly.
Pin that await? produces its lifted result type during generic method
type inference and during overload resolution. Exercises int, string,
ref-type, and mixed int/int? overload shapes. Also pins that an
-only overload fails to convert from int? with ERR_BadArgType.
Pin pattern-matching behavior on the lifted result (constant/type/null/
not-null/property patterns, switch expression), plus tuple deconstruction.
Deconstructing the lifted Nullable<ValueTuple> fails with the standard
type-inference-failed + missing-Deconstruct cascade; the workaround via
GetValueOrDefault() compiles cleanly.
Pin null-forgiving behavior (`(await? t)!.Length` suppresses CS8602;
`(await? t)!.Value` works on lifted int?) and `out var` definite-
assignment through an await? operand. When the operand receiver is
itself null-conditional (`h?.M(out var x)`) and can short-circuit,
out var is NOT definitely assigned — CS0165 fires.
Pin rejection of await? in non-async contexts where `await` is
likewise forbidden: default parameter value, field initializer,
constructor body, and finalizer. Each one produces the ternary-
disambiguation cascade we document for the standalone outside-async
case.
Pin that `async` lambdas using await? cannot be converted to an
expression tree, same as plain await.
Pin that await? surfaces the same binding errors as plain await on
malformed awaitable patterns: missing GetResult, awaiter not implementing
INotifyCompletion, GetResult with arguments, and static GetAwaiter
(which is outside the pattern).
Cyrus Najmabadi added 6 commits April 24, 2026 12:32
Many of the earlier binding tests verified types and diagnostics but not
runtime behavior. Add execution-level counterparts for every positive
(no-diagnostic) binding scenario where runtime semantics are the real
thing being verified:

- Overload resolution (int? overload actually runs; string overload
  handles null)
- Generic method type inference (lifted T actually flows to callee)
- Pattern matching: constant / type / null / not-null / property /
  switch-expression arms on await? results
- Null-forgiving on reference + lifted value results (member access /
  .Value at runtime)
- out var captured through await? (x visible after the statement)
- Tuple deconstruction of lifted Nullable<ValueTuple> via
  GetValueOrDefault
Covers the public SemanticModel surface (GetTypeInfo, GetAwaitExpressionInfo,
GetSymbolInfo, GetOperation, GetDeclaredSymbol, GetConversion, AnalyzeDataFlow,
AnalyzeControlFlow, GetEnclosingSymbol, LookupSymbols, GetSpeculativeTypeInfo)
and pins the IOperation tree shape for `await? e` (IAwaitOperation.Type lifts
to Nullable<R>, tree is flat, error operand maps to IInvalidOperation, CFG has
no short-circuit branch).
Addresses adversarial audit findings: adds coverage for Task<int?> (already-
nullable result), unconstrained T in statement vs value position, ConfigureAwait
misuse, generic Task<T:struct>, Nullable<ValueTask> void result, and `await?` as
a call argument. Tightens GetAwaitExpressionInfo assertions to cover GetResult
and IsCompleted. Adds AnalyzeDataFlow tests for `await?` as argument and inside
a try/catch.
Removes references to internal compiler types and phase numbering
(BoundAwaitExpression, BoundBadExpression, SpillSequenceSpiller,
ControlFlowGraphBuilder, phase 3/4) from test comments. Observable
assertions are unchanged; the comments now describe the public API
shape each test pins and the spec-level invariant behind it.
…Annotated

The test's own comment claimed the flow state was preserved across the NRT
context, but the assertion only covered Type and Annotation — a regression to
a different FlowState would have been silently accepted. Add the missing
assertion (actual value is None in NRT-disabled mode, which matches "nullable
flow analysis doesn't run here").
- DebugEmit smoke tests (straight-line and try/finally) confirming PDB
  + sequence-point emission doesn't choke on AwaitExpressionSyntax.QuestionToken.
- NRT flow tests pinning that (a) narrowing established before await?
  (e.g. `if (a is null) return;`) survives the await? expression, and
  (b) `(await? t) ?? default` narrows to non-null in the coalesce
  result.
@CyrusNajmabadi CyrusNajmabadi force-pushed the null-conditional-await-semantic-model branch from 57e4f9d to fb9ac5d Compare April 24, 2026 10:32
Cyrus Najmabadi and others added 17 commits April 24, 2026 13:45
The previous consolidation in TryParseStatementStartingWithIdentifier
broke plain `await expr;` in async methods: the outer `if (await)` block
always returned, making the `else if (IsPossibleAwaitExpressionStatement)`
branch below unreachable. `await foo();` fell through to
ParseStatementCoreRest and was (mis)parsed as a local declaration with
`await` as the type.

Let the `if (await)` block fall through when the token isn't the
foreach/using statement form, so the chain below (including
IsPossibleAwaitExpressionStatement) runs. Route the non-parenthesized
`await [?] using Type ...` declaration form directly to
ParseLocalDeclarationStatement from the dispatcher, bypassing the
non-async await-retry in ParseStatementCoreRest that would otherwise
trip on the ERR_UnexpectedToken we attach to the stray `?` and produce
a cascading diagnostic. Teach IsPossibleAwaitExpressionStatement to
reject `await [?] using` / `await [?] foreach` so it doesn't hijack
those statement forms as plain expression statements once the chain
is reachable again.

Adds three non-async-method parsing tests covering `await? foreach`,
`await? using (...)`, and `await? using var` recovery, pinning the
same single-diagnostic shape as the async variants.
Callers of ParseForEachStatement / ParseUsingStatement had to peek for
`await` (and recover a stray `?`) externally and pass the eaten token in
via an `awaitTokenOpt` parameter. Move that logic into each statement
parser: on entry they check whether CurrentToken is the `await`
contextual keyword and eat it (plus any stray `?` as skipped syntax)
themselves. Rename the helper to TryEatAwaitKeywordWithOptionalSkippedQuestion
so it returns null when not at `await`, letting callers assign its
result unconditionally.

This removes the `awaitTokenOpt` parameter from both statement parsers
and collapses the three `ParseForEachStatement(..., awaitTokenOpt: null)`
callers plus the `ParseUsingStatement(..., awaitTokenOpt: null)` caller
to single-argument calls. The two await-aware dispatcher callers no
longer pre-eat the `await` token; they just delegate.
The previous consolidation in TryParseStatementStartingWithIdentifier
broke plain `await expr;` in async methods: the outer `if (await)` block
always returned, making the `else if (IsPossibleAwaitExpressionStatement)`
branch below unreachable. `await foo();` fell through to
ParseStatementCoreRest and was (mis)parsed as a local declaration with
`await` as the type.

Let the `if (await)` block fall through when the token isn't the
foreach/using statement form, so the chain below (including
IsPossibleAwaitExpressionStatement) runs. Route the non-parenthesized
`await [?] using Type ...` declaration form directly to
ParseLocalDeclarationStatement from the dispatcher, bypassing the
non-async await-retry in ParseStatementCoreRest that would otherwise
trip on the ERR_UnexpectedToken we attach to the stray `?` and produce
a cascading diagnostic. Teach IsPossibleAwaitExpressionStatement to
reject `await [?] using` / `await [?] foreach` so it doesn't hijack
those statement forms as plain expression statements once the chain
is reachable again.

Adds three non-async-method parsing tests covering `await? foreach`,
`await? using (...)`, and `await? using var` recovery, pinning the
same single-diagnostic shape as the async variants.
Callers of ParseForEachStatement / ParseUsingStatement had to peek for
`await` (and recover a stray `?`) externally and pass the eaten token in
via an `awaitTokenOpt` parameter. Move that logic into each statement
parser: on entry they check whether CurrentToken is the `await`
contextual keyword and eat it (plus any stray `?` as skipped syntax)
themselves. Rename the helper to TryEatAwaitKeywordWithOptionalSkippedQuestion
so it returns null when not at `await`, letting callers assign its
result unconditionally.

This removes the `awaitTokenOpt` parameter from both statement parsers
and collapses the three `ParseForEachStatement(..., awaitTokenOpt: null)`
callers plus the `ParseUsingStatement(..., awaitTokenOpt: null)` caller
to single-argument calls. The two await-aware dispatcher callers no
longer pre-eat the `await` token; they just delegate.
Adds ExperimentalUrl to the new QuestionToken field on AwaitExpressionSyntax,
pointing at dotnet#83237.

Extends the syntax generator so that a field-level ExperimentalUrl also marks
the generated Update method and SyntaxFactory overloads as experimental. Those
signatures all include the new field as a parameter and are therefore
themselves new public API. The pre-field signatures remain non-experimental
via the hand-written forwarders.

Mirrors the pattern established in dotnet#83197 for labeled
break/continue.
The previous pass routed the three SyntaxFactory variant writers through
GetCreationExperimentalUrl, which returned a field-level ExperimentalUrl
for any factory when the node had one. That over-broadened the annotation:
a minimal-factory whose signature doesn't include the experimental field
(because the field is optional and omitted from the shorthand) still got
marked experimental. That made a shipped, signature-unchanged API appear
as a newly-experimental one, tripping RS0016 / RS0017 (the generator
emits [Experimental] on a method that is still listed in Shipped.txt
without the attribute).

Split the factory-side logic out into GetFactorySignatureExperimentalUrl,
which takes the specific fields that appear as factory parameters, and
propagates field-level ExperimentalUrl only if one of those fields is
actually in the signature. Node-level and kind-level marks still apply
regardless. Leaves GetFieldExperimentalUrl (used by Update, which always
takes every field) alone.
@CyrusNajmabadi CyrusNajmabadi changed the title Semantic model and IOperation tests for await? [await?] Semantic model and IOperation tests Apr 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area-Compilers Community The pull request was submitted by a contributor who is not a Microsoft employee. Needs API Review Needs to be reviewed by the API review council

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants