Skip to content

Lower switches to if/conditional chains in a new SwitchDesugarer phase#417

Open
fabiomadge wants to merge 2 commits into
mainfrom
step-2a/suppress-switch-restoration
Open

Lower switches to if/conditional chains in a new SwitchDesugarer phase#417
fabiomadge wants to merge 2 commits into
mainfrom
step-2a/suppress-switch-restoration

Conversation

@fabiomadge
Copy link
Copy Markdown
Collaborator

@fabiomadge fabiomadge commented May 20, 2026

Step 2a of PLAN.md §3 — switch desugaring.

Resolves (partially)

Summary

Switch lowering moves into its own dedicated SwitchDesugarer phase that runs before suspend(lower(unsuspend(_))) in JavaLowerer. JavaToLaurelCompiler consumes the if/conditional shape directly, so the rewrite is one-way — no UNSUSPEND-style reversal.

Pre-LOWER tree mutations fall into two categories:

  • Defending shapes LOWER would mutate (records, asserts) — symmetric SUSPEND/UNSUSPEND. Stays in Suspenders.
  • Transforming shapes into LOWER-resistant equivalents (switches) — one-way. Lives in the new SwitchDesugarer.

They share a pipeline slot for non-incidental reasons (LOWER mangles switches into bytecode-flavored fall-through that loses source positions and case structure), but they're different concerns. Splitting them clarifies the contract: Suspenders shrinks back to its narrow symmetric purpose; SwitchDesugarer owns the one-way encoding plus the latent-bug fixes the prior round-trip used to mask.

What's in this PR

1. New SwitchDesugarer class with switchToIf (statement form) and switchExpressionToConditional (expression form). Suspenders no longer touches switches.

2. Fix latent bug: switch-expression empty-cases base case. The old recursion produced a false literal as falsepart, harmless when UNSUSPEND restored the switch but a real type mismatch when the if/conditional shape survives. LOWER inserts coercion casts JavaToLaurelCompiler doesn't accept. Restructured to locate the default case explicitly (arrow form allows it anywhere in the list) and use its body as the innermost falsepart.

3. Fix latent bug: switch-statement default-not-last. switchToIf produced an if-cascade in source order, so default not last lowered to else if (true) { ... } making every later case unreachable — silent miscompile. Partition non-default cases from the default before building the cascade, with the default body in the trailing else.

4. Fix latent bug: selector evaluated multiple times. Selectors were inlined into every generated equality test. switch (foo()) { case 0 -> ...; case 1 -> ...; } lowered to if (foo() == 0) ... else if (foo() == 1) ... — multiple evaluations of foo(), costing precision in verification for impure selectors. Hoist into a fresh local ($switchSelectorN) and reference it from each case. For switch expressions the result is a synthesized JCTree.LetExpr; JavaToLaurelCompiler#convertExpression gains a case lowering it to a Laurel block (a StmtExpr usable in expression position).

5. Make convertStatement renames-aware. convertExpression already took a renames map (used to rename postcondition-lambda parameters to "result"); convertStatement did not. The new LetExpr arm reaches convertStatement from inside a renamed scope, so the asymmetry now matters: a hoisted-selector's var $sel = lambdaParam would be lowered with the original lambda-param name and Strata would see an unbound identifier. Threading renames through convertStatement, convertBlock, and extractLoopParts keeps the LetExpr arm a one-liner and prevents the same bug class in future code paths that reach convertStatement from a renamed scope. SwitchDesugaring.switchInPostcondition is the regression test.

6. Refuse STATEMENT-form switches uniformly. caseToExpression already refused STATEMENT-form non-default cases; the partition loop, however, captured a STATEMENT-form default case unconditionally, then wrapped its body in a plain JCBlock. If the body contains break; (targeting the original switch), the desugared form orphans that break — it now retargets the enclosing loop instead of the (now-removed) switch. Without this guard, the bug would have become a silent mistranslation when #418 (break/continue support) landed in main. Two-line guard at the top of switchToIf, symmetric with caseToExpression's per-case refusal.

