Skip to content

fix(types): use structural termination analysis for post-guard narrowing fallthrough#599

Merged
nahime0 merged 5 commits into
illegalstudio:mainfrom
mirchaemanuel:fix/590-narrowing-structural-termination
Jul 25, 2026
Merged

fix(types): use structural termination analysis for post-guard narrowing fallthrough#599
nahime0 merged 5 commits into
illegalstudio:mainfrom
mirchaemanuel:fix/590-narrowing-structural-termination

Conversation

@mirchaemanuel

Copy link
Copy Markdown
Contributor

Summary

Fixes #590. Checker::body_cannot_fall_through() — the helper that decides whether an if-guard body prevents fall-through, so the complement narrowing can be kept for the statements after the guard — only inspected body.last() against a small set of direct statement kinds. It therefore dropped a valid post-guard narrowing whenever the guard body could not fall through only through structure that check missed:

  • a terminal statement placed before unreachable trailing code (return; echo "x";);
  • a nested if/switch/try whose branches all terminate.

As a result, a guarded ?array (or any ?T) value was not narrowed to its non-null complement after the guard, so e.g. list-unpacking it failed to type-check.

Root cause

body_cannot_fall_through() kept its own last-statement-only model instead of using the compiler's structural control-flow analysis in src/termination.rs. block_terminal_effect already scans a block for the first non-fallthrough statement and recurses into nested if/switch/try by effect kind, so it naturally handles terminal-before-unreachable and all-branches-terminate shapes that the last-statement matcher could not.

Fix

Delegate the fallthrough decision to block_terminal_effect(body) != TerminalEffect::FallsThrough:

  • This uses the "cannot reach the following statement" notion, so break/continue count as non-fallthrough (they prevent reaching the statement after the guard even though they do not exit the function) — distinct from "guarantees function exit". Post-guard narrowing needs exactly this distinction, and src/termination.rs already models the effect kinds (Return/Throw/Continue/Break(n>1)ExitsCurrentBlock, Break(1)Breaks).
  • The structural model deliberately does not model exit(), die(), or a user function declared never (the last needs the checker's function table). That narrowing-specific divergence is preserved as an overlay (stmt_is_diverging_call), which now also fires when the diverging call precedes unreachable trailing code, not only as the block's last statement.

Checker-side only; src/termination.rs is consumed, not modified.

Test plan

Red→green verified against the issue's shapes. New tests:

tests/codegen/types/narrowing.rs (runtime, narrowing kept):

  • test_narrow_after_terminal_return_before_unreachable_code (shape 1)
  • test_narrow_after_nested_if_all_branches_terminate (shape 2)
  • test_narrow_after_nested_switch_all_paths_terminate
  • test_narrow_after_nested_try_all_paths_terminate
  • test_narrow_after_continue_before_unreachable_code
  • test_narrow_after_exit_before_unreachable_code
  • test_narrow_after_never_call_before_unreachable_code

tests/error_tests/narrowing.rs (narrowing correctly NOT kept):

  • test_no_narrow_when_nested_branch_falls_through
  • test_no_narrow_when_switch_has_no_default

Focused runs (all green): cargo test --test error_tests -- narrowing list_unpack never warnings and cargo test --test codegen_tests -- narrow list_unpack arrays::list_unpack. Existing return/throw/exit/die/declared-never narrowing tests and #522's list-unpack tests remain green.

Related

May also resolve #508 (nullable narrowing after an === null early-exit guard): its minimal exit/return repro already type-checks on current main (resolved by #522), and the nested === null early-exit shapes it describes now narrow correctly with this change.

https://claude.ai/code/session_01HRvbcjPHa3kAojdCjNZL5L

…ing fallthrough

`Checker::body_cannot_fall_through()` only inspected `body.last()` against a
small set of direct statement kinds, so a post-`if`-guard narrowing was dropped
whenever the guard body could not fall through only through structure the
last-statement check missed: a terminal statement before unreachable trailing
code, or a nested `if`/`switch`/`try` whose branches all terminate.

Delegate the fallthrough decision to the structural control-flow analysis in
`src/termination.rs` (`block_terminal_effect`), which already models
return/throw/break/continue and nested terminal forms by effect kind. This uses
the "cannot reach the following statement" notion — `break`/`continue` are valid
non-fallthrough effects — rather than "guarantees function exit". The
narrowing-specific divergence the structural model does not track (`exit()`,
`die()`, and user functions declared `never`, which needs the checker's function
table) is preserved as an overlay that now also fires when the diverging call
precedes unreachable code.

Fixes illegalstudio#590.

Claude-Session: https://claude.ai/code/session_01HRvbcjPHa3kAojdCjNZL5L
@github-actions github-actions Bot added area:types Touches type checking, inference, or compatibility. size:s Small pull request. type:fix Corrects broken or incompatible behavior. labels Jul 23, 2026
@github-actions github-actions Bot added size:m Medium-sized pull request. and removed size:s Small pull request. labels Jul 24, 2026

nahime0 commented Jul 24, 2026

Copy link
Copy Markdown
Member

Updated this PR in place and merged the current main (0fd01c162) into the contributor branch.

Follow-up after the structural termination review:

  • Moved exit() / die() into the shared terminal-effect model instead of keeping them in a checker-only overlay.
  • Added a recursive caller-supplied divergence hook so checker-known user functions declared never participate in nested if / switch / try analysis.
  • Extended non-fallthrough analysis to statically infinite while / for loops and mandatory terminating do / while bodies, while remaining conservative when a loop can break.
  • Reused the same checker-aware traversal for declared return coverage, so value-returning functions may terminate through nested never calls.
  • Removed the shallow top-level body.iter().any(...) overlay and added positive and negative regressions for nested exit / die / never, loops, switch, try, and fallthrough paths.

The semantic distinction remains intentional: break / continue are local non-fallthrough effects for post-guard narrowing, but are not treated as function exits.

Focused validation on the published head:

  • cargo build
  • 13 post-guard narrowing codegen tests
  • 5 declared-return coverage tests
  • 4 conservative narrowing error tests
  • focused optimizer regressions for exit, try/finally DCE, and while (true) break propagation
  • rustfmt --check and git diff --check

Published head: 8c426e6a5bd41b2b6346f684d608995ac6b0ef15.

GitHub currently reports the PR as mergeable and aligned with main. Build/archive, generated-doc/stub, and label checks are green for this exact SHA; the supported-target test matrix is still in progress.

@nahime0
nahime0 merged commit ee11456 into illegalstudio:main Jul 25, 2026
113 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:types Touches type checking, inference, or compatibility. size:m Medium-sized pull request. type:fix Corrects broken or incompatible behavior.

Projects

None yet

2 participants