wcc: ? error-subset propagation typecheck + tag remap

`expr?` previously did a brain-dead RET through whatever AX/DX/CX
held — only safe when operand and enclosing fn had identical variant
ordering. Tests relied on that alignment by construction.

Now:
- Typecheck: each non-first variant of operand must appear as a
  variant of the enclosing fn's return tagged union. Enclosing must
  itself be tagged (a non-tagged return has no slot for errors to
  land in).
- Cgen: on tag != 0, walk operand's error variants and emit a
  conditional tag remap (cmp/jne/mov/jmp) for any whose index in
  enclosing differs from operand's. Identity cases emit nothing,
  so same-shape operands cost zero extra instructions.

Selfhost cgen doesn't implement N_TRYPROP at all (no selfhost source
uses `?`); byte-identity tests still pass.

One existing e2e row used `?` with main returning i32 — relied on
the old loose semantics. Switched to `!` (abort-on-error); it was
exercising success-unwrap, not propagation.
This commit is contained in:
2026-05-12 01:40:02 +09:00
parent fa070b6d07
commit 41a82021a3
4 changed files with 113 additions and 4 deletions

View File

@@ -124,6 +124,17 @@ static const struct row rows[] = {
"ok" }, /* default arm absorbs missing variants */
{ "fn die() never = { for (true) { let _: i32 = 1; }; }; "
"fn f() i32 = { die(); };", "ok" }, /* never assignable to anything */
/* ? error-subset propagation typecheck */
{ "fn inner() (i32 | str) = { return 1; }; "
"fn outer() i32 = { let v: i32 = inner()?; return v; };",
"enclosing function must return a tagged union" },
{ "fn inner() (i32 | str | bool) = { return 1; }; "
"fn outer() (i64 | str) = { let v: i32 = inner()?; return v: i64; };",
"error variant bool not in enclosing return" },
{ "fn inner() (i32 | str | bool) = { return 1; }; "
"fn outer() (i64 | bool | str) = { let v: i32 = inner()?; return v: i64; };",
"ok" }, /* reversed-order error subset is OK */
};
int

View File

@@ -520,13 +520,54 @@ static const struct row rows[] = {
" };\n"
" return 0;\n"
"};", 4 },
/* ? success unwrap when the first variant is itself str */
/* ? with different success types but shared error: operand
* (i32 | str), enclosing (i64 | str). Success widens. */
{ "fn inner(b: bool) (i32 | str) = {\n"
" if (b) { return 7; };\n"
" return \"err\";\n"
"};\n"
"fn outer(b: bool) (i64 | str) = {\n"
" let v: i32 = inner(b)?;\n"
" return v: i64 + 100;\n"
"};\n"
"fn main() i32 = {\n"
" let r: (i64 | str) = outer(false);\n"
" match (r) {\n"
" case let n: i64 => return n: i32;\n"
" case let s: str => return s.len: i32;\n"
" };\n"
" return 0;\n"
"};", 3 },
/* ? with reversed error variant order: operand (i32 | str | bool),
* enclosing (i64 | bool | str). Tag remap maps str:1→2, bool:2→1. */
{ "fn inner(n: i32) (i32 | str | bool) = {\n"
" if (n == 0) { return \"z\"; };\n"
" if (n < 0) { return false; };\n"
" return n;\n"
"};\n"
"fn outer(n: i32) (i64 | bool | str) = {\n"
" let v: i32 = inner(n)?;\n"
" return v: i64 + 1000;\n"
"};\n"
"fn main() i32 = {\n"
" let r: (i64 | bool | str) = outer(-1);\n"
" match (r) {\n"
" case let n: i64 => return n: i32;\n"
" case let b: bool => { if (!b) { return 7; }; return 8; };\n"
" case let s: str => return 9;\n"
" };\n"
" return 0;\n"
"};", 7 },
/* ! success unwrap when the first variant is itself str.
* Uses ! (not ?) because main's return is i32, not tagged —
* ? would require error variants to be propagatable to the
* enclosing return. ! aborts on the error variant instead. */
{ "fn make() (str | i64) = {\n"
" return \"ok\";\n"
"};\n"
"fn main() i32 = {\n"
" let r: (str | i64) = make();\n"
" let v: str = r?;\n"
" let v: str = r!;\n"
" return v.len: i32;\n"
"};", 2 },
/* type error = str; named-alias variant works through the