7. Translate compile-time constant references in expression position. case CONST_INT (where CONST_INT is a static final int CONST_INT = 42), int x = SomeClass.MAX, etc. javac keeps these as JCFieldAccess with the constant value attached on the expression's type. JavaToLaurelCompiler#convertExpression gains a JCFieldAccess arm gated on fa.type.constValue() != null that mirrors convertLiteral's tag-based dispatch. SwitchDesugaring.switchExprCaseConst covers same-class and external-class constants. Empirically verified that javac's Type.constValue() returns Integer for boolean/char (not Boolean/Character), so the dispatch is on the declared type tag, not the runtime value class.

What flips, what doesn't

Switches.java stays @JVerifyTest(skip = "...") because two of its eleven methods use case null on int @Nullable [], which depends on Step 9.

Forms SwitchDesugarer can't encode (case-statement groups, pattern guards, case null) currently fall through to LOWER, which mutates them into shapes the translator doesn't accept; those surface as method-level skips via Step 1's per-method catch in JavaToLaurelCompiler. Step 2b (#419) replaces those skips with diagnostic-quality refusals at SwitchDesugarer time.

On the LetExpr arm scope

LetExpr is also emitted by javac's Lower for boxed compound-assign, boxed postops, switch-on-string, and pattern-matching switches. All of those require features JVerify doesn't support yet, blocking those paths before LetExpr would reach the new arm. Today, only SwitchDesugarer-synthesized LetExpr reaches it. If a future feature flip enables one of those upstream paths, the broad arm consumes those LetExprs too — semantically correct (a LetExpr is a block-with-final-expression) but a silent scope expansion worth being aware of.

Test plan

  • ./gradlew :verifier:test passes.
  • No new uncaught JavaViolationException stack traces in test output.
  • No green → red transitions.
  • SwitchDesugaring.java covers arrow-form switches end-to-end (statements, expressions, with default in any position, char selector, block body, non-exhaustive, non-trivial selector for hoist coverage, switch expression in postcondition lambda, compile-time constant case labels, Bad assertion).

@fabiomadge fabiomadge force-pushed the step-2a/suppress-switch-restoration branch 4 times, most recently from 8d1a691 to 12d4e27 Compare May 20, 2026 15:09
@fabiomadge fabiomadge force-pushed the step-2a/suppress-switch-restoration branch 2 times, most recently from 711f08f to a889b0b Compare May 26, 2026 13:17
@fabiomadge fabiomadge changed the base branch from step-1/graceful-skip-and-char to main May 26, 2026 13:33
@fabiomadge fabiomadge force-pushed the step-2a/suppress-switch-restoration branch from a889b0b to 06b2046 Compare May 26, 2026 13:33
@fabiomadge fabiomadge marked this pull request as ready for review May 27, 2026 11:11
@fabiomadge fabiomadge marked this pull request as draft May 27, 2026 11:12
@fabiomadge fabiomadge changed the title Stop UNSUSPEND from restoring switches to JCSwitch nodes Lower switches to if/conditional chains in a new SwitchDesugarer phase May 27, 2026
@fabiomadge fabiomadge force-pushed the step-2a/suppress-switch-restoration branch 6 times, most recently from fccec7f to 51290c8 Compare May 28, 2026 10:53
@fabiomadge fabiomadge marked this pull request as ready for review May 28, 2026 11:32
@fabiomadge fabiomadge force-pushed the step-2a/suppress-switch-restoration branch from 51290c8 to 153b060 Compare May 28, 2026 12:57
@fabiomadge fabiomadge marked this pull request as draft May 28, 2026 13:05
@fabiomadge fabiomadge force-pushed the step-2a/suppress-switch-restoration branch from 153b060 to 769a70a Compare May 28, 2026 13:07
Switch desugaring lives in its own TreeTranslator that runs as a
dedicated phase before suspend/lower/unsuspend in JavaLowerer.

