---
generated: true
model: claude-opus-4-7
generated_at: 2026-05-20T18:00:00+00:00
source_commit: bbd84dc65e58598bfa71fafe72764b4076b0869b
watched_paths_digest: 4cd2b0ab91da080eb6a16ece95070e661cf2096b991cd6d164bfccb383236671
source_doc: docs/generated/design/ir-reference/control-flow.md
source_doc_digest: c78fb8a143ec9c0010b90e49799d399562fcba32c0b8e6cb2c7a312c06cd7fab
warning: "Auto-generated. May drift from source. Do not edit by hand."
---

# Tests for ir-reference/control-flow

## Intent
Tests verify the per-opcode catalog of the IR control-flow family
described in
[`docs/generated/design/ir-reference/control-flow.md`](../../../design/ir-reference/control-flow.md):
that each documented IR control-flow opcode (`block` parent,
`param` block-parameter (Slang IR's phi replacement), the
`TerminatorInst` family — `return_val`, `unconditionalBranch`,
`loop`, `ifElse`, `switch`, `unreachable`, plus the in-block
`discard`) appears in `-dump-ir` output for the obvious AST-statement
surface that produces it, and that the operand shape matches the
doc.

The primary observation mechanism is `-target spirv-asm -dump-ir -o
/dev/null -entry main -stage compute` (or `-stage fragment` for
`discard`) followed by a FileCheck against the IR dump. Anchors are
user-named functions (`func %main`, `func %helper`, `func %findIndex`)
and user globals (`%a`, `%b`, `%n`, `%sel`); the IR-dump preamble
is large and any unanchored pattern risks false positives.

Conditions and loop predicates are derived from `uniform` globals so
that constant folding does not collapse the structured terminators
to a single block. Switch case labels in one test use
`static const int K = N;` to confirm the doc's claim that case
values appear as inline literal operands regardless of declaration
form.


## Functional coverage
| Claim | Intent | Anchor | Tests |
| --- | --- | --- | --- |
| A block parameter encodes Slang IR's replacement for an SSA phi node — the receiving block declares the param, predecessors supply the matching argument on their unconditionalBranch. | functional | [#block-and-param](../../../design/ir-reference/control-flow.md#block-and-param) | [`block-param-from-switch-merge.slang`](block-param-from-switch-merge.slang) |
| Boundary: a loop header carrying two independent induction variables receives two `param` declarations — one per induction value — and both initial values appear as trailing operands on the loop terminator. | boundary | [#block-and-param](../../../design/ir-reference/control-flow.md#block-and-param) | [`block-param-loop-header-induction.slang`](block-param-loop-header-induction.slang) |
| Boundary: a phi at an if-merge (each arm of a ternary supplying a different value) appears as a `param` on the merge block, fed by argument operands on each predecessor's unconditionalBranch. | boundary | [#block-and-param](../../../design/ir-reference/control-flow.md#block-and-param) | [`block-param-from-ifelse-merge.slang`](block-param-from-ifelse-merge.slang) |
| The front-end emits structured ifElse — not conditionalBranch — as the in-loop condition test; the loop body's predicate becomes an ifElse with the break label as one target. | functional | [#conditionalbranch-vs-ifelse](../../../design/ir-reference/control-flow.md#conditionalbranch-vs-ifelse) | [`loop-conditional-via-ifelse.slang`](loop-conditional-via-ifelse.slang) |
| Negative: `discard` is fragment-only per the doc ("HLSL discard for fragment shaders") — using it from a compute entry point is rejected with E36107 "unavailable features in entry point" for the compute stage. | negative | [#discard](../../../design/ir-reference/control-flow.md#discard) | [`discard-rejected-in-compute-stage.slang`](discard-rejected-in-compute-stage.slang) |
| `discard` is not a terminator: it sits as an ordinary instruction inside a block, and the block's real terminator (unconditionalBranch to the post-if merge) follows. | functional | [#discard](../../../design/ir-reference/control-flow.md#discard) | [`discard-non-terminator.slang`](discard-non-terminator.slang) |
| An if statement without an else still lowers to a four-operand ifElse with a synthesized merge block as the explicit structured join. | functional | [#ifelse](../../../design/ir-reference/control-flow.md#ifelse) | [`ifelse-empty-else-arm.slang`](ifelse-empty-else-arm.slang) |
| An if/else statement lowers to ifElse(cond, trueBlock, falseBlock, mergeBlock) with the explicit fourth-operand merge block recording the structured join. | functional | [#ifelse](../../../design/ir-reference/control-flow.md#ifelse) | [`ifelse-structured-merge.slang`](ifelse-structured-merge.slang) |
| Boundary: an if with an empty then-arm but a non-empty else still lowers to the four-operand ifElse, with the true-arm operand pointing at the same block as the merge. | boundary | [#ifelse](../../../design/ir-reference/control-flow.md#ifelse) | [`ifelse-empty-then-arm.slang`](ifelse-empty-then-arm.slang) |
| Boundary: the ternary `?:` expression also lowers to the structured ifElse terminator — the surface-level expression form produces the same four-operand structured tuple as the statement form. | boundary | [#ifelse](../../../design/ir-reference/control-flow.md#ifelse) | [`ifelse-ternary-expression.slang`](ifelse-ternary-expression.slang) |
| Nested if-statements produce nested structured ifElse terminators — each level carries its own four-operand cond/true/false/merge tuple. | functional | [#ifelse](../../../design/ir-reference/control-flow.md#ifelse) | [`nested-ifelse.slang`](nested-ifelse.slang) |
| Stress: five nested if-statements produce five distinct structured ifElse terminators in the same function, each with the four-operand cond/true/false/merge tuple. | stress | [#ifelse](../../../design/ir-reference/control-flow.md#ifelse) | [`ifelse-deeply-nested-5.slang`](ifelse-deeply-nested-5.slang) |
| A do-while loop lowers to a loop terminator; the trailing test is implemented by negating the condition so the ifElse branches to break on true. | functional | [#loop](../../../design/ir-reference/control-flow.md#loop) | [`loop-do-while.slang`](loop-do-while.slang) |
| A for-loop lowers to loop(body, break, continue, ...) with the three structured-join operands plus initial values for body-block induction params. | functional | [#loop](../../../design/ir-reference/control-flow.md#loop) | [`loop-for-induction-param.slang`](loop-for-induction-param.slang) |
| A while-loop lowers to the same loop(body, break, continue, ...) terminator as a for-loop, with the induction variable arriving as a param on the body block. | functional | [#loop](../../../design/ir-reference/control-flow.md#loop) | [`loop-while.slang`](loop-while.slang) |
| Boundary: a `while (true)` loop whose only exit is an internal `return` lowers to `loop` with the body's continueLabel pointing at the body itself; the break block is reached only via the inner return path and is terminated by `unreachable`. | boundary | [#loop](../../../design/ir-reference/control-flow.md#loop) | [`loop-infinite-while-true.slang`](loop-infinite-while-true.slang) |
| Boundary: a loop with a statically-known single iteration (`for (int i=0; i<1; i++)`) still emits the loop terminator with the three structured-join operands plus induction args. | boundary | [#loop](../../../design/ir-reference/control-flow.md#loop) | [`loop-single-iteration-bound.slang`](loop-single-iteration-bound.slang) |
| Stress: two nested for-loops produce two independent loop terminators in the same function, each with its own (body, break, continue) structured-join triple. | stress | [#loop](../../../design/ir-reference/control-flow.md#loop) | [`loop-nested-two-deep.slang`](loop-nested-two-deep.slang) |
| The back-edge of a loop is an unconditionalBranch back to the loop header carrying the updated induction values as argument operands. | functional | [#loop](../../../design/ir-reference/control-flow.md#loop) | [`loop-induction-update-on-branch.slang`](loop-induction-update-on-branch.slang) |
| A switch in which every arm returns leaves the synthesized post-switch merge block terminated by `unreachable`. | functional | [#terminators-no-continuation](../../../design/ir-reference/control-flow.md#terminators-no-continuation) | [`switch-unreachable-merge.slang`](switch-unreachable-merge.slang) |
| Boundary: an if/else in which both arms `return` leaves the synthesized merge block terminated by `unreachable` — `unreachable` is not unique to switch all-arms-return. | boundary | [#terminators-no-continuation](../../../design/ir-reference/control-flow.md#terminators-no-continuation) | [`all-paths-return-ifelse.slang`](all-paths-return-ifelse.slang) |
| A `return expr;` in a value-returning function terminates the block with return_val(val) where val is the returned IR value. | functional | [#terminators-returns-and-yields](../../../design/ir-reference/control-flow.md#terminators-returns-and-yields) | [`return-val-int.slang`](return-val-int.slang) |
| A void function's terminator is return_val(void_constant), the unique VoidLit value. | functional | [#terminators-returns-and-yields](../../../design/ir-reference/control-flow.md#terminators-returns-and-yields) | [`return-val-void-constant.slang`](return-val-void-constant.slang) |
| An early-return inside a loop body produces a return_val terminator on a block that is not the function's natural exit — return is a per-block terminator independent of loop structure. | functional | [#terminators-returns-and-yields](../../../design/ir-reference/control-flow.md#terminators-returns-and-yields) | [`early-return-from-loop.slang`](early-return-from-loop.slang) |
| Boundary: a `return` from inside a switch case terminates that arm's block with `return_val`, independent of the surrounding switch's structured layout; the synthesized post-switch merge is reached by no path and is terminated by `unreachable`. | boundary | [#terminators-returns-and-yields](../../../design/ir-reference/control-flow.md#terminators-returns-and-yields) | [`early-return-in-switch-case.slang`](early-return-in-switch-case.slang) |
| Boundary: an early `return expr;` from three-deep nested ifs produces a `return_val` terminator on the innermost block — return is a terminator on the block that hosts it, independent of surrounding ifElse depth. | boundary | [#terminators-returns-and-yields](../../../design/ir-reference/control-flow.md#terminators-returns-and-yields) | [`early-return-at-depth-three.slang`](early-return-at-depth-three.slang) |
| A switch statement lowers to switch(value, breakLabel, defaultLabel, caseVal1, caseBlock1, ...) — scrutinee, break label, default label, then (caseValue, caseBlock) pairs. | functional | [#terminators-switches](../../../design/ir-reference/control-flow.md#terminators-switches) | [`switch-multi-case.slang`](switch-multi-case.slang) |
| Boundary: a switch with no `default:` arm still emits the switch terminator; the defaultLabel operand is supplied by the lowering as the post-switch merge block. | boundary | [#terminators-switches](../../../design/ir-reference/control-flow.md#terminators-switches) | [`switch-no-default.slang`](switch-no-default.slang) |
| Boundary: the smallest non-trivial switch — one explicit case plus default — still emits the switch terminator with the (value, break, default, caseVal, caseBlock) shape. | boundary | [#terminators-switches](../../../design/ir-reference/control-flow.md#terminators-switches) | [`switch-single-case-with-default.slang`](switch-single-case-with-default.slang) |
| Boundary: the source-text position of `default:` (before or after the case-arms) does not affect the switch terminator's operand layout — the default label is still the third operand. | boundary | [#terminators-switches](../../../design/ir-reference/control-flow.md#terminators-switches) | [`switch-default-before-cases.slang`](switch-default-before-cases.slang) |
| Case-label values in a switch terminator appear as inline literal operands `<N> : Int`, including for case labels named via static const. | functional | [#terminators-switches](../../../design/ir-reference/control-flow.md#terminators-switches) | [`switch-static-const-case-label.slang`](switch-static-const-case-label.slang) |
| Negative: two identical case labels in a switch are rejected at semantic-check time with E30605 "duplicate cases not allowed" — the IR's `(caseValue, caseBlock)` pair set is required to have unique caseValue entries, so the front-end refuses to lower a malformed switch. | negative | [#terminators-switches](../../../design/ir-reference/control-flow.md#terminators-switches) | [`switch-duplicate-case-labels-rejected.slang`](switch-duplicate-case-labels-rejected.slang) |
| Stress: a switch with eight case-arms emits all eight `(caseVal, caseBlock)` pairs as trailing operands on the single switch terminator. | stress | [#terminators-switches](../../../design/ir-reference/control-flow.md#terminators-switches) | [`switch-eight-case-arms.slang`](switch-eight-case-arms.slang) |
| A `break;` lowers to unconditionalBranch to the loop's breakLabel; a `continue;` lowers to unconditionalBranch to the loop's continueLabel. | functional | [#terminators-unconditional-branches](../../../design/ir-reference/control-flow.md#terminators-unconditional-branches) | [`loop-break-continue.slang`](loop-break-continue.slang) |
| Boundary: a `break;` inside a nested inner loop targets the inner loop's breakLabel only — the outer loop's break/continue labels are unchanged and both `loop` terminators retain their independent structured-join operands. | boundary | [#terminators-unconditional-branches](../../../design/ir-reference/control-flow.md#terminators-unconditional-branches) | [`break-from-inner-loop-only.slang`](break-from-inner-loop-only.slang) |
| Boundary: a `break;` inside a switch nested in a loop targets the switch's breakLabel — it leaves the switch, not the enclosing loop. Both a switch terminator and a loop terminator coexist with distinct break labels. | boundary | [#terminators-unconditional-branches](../../../design/ir-reference/control-flow.md#terminators-unconditional-branches) | [`break-from-switch-inside-loop.slang`](break-from-switch-inside-loop.slang) |
| unconditionalBranch carries argument operands beyond the target block; these become the values bound to the target's block params, the Slang-IR SSA phi handoff. | functional | [#terminators-unconditional-branches](../../../design/ir-reference/control-flow.md#terminators-unconditional-branches) | [`unconditional-branch-with-args.slang`](unconditional-branch-with-args.slang) |


## Untested claims
| Claim | Reason | Anchor | Why untested |
| --- | --- | --- | --- |
| **`defer`** — `DeferStmt` lowers to `defer`, but the natural surface form is non-trivial to write portably. | (unclassified) | [#defer](../../../design/ir-reference/control-flow.md#defer) | Reason and explanation to be refined by the next regeneration. |
| **`tryCall` / `throw`** — error-flow opcodes. The natural `try` / `throw` surface is unstable in current Slang dumps; exercise via `cross-cutting/ir-instructions` if at all. | (unclassified) | [#trycall](../../../design/ir-reference/control-flow.md#trycall) | Reason and explanation to be refined by the next regeneration. |
| **`yield`** — terminates the body of a `generic` value; observable only inside generic-instantiation IR, not via natural surface code. | (unclassified) | [#yield](../../../design/ir-reference/control-flow.md#yield) | Reason and explanation to be refined by the next regeneration. |
| **`gpuForeach`** — emitted by host-shader lowering only. | needs-unit-test | [#gpuforeach](../../../design/ir-reference/control-flow.md#gpuforeach) | No slangc CLI surface reaches this. A C++ unit test in `tools/slang-unit-test/` could exercise the relevant compiler internals directly. |
| **`conditionalBranch`** — listed in the doc as `(synthesized)` with no AST origin. The front-end emits structured `ifElse` for every surface conditional; no portable Slang surface produces `conditionalBranch` at the LOWER-TO-IR stage. | link-stage-only | [#conditionalbranch](../../../design/ir-reference/control-flow.md#conditionalbranch) | Synthesized at a later IR pass than this bundle's `pipeline_stage` observes; the test belongs in the bundle whose pipeline stage matches. |
| **`GenericAsm`** / **`RequirePrelude`** / **`RequireTargetExtension`** / **`RequireComputeDerivative`** / **`StaticAssert`** / **`Printf`** / **`RequireMaximallyReconverges`** / **`RequireQuadDerivatives`** — all `(synthesized)` or backend hints per the doc; no portable surface anchors them at LOWER-TO-IR. | link-stage-only | [#genericasm](../../../design/ir-reference/control-flow.md#genericasm) | Synthesized at a later IR pass than this bundle's `pipeline_stage` observes; the test belongs in the bundle whose pipeline stage matches. |
| **`missingReturn`** — synthesized by lowering when a non-`void` function falls off the end; the natural surface is rejected as a diagnostic before IR is dumped. | link-stage-only | [#missingreturn](../../../design/ir-reference/control-flow.md#missingreturn) | Synthesized at a later IR pass than this bundle's `pipeline_stage` observes; the test belongs in the bundle whose pipeline stage matches. |
| **`targetSwitch`** — synthesized by the multi-target lowering pass; not produced from surface code on the LOWER-TO-IR stage. | link-stage-only | [#targetswitch](../../../design/ir-reference/control-flow.md#targetswitch) | Synthesized at a later IR pass than this bundle's `pipeline_stage` observes; the test belongs in the bundle whose pipeline stage matches. |


## Doc gaps observed
| Anchor | Kind | Gap | Suggested addition |
| --- | --- | --- | --- |
| [#conditionalbranch](../../../design/ir-reference/control-flow.md#conditionalbranch) | undocumented-behavior | The doc lists `conditionalBranch` with no AST origin and notes that it is the lower-level form, but does not state explicitly that the front-end *never* emits it directly from surface code. | A note pinning the producer (some later IR-pass / legalization step) would make the cross-bundle boundary with `cross-cutting/ir-instructions` clearer. |
| [#loop](../../../design/ir-reference/control-flow.md#loop) | undocumented-behavior | The doc's `loop` section names the three structured-join operands but does not call out that a `do`-`while` lowering **inverts** the test (computing `not(cond)` and branching to break on true). | A one-line note in the `## loop` heading would prevent readers from expecting `cmpLT` + branch-to-body in the do-while case. |
| [#is-not-technically-a-terminator](../../../design/ir-reference/control-flow.md#is-not-technically-a-terminator) | undocumented-behavior | The doc says `discard` "is not technically a terminator" but does not name the terminator that *does* end its block in practice (`unconditionalBranch` to the post-`if` merge). | A short example of the surrounding shape would help. |
| [#terminators-no-continuation](../../../design/ir-reference/control-flow.md#terminators-no-continuation) | undocumented-behavior | The doc's `## terminators-no-continuation` heading describes `unreachable` semantically but does not name a natural surface that produces it (every-arm-returns inside a switch is the observed case). An example anchor would help. |  |
| [#ifelse](../../../design/ir-reference/control-flow.md#ifelse) | undocumented-behavior | The doc does not specify the operand layout of `ifElse` when one arm is empty: in practice the lowering reuses the merge block as the empty arm's target (e.g. `ifElse(cond, merge, falseBlock, merge)` for an empty then-arm). | Stating this collapsing rule would clarify how the four-operand invariant interacts with degenerate surface forms. |
| [#if](../../../design/ir-reference/control-flow.md#if) | undocumented-behavior | The doc does not call out that **both-empty** `if`/`else` blocks (degenerate, both arms empty) are eliminated at LOWER-TO-IR — no `ifElse` is emitted at all. Same for a zero-iteration `for (int i=0; i<0; i++)` and an empty switch body. | Naming this collapsing behavior would prevent readers from expecting a terminator that the IR never produces. |
| [#unreachable-after-return](../../../design/ir-reference/control-flow.md#unreachable-after-return) | undocumented-behavior | The doc does not call out that dead code following a `return` (e.g. `return x; return 0;`) is silently dropped during lowering — the second `return_val` does not appear in the IR dump, and no `unreachable` is synthesized either. | A note alongside the `return_val` row would clarify why "unreachable after return" is not an observable surface anchor for `unreachable`. |
| (unspecified) | undocumented-behavior | The doc does not mention that a `while (true)` loop with the only exit being an internal `return` produces a structured `loop` terminator whose **continueLabel and body are the same block**; the break label is reachable only via the return path and is terminated by `unreachable`. | Naming this shape would help readers recognize infinite-loop CFGs. |
| [#loopmaxitersn](../../../design/ir-reference/control-flow.md#loopmaxitersn) | undocumented-behavior | The doc does not mention the `[loopMaxIters(N)]` decoration emitted on `loop` terminators whose trip count is statically known. The decoration is observable in `-dump-ir` output and consumers (unroller, structured emitters) read it. | A one-line note alongside `loop` would document its presence. |
| [#terminators-switches](../../../design/ir-reference/control-flow.md#terminators-switches) | undocumented-behavior | The doc's `#terminators-switches` entry says case-value operands are `(caseValue, caseBlock)` pairs but does not pin the uniqueness invariant: duplicate case values are rejected by the front-end (E30605 in the checker), not by an IR validator. A cross-reference to the diagnostic would explain why the IR never sees a malformed switch. |  |
| [#discard](../../../design/ir-reference/control-flow.md#discard) | undocumented-behavior | The doc's `#discard` entry describes `discard` as the "HLSL fragment-shader instruction" but does not pin the corresponding diagnostic code emitted when `discard` is used from a compute or vertex entry point (E36107). A cross-reference would make the stage restriction enforceable from the doc. |  |