Rationale: pre-LOWER tree mutations fall into two categories — defending
shapes LOWER would mutate (records, asserts; symmetric) and transforming
shapes into LOWER-resistant equivalents (switches; one-way). They share
a pipeline slot for non-incidental reasons (LOWER mangles switches into
bytecode-flavored fall-through that loses source positions and case
structure), but they're different concerns. Keeping them in one class
under the "Suspenders" name conflated reversible defense with one-way
lowering.

Suspenders shrinks back to its original symmetric concern: RECORD-flag
restoration around LOWER, and assert rewrite/restore around LOWER.
Switch handling — the if/conditional encoding from this commit's first
revision — moves verbatim to SwitchDesugarer with no semantic change.

Three latent bugs the round-trip masking used to cover are still fixed
in SwitchDesugarer:
  - empty-cases base case in switchExpressionToConditional produced a
    boolean falsepart that LOWER coerced via casts JavaToLaurelCompiler
    can't accept; restructured to terminate recursion via the default
    case explicitly (arrow form allows default in any position).
  - switch statements with `default` not last lowered to
    `else if (true) { ... }` making every later case unreachable;
    partition before recursing.
  - selector expressions were inlined into every generated equality
    test, evaluating side-effecting selectors multiple times; hoist
    into a fresh local ($switchSelectorN) and reference it from each
    case. For switch expressions, the result is a synthesized
    JCTree.LetExpr; JavaToLaurelCompiler#convertExpression gains a
    case lowering it to a Laurel block.

The renames-aware LetExpr handling and SwitchDesugaring/SwitchInPostcondition
regression tests stay as in 2a's first revision.

Step 2a of PLAN.md §3. Step 2b will follow with diagnostic-quality
refusals for forms SwitchDesugarer can't encode (case-statement groups,
non-literal labels, pattern guards, case null).

Test plan:
  - SwitchDesugaring regression test covers arrow-form expressions and
    statements (with/without default, default-not-last, block-body,
    non-exhaustive, char selector, non-trivial selector for hoist
    coverage, Bad assertion case).
  - SwitchInPostcondition is a regression test for the lambda-param
    rename leak through the LetExpr's VarDef initializer.
  - Switches.java stays @JVerifyTest(skip = "..."): the two case-null
    methods need Step 9 before the full fixture flips.
  - Full suite: 115 / 0 / 72 (was 113 / 0 / 72; +SwitchDesugaring,
    +SwitchInPostcondition).
caseToExpression already refuses STATEMENT-form non-default cases. The
partition loop at line 113-121, however, captures a STATEMENT-form
default case unconditionally. switchToIf would then build a desugared
shape:

  Block([selVarDecl, Block(defaultCase.stats)])

If the default body contains a 'break;' targeting the switch, the
desugared form orphans that break — it now targets the enclosing loop
(if any) instead of the (now-removed) switch. JavaToLaurelCompiler's
JCBreak handling resolves through its own labelStack and would emit
exit() to the wrong target.

The bug is dormant on main (no JCBreak handling yet) but becomes a
silent mistranslation once #418 (break/continue support) lands.

Fix: refuse STATEMENT-form switches uniformly. Two-line guard at the
top of switchToIf, symmetric with caseToExpression's per-case
STATEMENT refusal. Refused trees fall through to LOWER, which mangles
them into shapes JavaToLaurelCompiler doesn't accept; Step 1 then
surfaces them as method-level skips.

Step 2b (#419) replaces the silent skip with per-construct refusal
diagnostics.
@fabiomadge fabiomadge marked this pull request as ready for review May 28, 2026 13:43
@fabiomadge fabiomadge force-pushed the step-2a/suppress-switch-restoration branch from 769a70a to 65f09cc Compare May 28, 2026 13:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant