From e55dc1dc72a4c3fdd6735f4d5758230c5a140731 Mon Sep 17 00:00:00 2001 From: DslsDZC Date: Sat, 24 Oct 2026 04:21:46 +0800 Subject: [PATCH 01/22] docs: add v10 multibranch implementation plan --- .../plans/2026-10-24-multibranch-v10.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-24-multibranch-v10.md diff --git a/docs/superpowers/plans/2026-10-24-multibranch-v10.md b/docs/superpowers/plans/2026-10-24-multibranch-v10.md new file mode 100644 index 00000000..a9133294 --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-multibranch-v10.md @@ -0,0 +1,220 @@ +# v10 多路分支 / match 决策 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 v10 `.ccr` 上实现多路 `MATCH` 节点和 CASE 表:corec 把 `match` 降为多路分支,corearch 映射为比较链/二分/跳转表,interp 与 ELF 语义一致。 + +**Architecture:** 保留 `IR_BRANCH` 作为 `if`/`while` 的二元分支;新增 `IR_MATCH` 和 CASE 记录。CASE 是 v10 第 9 段,记录为 24B `{value, kind, str_index, target_nod, flags}`。先做整数/bool/enum 标签的多路分派;字符串/struct/ident 模式在后续任务补齐。 + +**Spec:** `docs/superpowers/specs/2026-10-23-pattern-matching-ir-design.md`(v9.1 设计,本计划适配 v10)+ `docs/superpowers/specs/2026-10-23-lattice-ccr-v10-design.md` §11 CASE。 + +## 全局约束 + +- 不改变 `IR_BRANCH` 的二元编码;`if`/`while` 继续走现有路径。 +- `MATCH` 只表示多路分派;`CASE` 不存宽度、偏移、ABI 或机器码。 +- CASE 值只进入 v10 CASE 段;字符串模式后续归一化为 `str_eq`,不把字符串指针放进 CASE。 +- 每个任务单独提交;先 CASE/IR_MATCH 读写,再 corec,再 corearch/interp。 +- 自举要求 stage2/stage3 逐字节一致;`test_bootstrap` 和 canary 不因中间态长时间红。 +- `jj` only;子代理每个任务新开,禁止并行实现同一文件。 +- 所有长命令 `nice -n 19`。 + +--- + +### Task 1: IR_MATCH/CASE schema 与 v10 读写 + +**Files:** +- Modify: `src/compiler/ast.cr` +- Modify: `src/compiler/globals.cr` +- Modify: `src/compiler/ccr_schema.cr` +- Modify: `src/compiler/ccr_io.cr` +- Test: `tests/selfhost/test_multibranch_v10.py` + +**Interfaces:** +- Consumes: v10 CASE 段 tag 9,24B 记录。 +- Produces: + - `IR_MATCH : int = 54` + - `g_ccr_case : string`, `g_ccr_case_count : int` + - CASE record offsets/stride: + - `CCR_CASE_VALUE=0` (8B signed) + - `CCR_CASE_KIND=8` (4B) + - `CCR_CASE_STR=12` (4B signed) + - `CCR_CASE_TARGET=16` (4B signed NOD label id) + - `CCR_CASE_FLAGS=20` (4B) + - `CCR_CASE_DISK=24` + - `CCR_CASE_KIND_VALUE=0`, `KIND_ENUM=1`, `KIND_STRING=2`, `KIND_DEFAULT=3` + +- [ ] **Step 1: failing test** + +Create a Python test that builds a `.ccr` with an `IR_MATCH` node and 3 CASE records, parses the CASE segment independently, and asserts count/order/value/target. Also assert a v10 file with CASE count > 0 is not rejected by the container (once implemented). + +- [ ] **Step 2: implement schema** + +Add `IR_MATCH`; `ccr_op_is_known` includes it; CASE segment writer reads `g_ccr_case`; loader rebuilds it. For now `g_ccr_case_count=0` unless corec populates. CASE segment size = `4 + count*24`. Add bounds/str/target checks; default exactly one optional. + +- [ ] **Step 3: verify** + +```bash +python3 tests/selfhost/test_multibranch_v10.py +python3 tests/selfhost/test_ccr_v10_format.py +python3 tests/selfhost/test_ccr_v10_stream.py +nice -n 19 python3 build_selfhost_native.py +``` + +- [ ] **Step 4: commit** + +`jj commit -m "ccr: add v10 match and case schema"` + +--- + +### Task 2: corec `match` 降为 MATCH + +**Files:** +- Modify: `src/compiler/ir_gen.cr` +- Modify: `src/compiler/dump.cr` +- Modify: `src/compiler/ccr_types.cr` +- Modify: `src/compiler/cir_cache.cr` +- Test: `tests/selfhost/test_multibranch_v10.py` + +**Interfaces:** +- Consumes: Task 1 `IR_MATCH`, `g_ccr_case`. +- Produces: one `IR_MATCH` for a match whose arms are int/bool/enum/wildcard; body labels are existing labels; default is a `KIND_DEFAULT` case. +- Unsupported patterns keep the existing sequential lowering until Task 4; do not silently change semantics. + +- [ ] **Step 1: RED fixture** + +4-way int match and enum match; assert exactly one `MATCH` in the function and CASE count = arms + default; old code produces zero. + +- [ ] **Step 2: implement** + +Collect cases in arm order, dedupe by value keeping first, `KIND_DEFAULT` for `_`; emit `MATCH` with `src1=scrutinee`, `src2=case_start`, `src3=case_count`. Keep optional representation special path unchanged for now. If a match has string/struct/ident patterns, fall back to existing code and mark a TODO; do not emit a wrong MATCH. + +- [ ] **Step 3: CIR cache** + +Disable CIR cache for functions containing `MATCH` in this task (simplest correct); add a per-function `g_cir_has_match` reset and check. Task 5 may replace with CASE range persistence. + +- [ ] **Step 4: dump** + +`--dump-objects` prints `match` with case range and cases. + +- [ ] **Step 5: verify** + +```bash +python3 tests/selfhost/test_multibranch_v10.py +nice -n 19 python3 tests/selfhost/test_ccr_types.py +nice -n 19 python3 build_selfhost_native.py +./build/corec check src/compiler +``` + +- [ ] **Step 6: commit** + +`jj commit -m "ccr: lower integer and enum matches to v10 MATCH"` + +--- + +### Task 3: corearch / interp / dataflow support + +**Files:** +- Modify: `src/arch/x86_64/instr.cr` +- Modify: `src/arch/x86_64/regalloc.cr` +- Modify: `src/arch/x86_64/sizes.cr` if present +- Modify: `src/compiler/dataflow.cr` +- Modify: `src/compiler/interp.cr` +- Modify: `src/compiler/ccr_io.cr` validation +- Test: `tests/selfhost/test_multibranch_v10.py` + +**Interfaces:** +- `MATCH` successors = all case targets (plus default); regalloc handles multiple successors. +- corearch emits: + - count 0 -> default jump/trap + - count 1..2 -> cmp/jcc chain + - count >2 -> binary search by sorted value (Task 4 can replace with jump table) +- interp reads the loaded case table and jumps to the target; default on no hit. +- `MATCH` target labels must be valid NOD labels and in function span; loader validates. + +- [ ] **Step 1: RED** 4-way int match ELF and interp; both must return the selected arm; current no MATCH path fails. +- [ ] **Step 2: implement**; keep `IR_BRANCH` code untouched. +- [ ] **Step 3: verify**: +```bash +python3 tests/selfhost/test_multibranch_v10.py +python3 tests/selfhost/test_interp_parity.py +python3 tests/selfhost/test_native_memory.py +python3 tests/selfhost/test_backend_bootstrap.py +``` +- [ ] **Step 4: commit** +`jj commit -m "backend: lower v10 MATCH to compare chains"` + +--- + +### Task 4: dense jump table / sparse binary search + +**Files:** +- Modify: `src/arch/x86_64/instr.cr` +- Modify: `src/arch/x86_64/regalloc.cr` +- Modify: `src/arch/x86_64/sizes.cr` +- Test: `tests/selfhost/test_multibranch_v10.py` + +**Interfaces:** +- Dense: `count >= 8 && max-min+1 <= 2*count` -> 32-bit relative table in rodata; range check; default out-of-range. +- Sparse: sorted binary comparison tree. +- `s3==1` with value 1 and default remains same machine shape as existing `IR_BRANCH` if possible. +- Add 16/64-way benchmarks and assert no linear chain on dense case. + +- [ ] **Step 1: RED** 64-way dense fixture; assert ELF contains table path / bounded comparisons. +- [ ] **Step 2: implement** with label patch mechanism; no PIE/dynamic relocations. +- [ ] **Step 3: verify and commit** +`ccr: add jump-table lowering for dense match` + +--- + +### Task 5: pattern coverage + +**Files:** +- Modify: `src/compiler/parser.cr` (tuple pattern if missing) +- Modify: `src/compiler/checker.cr` +- Modify: `src/compiler/ir_gen.cr` +- Modify: `bootstrap/corec/frontend/desugar.py` +- Test: `tests/selfhost/test_multibranch_v10.py` + +**Interfaces:** +- string/char patterns normalize to `str_eq` and binary MATCH/default. +- struct/tuple fields load then recursively dispatch. +- ident binds; wildcard default. +- literal exhaustiveness: `_` or irrefutable arm else `EC_TM_EXHAUST`. +- corec source must avoid unsupported bootstrap match patterns; extend desugar if needed. + +- [ ] **Steps** TDD per pattern: string, char, struct, tuple, ident; behavior parity; no silent first-arm. +- [ ] **Commit** `ccr: cover string struct and ident match patterns` + +--- + +### Task 6: cleanup, cache, canary + +**Files:** +- Modify: `src/compiler/cir_cache.cr` (persist CASE range or keep disable) +- Modify: `tools/baseline/canary_values.tsv` +- Modify: `docs/superpowers/specs/2026-10-23-pattern-matching-ir-design.md` (v10 adaptation) +- Test: `tests/selfhost/test_multibranch_v10.py` + +- [ ] Replace temporary cache disable with CASE-range persistence if feasible; otherwise document. +- [ ] Update bootstrap parser for V4 syntax tests if CI still red. +- [ ] Run full gate and relock only affected `.ccr`/ELF with attribution. +- [ ] Commit. + +## Final Gate + +```bash +nice -n 19 python3 build_selfhost_native.py +./build/corec check src/compiler +python3 tests/selfhost/test_multibranch_v10.py +python3 tests/selfhost/test_ccr_v10_format.py +python3 tests/selfhost/test_ccr_v10_stream.py +python3 tests/selfhost/test_ccr_types.py +python3 tests/selfhost/test_backend_bootstrap.py +python3 tests/selfhost/test_interp_parity.py +python3 tests/selfhost/test_native_memory.py +python3 tests/harness/test_ci_hook_coverage.py +bash tools/baseline/canary_check.sh +./build/corec build src/compiler/main.cr -o build/corec2 --static -O 0 +./build/corec2 build src/compiler/main.cr -o build/corec3 --static -O 0 +cmp build/corec2 build/corec3 +``` From db7d963dc69812a1a835d1301d1c88c453398de7 Mon Sep 17 00:00:00 2001 From: DslsDZC Date: Sat, 24 Oct 2026 04:24:50 +0800 Subject: [PATCH 02/22] ccr: add v10 match and case schema --- src/compiler/ast.cr | 1 + src/compiler/ccr_io.cr | 143 ++++++++- src/compiler/ccr_schema.cr | 17 + src/compiler/globals.cr | 7 + tests/selfhost/test_multibranch_v10.py | 421 +++++++++++++++++++++++++ 5 files changed, 577 insertions(+), 12 deletions(-) create mode 100644 tests/selfhost/test_multibranch_v10.py diff --git a/src/compiler/ast.cr b/src/compiler/ast.cr index 53884848..d94b4970 100644 --- a/src/compiler/ast.cr +++ b/src/compiler/ast.cr @@ -645,6 +645,7 @@ IR_F2I : int = 50 // dest=int_var, s1=dex_var(bits); binary64 to int(cvttsd IR_APPROX : int = 51 //无操作数注解:apx 变量声明处(语义许可,后端可忽略,interp 跳过) IR_PTR_OF : int = 52 // dest=same int var, s1=same string var; view/convert address to mathematical int IR_STR_OF : int = 53 // dest=same string var, s1=same int var; view/convert mathematical int to raw address +IR_MATCH : int = 54 // src1=scrutinee var, src2=CASE table start, src3=case count; multi-way dispatch // Resolution flag for BRANCH/JUMP (stored in type_kind field after label resolution) IR_RESOLVED : int = 1 diff --git a/src/compiler/ccr_io.cr b/src/compiler/ccr_io.cr index 5d3c9a58..b9c3db64 100644 --- a/src/compiler/ccr_io.cr +++ b/src/compiler/ccr_io.cr @@ -31,6 +31,11 @@ // type_id i32, flags u32, first_edge u32, edge_count u32}]. // src2/src3 never carry width, byte offset, stride, or alignment. // EDG(8): [count][8B {to_nod u32, kind u32}]. +// CASE(9): [count][24B {value i64, kind u32, str_index i32, target_nod i32, +// flags u32}]. kind is 0=value, 1=enum, 2=string, 3=default. target_nod is +// a NOD id whose node is IR_LABEL. str_index is -1 or a STR index, and a +// string case needs a real index. flags is reserved and zero in v10. CASE is +// last so every target and STR reference is already visible when it loads. // ENT(6): [count][24B {var_id i32, version u32, def_nod i32, live_start u32, // live_end u32, flags u32}]. home is not part of v10. // REG(7): [count][24B {kind u32, parent i32, enter_nod u32, exit_nod u32, @@ -340,8 +345,45 @@ fn ccr_reg_seg_size() -> int: return 4 + g_sg_count * CCR_V10_REG_DISK fn ccr_case_seg_size() -> int: - // CASE is an empty table until match lowering writes it: 4B case_count = 0. - return 4 + // CASE is 4B case_count plus 24B records. Match lowering fills g_ccr_case; + // before that the count is zero and the segment is a bare 4B zero. + return 4 + g_ccr_case_count * CCR_CASE_DISK + +// Write-side CASE domain check. The in-memory table is the source of truth, so +// a malformed row must reject the save instead of producing a file the loader +// would refuse. Targets are NOD ids of LABEL nodes; the value domain is checked +// by the projection that consumes it, not here. +fn ccr_validate_cases() -> int: + if g_ccr_case_count < 0: + return 0 + defaults : ., mut = 0 + ci : ., mut = 0 + loop: + if ci >= g_ccr_case_count: + break + base := ci * CCR_CASE_DISK + kind := cell_load32(g_ccr_case, base + CCR_CASE_KIND) + sidx := cell_load32(g_ccr_case, base + CCR_CASE_STR) + target := cell_load32(g_ccr_case, base + CCR_CASE_TARGET) + cflags := cell_load32(g_ccr_case, base + CCR_CASE_FLAGS) + if kind < CCR_CASE_KIND_VALUE || kind > CCR_CASE_KIND_DEFAULT: + return 0 + if sidx < -1 || sidx >= g_str_count: + return 0 + if kind == CCR_CASE_KIND_STRING && sidx < 0: + return 0 + if target < 0 || target >= g_ir_instr_count: + return 0 + if iri_op(target) != IR_LABEL: + return 0 + if cflags != 0: + return 0 + if kind == CCR_CASE_KIND_DEFAULT: + defaults = defaults + 1 + if defaults > 1: + return 0 + ci = ci + 1 + return 1 // ── Header flag classification (spec §3.1) ────────────────────────────── // SYSTEM boundary ops: runtime/FFI/ABI surfaces. ARENA_NEW only counts when the @@ -388,12 +430,12 @@ fn ccr_op_is_concurrent(op: int) -> int: return 1 return 0 -// v10 opcode table. The IR opcode values are assigned densely 0..53 in ast.cr; +// v10 opcode table. The IR opcode values are assigned densely 0..54 in ast.cr; // anything outside that table is not a v10 operation. Rejecting unknown opcodes // before emission also prevents emit_instr from silently falling through as a // zero-byte instruction. fn ccr_op_is_known(op: int) -> int: - if op >= IR_NOP && op <= IR_STR_OF: + if op >= IR_NOP && op <= IR_MATCH: return 1 return 0 @@ -527,6 +569,14 @@ fn ccr_validate_nod_operands() -> int: return -1 if ccr_label_known(ids, ln, s2) == 0 || ccr_label_known(ids, ln, s3) == 0: return -1 + else if op == IR_MATCH: + // Minimal v10 MATCH shape: scrutinee var plus a non-negative + // CASE range. The range is checked against the CASE table after + // that segment loads (match projection work lands with the backend). + if ccr_nod_var_ok(s1) == 0: + return -1 + if s2 < 0 || s3 < 0: + return -1 else if op == IR_JUMP || op == IR_LABEL: if ccr_label_known(ids, ln, s1) == 0: return -1 @@ -592,7 +642,7 @@ fn ccr_validate_nod_operands() -> int: // fact. ALLOC_ARRAY/ADDR_INDEX/DEREF/STORE_PTR are handled explicitly because // their old src2/src3 facts are the migration's critical cases. fn ccr_nod_src3_semantic(op: int) -> int: - if op == IR_UNARY || op == IR_BINARY || op == IR_CALL || op == IR_ALLOC_STRUCT || op == IR_LOAD_FIELD || op == IR_STORE_FIELD || op == IR_STORE_INDEX || op == IR_BRANCH || op == IR_SLICE || op == IR_SPAWN || op == IR_HOTPATCH_ROUTE || op == IR_CALL_EXTERN || op == IR_LOAD_INDEX: + if op == IR_UNARY || op == IR_BINARY || op == IR_CALL || op == IR_ALLOC_STRUCT || op == IR_LOAD_FIELD || op == IR_STORE_FIELD || op == IR_STORE_INDEX || op == IR_BRANCH || op == IR_MATCH || op == IR_SLICE || op == IR_SPAWN || op == IR_HOTPATCH_ROUTE || op == IR_CALL_EXTERN || op == IR_LOAD_INDEX: return 1 return 0 @@ -812,6 +862,10 @@ fn save_ccr(path: string) -> int: // lossy file instead of letting w32 keep only the low bits. if ccr_validate_i32_fields() == 0: return -1 + // CASE rows are in-memory 24B records. Reject a malformed table here so the + // writer never emits a file the loader would reject. + if ccr_validate_cases() == 0: + return -1 // ENT data is computed first. The corec writer calls compute_live_ranges(), // which calls compute_entries per function and fills the in-memory 24B @@ -1325,10 +1379,26 @@ fn save_ccr(path: string) -> int: pos = pos + 4 // kind ei3 = ei3 + 1 - // === CASE: empty table for now (4B case_count = 0). CASE is last because it - // may reference every NOD target once match lowering fills it in. - buf_write_u32(buf, pos, 0) + // === CASE: 24B records from g_ccr_case. CASE is last because targets may + // reference every NOD label and string cases may reference every STR entry. + buf_write_u32(buf, pos, g_ccr_case_count) pos = pos + 4 + ci3 : ., mut = 0 + loop: + if ci3 >= g_ccr_case_count: + break + cbase := ci3 * CCR_CASE_DISK + buf_write_i64(buf, pos, cell_load(g_ccr_case, cbase + CCR_CASE_VALUE)) + pos = pos + 8 + buf_write_u32(buf, pos, cell_load32(g_ccr_case, cbase + CCR_CASE_KIND)) + pos = pos + 4 + buf_write_i32(buf, pos, cell_load32(g_ccr_case, cbase + CCR_CASE_STR)) + pos = pos + 4 + buf_write_i32(buf, pos, cell_load32(g_ccr_case, cbase + CCR_CASE_TARGET)) + pos = pos + 4 + buf_write_u32(buf, pos, cell_load32(g_ccr_case, cbase + CCR_CASE_FLAGS)) + pos = pos + 4 + ci3 = ci3 + 1 // The size calculator and the writer are two independent walks. A mismatch // means a section changed without updating calc_ccr_size; refuse to write a @@ -1565,6 +1635,8 @@ fn load_ccr(data: string, fsize: int) -> int: g_entry_count = 0 g_v7_edge_count = 0 g_v7_nod_count = 0 // NOD 对象缓冲计数(NOD 段载入后置位) + g_ccr_case = "" + g_ccr_case_count = 0 // TYPE 段有真实内存表(g_types 记录表 + 项层 g_type_terms/ // g_tt_index),复位在 TYPE 段解析处(记录表大小随段内容,且需与项层 memo // 同步作废;见该段注释)。 @@ -2623,17 +2695,64 @@ fn load_ccr(data: string, fsize: int) -> int: return -1 // 2 行走完 == 段体大小 - // === CASE: empty table for now === - // The loader requires case_count == 0 until match lowering writes CASE. Any - // other count is an unknown shape, so reject instead of treating it as empty. + // === CASE: [case_count][24B records] === + // Targets are NOD ids of LABEL nodes; CASE loads after NOD, so the whole + // graph is visible. Records are bounds-checked before each read and the + // walk must end exactly at the segment boundary. if !ccr_has_bytes(pos, 4, seg_end9): return -1 case_cnt := buf_read_u32(data, pos) pos = pos + 4 - if case_cnt != 0: + if case_cnt > (seg_end9 - pos) / CCR_V10_CASE_DISK: return -1 + case_buf : string, mut = "" + if case_cnt > 0: + case_buf = alloc(case_cnt * CCR_CASE_DISK) + case_defaults : ., mut = 0 + ci4 : ., mut = 0 + loop: + if ci4 >= case_cnt: + break + if !ccr_has_bytes(pos, CCR_V10_CASE_DISK, seg_end9): + return -1 + cbase := ci4 * CCR_CASE_DISK + cval := buf_read_i64(data, pos) + pos = pos + 8 + ckind := buf_read_i32(data, pos) + pos = pos + 4 + csidx := buf_read_i32(data, pos) + pos = pos + 4 + ctarget := buf_read_i32(data, pos) + pos = pos + 4 + cflags := buf_read_u32(data, pos) + pos = pos + 4 + if ckind < CCR_CASE_KIND_VALUE || ckind > CCR_CASE_KIND_DEFAULT: + return -1 + if csidx < -1 || csidx >= g_str_count: + return -1 + if ckind == CCR_CASE_KIND_STRING && csidx < 0: + return -1 + if ctarget < 0 || ctarget >= g_v7_nod_count: + return -1 + if nod_op(ctarget) != IR_LABEL: + return -1 + // v10 defines no CASE flag bits yet; reject unknown meaning. + if cflags != 0: + return -1 + if ckind == CCR_CASE_KIND_DEFAULT: + case_defaults = case_defaults + 1 + if case_defaults > 1: + return -1 + cell_store(case_buf, cbase + CCR_CASE_VALUE, cval) + cell_store32(case_buf, cbase + CCR_CASE_KIND, ckind) + cell_store32(case_buf, cbase + CCR_CASE_STR, csidx) + cell_store32(case_buf, cbase + CCR_CASE_TARGET, ctarget) + cell_store32(case_buf, cbase + CCR_CASE_FLAGS, cflags) + ci4 = ci4 + 1 if pos != seg_end9: return -1 + g_ccr_case = case_buf + g_ccr_case_count = case_cnt // === NOD type_id domain check === // TYPE is consumed before NOD, so the row domain is already rebuilt. The diff --git a/src/compiler/ccr_schema.cr b/src/compiler/ccr_schema.cr index 399142a5..f32453f8 100644 --- a/src/compiler/ccr_schema.cr +++ b/src/compiler/ccr_schema.cr @@ -39,6 +39,23 @@ CCR_V10_REG_DISK : int = 24 CCR_V10_EDG_DISK : int = 8 CCR_V10_CASE_DISK : int = 24 +// CASE record (24B): value i64, kind u32, str index i32, target NOD id i32, +// flags u32. The target names an IR_LABEL node; CASE carries no width, byte +// extent, or target layout fact. +CCR_CASE_VALUE : int = 0 +CCR_CASE_KIND : int = 8 +CCR_CASE_STR : int = 12 +CCR_CASE_TARGET : int = 16 +CCR_CASE_FLAGS : int = 20 +CCR_CASE_DISK : int = 24 + +// CASE semantic kinds. VALUE covers int/bool/char literals, ENUM covers enum +// variant tags, STRING names a STR entry, and DEFAULT is the fall-through arm. +CCR_CASE_KIND_VALUE : int = 0 +CCR_CASE_KIND_ENUM : int = 1 +CCR_CASE_KIND_STRING : int = 2 +CCR_CASE_KIND_DEFAULT : int = 3 + // v10 header flags (spec §3.1). They describe program properties only; no bit // carries raw-int, external-binary-interface, or target identity. // bit 0 PORTABLE no raw/FFI/external-binary boundary diff --git a/src/compiler/globals.cr b/src/compiler/globals.cr index a67f9f77..942f98a3 100644 --- a/src/compiler/globals.cr +++ b/src/compiler/globals.cr @@ -60,6 +60,13 @@ g_ccr_nod_type : string, mut g_ccr_nod_type_count : int, mut g_ccr_nod_flags : string, mut g_ccr_nod_flags_count : int, mut +// In-memory CASE table for v10 segment tag 9. Rows are 24B records with the +// layout named by CCR_CASE_* (value, kind, str index, target NOD, flags). +// corec fills this table when it lowers a match; the empty program keeps count +// zero and the wire writes a bare 4B zero count. The loader rebuilds the same +// rows from disk. This file is first in both builds, so it must stay here. +g_ccr_case : string, mut +g_ccr_case_count : int, mut g_ast : string, mut g_ast_count : int, mut g_ast_cap : int, mut diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py new file mode 100644 index 00000000..59c5a539 --- /dev/null +++ b/tests/selfhost/test_multibranch_v10.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +"""v10 multibranch wire tests: IR_MATCH opcode and the CASE segment (tag 9). + +CASE is the last v10 segment. Its body is: + + case_count u32 + case_count x 24B { + value i64 @0 // signed scrutinee value (kind VALUE/ENUM) + kind u32 @8 // 0=value 1=enum 2=string 3=default + str_index i32 @12 // STR index, or -1 when unused + target_nod i32 @16 // NOD id of an IR_LABEL node + flags u32 @20 // reserved; zero in v10 + } + +The tests below check the physical layout with an independent parser, then +prove the real loader accepts IR_MATCH (54) and nonempty CASE bodies and rejects +out-of-domain case records. Synthetic CASE bodies are installed into a real +`corec` image so the rest of the container stays authoritative. + +Runs standalone (`python3 tests/selfhost/test_multibranch_v10.py`) and is also +pytest-discoverable (stdlib only). +""" + +import struct +import subprocess +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from ccr_v10_model import ( # noqa: E402 + BODY_START, + SEG_TAGS, + encode, + flags, + read_segment_table, + segment, + validate, +) + +BASE = Path(__file__).resolve().parent.parent.parent +COREC = BASE / "build" / "corec" +COREARCH = BASE / "build" / "corearch" + +IR_LABEL = 21 +IR_MATCH = 54 + +CCR_CASE_VALUE = 0 +CCR_CASE_KIND = 8 +CCR_CASE_STR = 12 +CCR_CASE_TARGET = 16 +CCR_CASE_FLAGS = 20 +CCR_CASE_DISK = 24 + +KIND_VALUE = 0 +KIND_ENUM = 1 +KIND_STRING = 2 +KIND_DEFAULT = 3 + +CASE_TAG = 9 +NOD_TAG = 5 + +# Two independent ifs give several LABEL nodes and a nonempty STR table, so a +# synthetic CASE can point at real graph labels and a real string. +BRANCH_SOURCE = """fn main() -> int: + x := 1 + if x == 2: + return 9 + if x == 3: + return 8 + return 7 +""" + +EXPECTED_EXIT = 7 + + +class CaseParseError(ValueError): + """Raised when a CASE body is not the exact 24B table v10 defines.""" + + +def _run(cmd, timeout=180): + return subprocess.run( + [str(part) for part in cmd], + capture_output=True, + text=True, + cwd=str(BASE), + timeout=timeout, + ) + + +def _build_ccr(tmp, source=BRANCH_SOURCE, name="multibranch"): + src = tmp / f"{name}.cr" + ccr = tmp / f"{name}.ccr" + src.write_text(source, encoding="utf-8") + r = _run([COREC, "ccr", src, "-o", ccr]) + assert r.returncode == 0, f"corec ccr failed: {r.stdout}\n{r.stderr}" + data = ccr.read_bytes() + validate(data) + return data + + +def _split(data): + bodies = {} + for tag, offset, size in read_segment_table(data): + bodies[tag] = data[offset : offset + size] + return bodies + + +def _replace_case(data, body): + bodies = _split(data) + bodies[CASE_TAG] = body + out = encode(bodies, flags=flags(data)) + validate(out) + return out + + +def _pack_case(records): + """Pack `(value, kind, str_index, target_nod, flags)` records.""" + body = struct.pack("= 3, f"probe source has only {len(labels)} labels" + str_count = struct.unpack_from(" 0, "probe source has no strings" + + records = [ + (7, KIND_VALUE, -1, labels[0], 0), + (2, KIND_ENUM, -1, labels[1], 0), + (0, KIND_STRING, 0, labels[2], 0), + (0, KIND_DEFAULT, -1, labels[0], 0), + ] + body = _pack_case(records) + out = _replace_case(data, body) + + assert segment(out, CASE_TAG) == body + table = read_segment_table(out) + _tag, case_off, case_size = table[CASE_TAG - 1] + assert case_size == 4 + len(records) * CCR_CASE_DISK == 4 + 4 * 24 + assert case_off + case_size == len(out), "CASE must be the final segment" + + rows = _parse_case(segment(out, CASE_TAG)) + assert [row["value"] for row in rows] == [7, 2, 0, 0] + assert [row["kind"] for row in rows] == list(range(4)) + assert [row["str_index"] for row in rows] == [-1, -1, 0, -1] + assert [row["target_nod"] for row in rows] == [labels[0], labels[1], labels[2], labels[0]] + assert [row["flags"] for row in rows] == [0, 0, 0, 0] + # 24B stride and field offsets are the wire contract, not a comment. + assert [row["offset"] for row in rows] == [4, 28, 52, 76] + assert rows[0]["offset"] + CCR_CASE_VALUE == 4 + assert rows[0]["offset"] + CCR_CASE_KIND == 12 + assert rows[0]["offset"] + CCR_CASE_STR == 16 + assert rows[0]["offset"] + CCR_CASE_TARGET == 20 + assert rows[0]["offset"] + CCR_CASE_FLAGS == 24 + + # Deterministic: re-encoding the split image, twice, reproduces the bytes. + assert _pack_case(records) == body + assert encode(_split(out), flags=flags(out)) == out + assert encode(_split(out), flags=flags(out)) == encode(_split(out), flags=flags(out)) + + +def test_case_segment_is_last_after_edg(): + with tempfile.TemporaryDirectory() as td: + data = _build_ccr(Path(td)) + table = read_segment_table(data) + assert [tag for tag, _o, _s in table] == list(SEG_TAGS) + assert table[0][1] == BODY_START + assert table[CASE_TAG - 1][1] + table[CASE_TAG - 1][2] == len(data) + + +# --------------------------------------------------------------------------- +# 2. IR_MATCH is a known v10 opcode +# --------------------------------------------------------------------------- + + +def test_ir_match_opcode_is_known_to_loader(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp) + labels = _label_nodes(data) + assert len(labels) >= 3 + nod_off = None + for tag, offset, _size in read_segment_table(data): + if tag == NOD_TAG: + nod_off = offset + break + assert nod_off is not None + + # A MATCH node plus three CASE records, as the schema expects them to + # coexist. s2/s3 are zero because this fixture only exercises the + # opcode/segment gates; match lowering fills the range in Task 2. + records = [ + (7, KIND_VALUE, -1, labels[0], 0), + (2, KIND_ENUM, -1, labels[1], 0), + (0, KIND_DEFAULT, -1, labels[2], 0), + ] + with_match = _replace_case(data, _pack_case(records)) + d = bytearray(with_match) + struct.pack_into("= 3 + + records = [ + (7, KIND_VALUE, -1, labels[0], 0), + (2, KIND_ENUM, -1, labels[1], 0), + (0, KIND_STRING, 0, labels[2], 0), + (0, KIND_DEFAULT, -1, labels[0], 0), + ] + out = _replace_case(data, _pack_case(records)) + + # Container and loader: CASE is consumed, not rejected as an unknown + # shape. + _assert_loads(out, tmp, "nonempty_case") + + ccr = tmp / "nonempty_case.elf.ccr" + ccr.write_bytes(out) + elf = tmp / "nonempty_case.elf" + r = _run([COREARCH, ccr, "--elf", "--static", "-o", elf]) + assert r.returncode == 0, r.stdout + r.stderr + assert elf.exists() + run = subprocess.run([str(elf)], capture_output=True, text=True, timeout=10) + assert run.returncode == EXPECTED_EXIT, ( + f"case probe returned {run.returncode}, expected {EXPECTED_EXIT}" + ) + + +def test_empty_case_stays_empty_and_loads(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp) + assert segment(data, CASE_TAG) == struct.pack(" Date: Sat, 24 Oct 2026 04:35:08 +0800 Subject: [PATCH 03/22] ccr: tighten match and case validation --- src/ci/run.sh | 1 + src/compiler/ccr_io.cr | 21 +++++++++++---------- src/compiler/ccr_schema.cr | 4 ++-- tests/selfhost/test_multibranch_v10.py | 23 ++++++++++++++++++++--- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/ci/run.sh b/src/ci/run.sh index 8b6c166e..d3617151 100755 --- a/src/ci/run.sh +++ b/src/ci/run.sh @@ -136,6 +136,7 @@ case "$CI_JOB_NAME" in python3 tests/selfhost/test_purity.py # 效应/纯度批判据(真纯度 13 例 + 链语义 4 例的自测通道驱动) python3 tests/selfhost/test_ccr_v10_format.py # v10 wire oracle + header/SYM/NOD negative fixtures(Task 1 + final-review Critical 1/2/6) python3 tests/selfhost/test_ccr_v10_stream.py # v10 canonical forward-only loader structure + truncation/order negative fixtures + python3 tests/selfhost/test_multibranch_v10.py # v10 MATCH opcode + CASE segment 24B layout/loader domain fixtures python3 tests/selfhost/test_ccr_v7.py # 效应/纯度批 Task 3:.ccr 层三条边集语义断言(取代「与旧版逐字节同」) python3 tests/selfhost/test_ccr_types.py # R2 P4 Task 1/2/3/4 + P5 Task 2:段机制(版本 8 + loader 三闸/必备集 + 旧 v7 拒收)8 例 + TYPE 内容面(行表/项 DAG 序列化 + 确定性装填 + corearch 读回对拍 + loader 负分支)10 例 + IFACE 内容面(五小节/条目扩列 16/形状命名化/签名项化/impl 边与方法表/corearch 读回/跨段引用域/确定性)10 例 + DFNode.TK 项槽(逐节点规则 + dex 语料 + NOD 36B/同序 + 冷/暖快照对称 + dump 零产物影响 + .cir 布局未变)6 例 = 34 例 + P5 T2 单槽化 6 例(复合行走辅码 + 互斥不变量 + F1 hotpatch 无面 + 复合行冷/暖对称 + 复合行 NOD 同序 + 快照盘面派生码保真)= 40 例 + R2 P6 Task 3 项索引 8 例(版本闸 v8 拒收 / NOD 40B 字段序 / 项索引落盘读回对拍(非退化)/ 两通道面一致 / 辅码面与复合行保真 / 项索引越界与不一致 ⇒ 拒绝 / 段界检 / D18 纯度静态)= 49 例(**+1 他批新增,本批机械核对同步**:实测 49/49) python3 tests/selfhost/test_impl.py diff --git a/src/compiler/ccr_io.cr b/src/compiler/ccr_io.cr index b9c3db64..79fdb0ac 100644 --- a/src/compiler/ccr_io.cr +++ b/src/compiler/ccr_io.cr @@ -347,7 +347,7 @@ fn ccr_reg_seg_size() -> int: fn ccr_case_seg_size() -> int: // CASE is 4B case_count plus 24B records. Match lowering fills g_ccr_case; // before that the count is zero and the segment is a bare 4B zero. - return 4 + g_ccr_case_count * CCR_CASE_DISK + return 4 + g_ccr_case_count * CCR_V10_CASE_DISK // Write-side CASE domain check. The in-memory table is the source of truth, so // a malformed row must reject the save instead of producing a file the loader @@ -361,7 +361,7 @@ fn ccr_validate_cases() -> int: loop: if ci >= g_ccr_case_count: break - base := ci * CCR_CASE_DISK + base := ci * CCR_V10_CASE_DISK kind := cell_load32(g_ccr_case, base + CCR_CASE_KIND) sidx := cell_load32(g_ccr_case, base + CCR_CASE_STR) target := cell_load32(g_ccr_case, base + CCR_CASE_TARGET) @@ -570,10 +570,11 @@ fn ccr_validate_nod_operands() -> int: if ccr_label_known(ids, ln, s2) == 0 || ccr_label_known(ids, ln, s3) == 0: return -1 else if op == IR_MATCH: - // Minimal v10 MATCH shape: scrutinee var plus a non-negative - // CASE range. The range is checked against the CASE table after - // that segment loads (match projection work lands with the backend). - if ccr_nod_var_ok(s1) == 0: + // Minimal v10 MATCH shape: a real scrutinee var plus a non-negative + // CASE range. -1 is the reserved "none" id and is not a scrutinee. + // The range is checked against the CASE table after that segment + // loads (match projection work lands with the backend). + if s1 < 0 || ccr_nod_var_ok(s1) == 0: return -1 if s2 < 0 || s3 < 0: return -1 @@ -1387,7 +1388,7 @@ fn save_ccr(path: string) -> int: loop: if ci3 >= g_ccr_case_count: break - cbase := ci3 * CCR_CASE_DISK + cbase := ci3 * CCR_V10_CASE_DISK buf_write_i64(buf, pos, cell_load(g_ccr_case, cbase + CCR_CASE_VALUE)) pos = pos + 8 buf_write_u32(buf, pos, cell_load32(g_ccr_case, cbase + CCR_CASE_KIND)) @@ -2707,7 +2708,7 @@ fn load_ccr(data: string, fsize: int) -> int: return -1 case_buf : string, mut = "" if case_cnt > 0: - case_buf = alloc(case_cnt * CCR_CASE_DISK) + case_buf = alloc(case_cnt * CCR_V10_CASE_DISK) case_defaults : ., mut = 0 ci4 : ., mut = 0 loop: @@ -2715,10 +2716,10 @@ fn load_ccr(data: string, fsize: int) -> int: break if !ccr_has_bytes(pos, CCR_V10_CASE_DISK, seg_end9): return -1 - cbase := ci4 * CCR_CASE_DISK + cbase := ci4 * CCR_V10_CASE_DISK cval := buf_read_i64(data, pos) pos = pos + 8 - ckind := buf_read_i32(data, pos) + ckind := buf_read_u32(data, pos) pos = pos + 4 csidx := buf_read_i32(data, pos) pos = pos + 4 diff --git a/src/compiler/ccr_schema.cr b/src/compiler/ccr_schema.cr index f32453f8..6fa651a2 100644 --- a/src/compiler/ccr_schema.cr +++ b/src/compiler/ccr_schema.cr @@ -41,13 +41,13 @@ CCR_V10_CASE_DISK : int = 24 // CASE record (24B): value i64, kind u32, str index i32, target NOD id i32, // flags u32. The target names an IR_LABEL node; CASE carries no width, byte -// extent, or target layout fact. +// extent, or target layout fact. CCR_V10_CASE_DISK is the one canonical record +// size used by size calculation, writer, and loader. CCR_CASE_VALUE : int = 0 CCR_CASE_KIND : int = 8 CCR_CASE_STR : int = 12 CCR_CASE_TARGET : int = 16 CCR_CASE_FLAGS : int = 20 -CCR_CASE_DISK : int = 24 // CASE semantic kinds. VALUE covers int/bool/char literals, ENUM covers enum // variant tags, STRING names a STR entry, and DEFAULT is the fall-through arm. diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index 59c5a539..a97f0a14 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -268,8 +268,9 @@ def test_ir_match_opcode_is_known_to_loader(): assert nod_off is not None # A MATCH node plus three CASE records, as the schema expects them to - # coexist. s2/s3 are zero because this fixture only exercises the - # opcode/segment gates; match lowering fills the range in Task 2. + # coexist. s2/s3 carry the CASE range: s2=0, s3=count. Removing + # IR_MATCH from ccr_nod_src3_semantic would reject s3=3 as a legacy + # target fact, so this fixture pins the semantic field. records = [ (7, KIND_VALUE, -1, labels[0], 0), (2, KIND_ENUM, -1, labels[1], 0), @@ -277,12 +278,28 @@ def test_ir_match_opcode_is_known_to_loader(): ] with_match = _replace_case(data, _pack_case(records)) d = bytearray(with_match) - struct.pack_into(" Date: Sat, 24 Oct 2026 04:52:21 +0800 Subject: [PATCH 04/22] ccr: lower integer and enum matches to v10 MATCH --- src/arch/x86_64/corearch.cr | 24 ++ src/arch/x86_64/instr.cr | 85 +++++++ src/compiler/ccr_io.cr | 69 ++++-- src/compiler/ccr_types.cr | 4 + src/compiler/cir_cache.cr | 10 + src/compiler/dataflow.cr | 7 + src/compiler/dump.cr | 5 + src/compiler/globals.cr | 7 + src/compiler/interp.cr | 57 +++++ src/compiler/ir_gen.cr | 195 +++++++++++++++- src/compiler/main.cr | 8 +- src/kernel/bytes.cr | 8 + tests/selfhost/test_interp_parity.py | 2 +- tests/selfhost/test_multibranch_v10.py | 310 ++++++++++++++++++++++++- 14 files changed, 766 insertions(+), 25 deletions(-) diff --git a/src/arch/x86_64/corearch.cr b/src/arch/x86_64/corearch.cr index c16bd13f..7c9b7b55 100644 --- a/src/arch/x86_64/corearch.cr +++ b/src/arch/x86_64/corearch.cr @@ -284,6 +284,30 @@ fn dump_object_surface(): print(" ec ") print_i(nod_edge_count(ni)) println("") + if nod_op(ni) == IR_MATCH: + // CASE rows are part of the match node's dumped surface. The range + // comes from the MATCH operands; targets are NOD ids of LABEL nodes. + mstart := nod_s2(ni) + mcount := nod_s3(ni) + mi3 : ., mut = 0 + loop: + if mi3 >= mcount: + break + cbase := (mstart + mi3) * CCR_V10_CASE_DISK + print("case ") + print_i(mstart + mi3) + print(" value ") + print_i(cell_load(g_ccr_case, cbase + CCR_CASE_VALUE)) + print(" kind ") + print_i(cell_load32(g_ccr_case, cbase + CCR_CASE_KIND)) + print(" str ") + print_i(cell_load_i32(g_ccr_case, cbase + CCR_CASE_STR)) + print(" target ") + print_i(cell_load_i32(g_ccr_case, cbase + CCR_CASE_TARGET)) + print(" flags ") + print_i(cell_load32(g_ccr_case, cbase + CCR_CASE_FLAGS)) + println("") + mi3 = mi3 + 1 nod_inputs(ni) // print outgoing edge records through the kernel helper ni = ni + 1 diff --git a/src/arch/x86_64/instr.cr b/src/arch/x86_64/instr.cr index 048ccdf7..0eee4c65 100644 --- a/src/arch/x86_64/instr.cr +++ b/src/arch/x86_64/instr.cr @@ -1748,6 +1748,91 @@ fn emit_instr(instr_idx: int, buf: string, pos: int) -> int: pi = pi + 1 return 0 + if op == IR_MATCH: + // v10 multi-way dispatch (comparison chain; Task 4 may add tables). + // s1 = scrutinee var, s2 = CASE start, s3 = CASE count. Every target is + // a NOD id whose node is IR_LABEL; the label number drives backpatching. + ti_s := irv_type(s1) + cp = cp + e2_load_var(buf, pos+cp, 10, s1) + + // Enum CASE rows compare the tag word, not the pointer. A match cannot + // mix enum and value patterns under the type checker, so the first + // non-default row selects the scrutinee form. + first_kind : ., mut = -1 + fk : ., mut = 0 + loop: + if fk >= s3: + break + fk_kind := cell_load32(g_ccr_case, (s2 + fk) * CCR_V10_CASE_DISK + CCR_CASE_KIND) + if fk_kind != CCR_CASE_KIND_DEFAULT: + first_kind = fk_kind + break + fk = fk + 1 + if first_kind == CCR_CASE_KIND_ENUM: + // mov r10, [r10 + 0]: the enum tag word. + cp = cp + emit_rex(buf, pos+cp, 1, 10/8, 0, 10/8) + e2_w8(buf, pos+cp, 139) + cp = cp + 1 + cp = cp + emit_modrm(buf, pos+cp, 2, 10%8, 10%8) + cp = cp + e2_w32(buf, pos+cp, 0) + if g_x86_big_enabled != 0: + cp = cp + e2_big_tag_reg(buf, pos+cp, 10) + + default_lbl : ., mut = -1 + ci : ., mut = 0 + loop: + if ci >= s3: + break + cbase := (s2 + ci) * CCR_V10_CASE_DISK + ckind := cell_load32(g_ccr_case, cbase + CCR_CASE_KIND) + ctarget := cell_load_i32(g_ccr_case, cbase + CCR_CASE_TARGET) + if ckind == CCR_CASE_KIND_DEFAULT: + if ctarget >= 0 && ctarget < g_ir_instr_count: + if iri_op(ctarget) == IR_LABEL: + default_lbl = iri_s1(ctarget) + else: + lbl_no : ., mut = -1 + if ctarget >= 0 && ctarget < g_ir_instr_count: + if iri_op(ctarget) == IR_LABEL: + lbl_no = iri_s1(ctarget) + if lbl_no >= 0: + cval := cell_load(g_ccr_case, cbase + CCR_CASE_VALUE) + tagged : ., mut = 0 + if ckind == CCR_CASE_KIND_ENUM: + tagged = 1 + else if g_x86_big_enabled != 0 && ti_s == TI_INT: + tagged = 1 + imm := cval + if tagged != 0: + imm = cval * 2 + cp = cp + e2_big_mov_imm64(buf, pos+cp, 11, imm) + cp = cp + e2_alu(buf, pos+cp, 57) // cmp r10, r11 + jp := pos + cp + cp = cp + e2_big_jcc(buf, pos+cp, 4) // je rel32 + if cell_load(g_label_poses, lbl_no * 8) >= 0: + e2_big_patch_jcc(buf, jp, cell_load(g_label_poses, lbl_no * 8)) + else: + grow_pending(g_pending_count + 1) + cell_store(g_pending_pos, g_pending_count*8, jp + 2) + cell_store(g_pending_label, g_pending_count*8, lbl_no) + g_pending_count = g_pending_count + 1 + ci = ci + 1 + + // No value hit -> default (or fall through when the loader let an + // invalid target through; the loader rejects that shape). + if default_lbl >= 0: + jmp_pos := pos + cp + cp = cp + e2_jmp(buf, pos+cp, 0) + if cell_load(g_label_poses, default_lbl * 8) >= 0: + target := cell_load(g_label_poses, default_lbl * 8) + e2_w32(buf, jmp_pos + 1, target - (jmp_pos + 5)) + else: + grow_pending(g_pending_count + 1) + cell_store(g_pending_pos, g_pending_count*8, jmp_pos + 1) + cell_store(g_pending_label, g_pending_count*8, default_lbl) + g_pending_count = g_pending_count + 1 + return cp + if op == IR_LOAD_ENUM_TAG && d >= 0: do2 := g2_slot(d) // Read the tag through e2_load_var: locals use [rbp+slot], globals use a diff --git a/src/compiler/ccr_io.cr b/src/compiler/ccr_io.cr index 79fdb0ac..602ec265 100644 --- a/src/compiler/ccr_io.cr +++ b/src/compiler/ccr_io.cr @@ -356,15 +356,14 @@ fn ccr_case_seg_size() -> int: fn ccr_validate_cases() -> int: if g_ccr_case_count < 0: return 0 - defaults : ., mut = 0 ci : ., mut = 0 loop: if ci >= g_ccr_case_count: break base := ci * CCR_V10_CASE_DISK kind := cell_load32(g_ccr_case, base + CCR_CASE_KIND) - sidx := cell_load32(g_ccr_case, base + CCR_CASE_STR) - target := cell_load32(g_ccr_case, base + CCR_CASE_TARGET) + sidx := cell_load_i32(g_ccr_case, base + CCR_CASE_STR) + target := cell_load_i32(g_ccr_case, base + CCR_CASE_TARGET) cflags := cell_load32(g_ccr_case, base + CCR_CASE_FLAGS) if kind < CCR_CASE_KIND_VALUE || kind > CCR_CASE_KIND_DEFAULT: return 0 @@ -378,11 +377,33 @@ fn ccr_validate_cases() -> int: return 0 if cflags != 0: return 0 - if kind == CCR_CASE_KIND_DEFAULT: - defaults = defaults + 1 - if defaults > 1: - return 0 ci = ci + 1 + // Every MATCH owns a contiguous CASE range with at most one DEFAULT. + // Multiple matches in one program therefore have one default each; the + // table itself is global. + mi : ., mut = 0 + loop: + if mi >= g_ir_instr_count: + break + if iri_op(mi) == IR_MATCH: + mstart := iri_s2(mi) + mcount := iri_s3(mi) + if mstart < 0 || mcount < 0: + return 0 + if mstart + mcount > g_ccr_case_count: + return 0 + mdef : ., mut = 0 + mj : ., mut = 0 + loop: + if mj >= mcount: + break + mk := cell_load32(g_ccr_case, (mstart + mj) * CCR_V10_CASE_DISK + CCR_CASE_KIND) + if mk == CCR_CASE_KIND_DEFAULT: + mdef = mdef + 1 + if mdef > 1: + return 0 + mj = mj + 1 + mi = mi + 1 return 1 // ── Header flag classification (spec §3.1) ────────────────────────────── @@ -1393,9 +1414,9 @@ fn save_ccr(path: string) -> int: pos = pos + 8 buf_write_u32(buf, pos, cell_load32(g_ccr_case, cbase + CCR_CASE_KIND)) pos = pos + 4 - buf_write_i32(buf, pos, cell_load32(g_ccr_case, cbase + CCR_CASE_STR)) + buf_write_i32(buf, pos, cell_load_i32(g_ccr_case, cbase + CCR_CASE_STR)) pos = pos + 4 - buf_write_i32(buf, pos, cell_load32(g_ccr_case, cbase + CCR_CASE_TARGET)) + buf_write_i32(buf, pos, cell_load_i32(g_ccr_case, cbase + CCR_CASE_TARGET)) pos = pos + 4 buf_write_u32(buf, pos, cell_load32(g_ccr_case, cbase + CCR_CASE_FLAGS)) pos = pos + 4 @@ -2709,7 +2730,6 @@ fn load_ccr(data: string, fsize: int) -> int: case_buf : string, mut = "" if case_cnt > 0: case_buf = alloc(case_cnt * CCR_V10_CASE_DISK) - case_defaults : ., mut = 0 ci4 : ., mut = 0 loop: if ci4 >= case_cnt: @@ -2740,10 +2760,6 @@ fn load_ccr(data: string, fsize: int) -> int: // v10 defines no CASE flag bits yet; reject unknown meaning. if cflags != 0: return -1 - if ckind == CCR_CASE_KIND_DEFAULT: - case_defaults = case_defaults + 1 - if case_defaults > 1: - return -1 cell_store(case_buf, cbase + CCR_CASE_VALUE, cval) cell_store32(case_buf, cbase + CCR_CASE_KIND, ckind) cell_store32(case_buf, cbase + CCR_CASE_STR, csidx) @@ -2752,6 +2768,31 @@ fn load_ccr(data: string, fsize: int) -> int: ci4 = ci4 + 1 if pos != seg_end9: return -1 + // MATCH ranges are slices of the global table. Each range must fit and may + // contain at most one DEFAULT; different matches may each have one. + mi4 : ., mut = 0 + loop: + if mi4 >= g_v7_nod_count: + break + if nod_op(mi4) == IR_MATCH: + mstart4 := nod_s2(mi4) + mcount4 := nod_s3(mi4) + if mstart4 < 0 || mcount4 < 0: + return -1 + if mstart4 + mcount4 > case_cnt: + return -1 + mdef4 : ., mut = 0 + mj4 : ., mut = 0 + loop: + if mj4 >= mcount4: + break + mk4 := cell_load32(case_buf, (mstart4 + mj4) * CCR_V10_CASE_DISK + CCR_CASE_KIND) + if mk4 == CCR_CASE_KIND_DEFAULT: + mdef4 = mdef4 + 1 + if mdef4 > 1: + return -1 + mj4 = mj4 + 1 + mi4 = mi4 + 1 g_ccr_case = case_buf g_ccr_case_count = case_cnt diff --git a/src/compiler/ccr_types.cr b/src/compiler/ccr_types.cr index dfcfa6e4..8eb4aea7 100644 --- a/src/compiler/ccr_types.cr +++ b/src/compiler/ccr_types.cr @@ -440,6 +440,10 @@ fn ccr_nod_type_domain(ii: int, op: int, d: int, s1: int, s2: int, s3: int) -> i return ccr_var_type_row(d) if op == IR_LOAD_ENUM_TAG: return ccr_var_type_row(d) + if op == IR_MATCH: + // The scrutinee's semantic type is the meaningful domain; the CASE + // table carries no width or layout fact. + return ccr_var_type_row(s1) // Listed ops whose domain is genuinely unavailable from the current IR // state. They remain -1 rather than guessing. return -1 diff --git a/src/compiler/cir_cache.cr b/src/compiler/cir_cache.cr index 7b71eaf9..517421da 100644 --- a/src/compiler/cir_cache.cr +++ b/src/compiler/cir_cache.cr @@ -567,6 +567,16 @@ fn load_cir_cache(path: string, func_idx: int) -> int: p = p + 8 + p_instr_count * 48 if p > dlen: return -1 + // A snapshot stores instructions but not the CASE table. A function with + // IR_MATCH cannot be restored without its CASE range, so treat it as a miss + // and rebuild. (Task 5 may persist the range instead.) + p_mi : ., mut = 0 + loop: + if p_mi >= p_instr_count: + break + if cell_load(data, instr_p + 8 + p_mi * 48) == IR_MATCH: + return -1 + p_mi = p_mi + 1 str_p := p p_str_count := cell_load(data, p) p = p + 8 diff --git a/src/compiler/dataflow.cr b/src/compiler/dataflow.cr index 86e11b25..813cf289 100644 --- a/src/compiler/dataflow.cr +++ b/src/compiler/dataflow.cr @@ -339,6 +339,11 @@ fn df_connect_srcs(node_id: int, opcode: int, s1: int, s2: int, s3: int, tk_aux: if opcode == IR_BRANCH: df_use_var(node_id, s1) // condition var (labels s2, s3 are not vars) return + if opcode == IR_MATCH: + // The scrutinee is the only variable read; case targets are labels in + // the CASE table, not node operands. + df_use_var(node_id, s1) + return if opcode == IR_BOUNDS_CHECK: df_use_var(node_id, s1) // 单槽化:旗标已从混用 tk 槽移入辅码槽 -> 读 tk_aux(值域不变: @@ -597,6 +602,8 @@ fn df_opcode_name(opcode: int, s3: int) -> string: return "ref" if opcode == IR_BRANCH: return "branch" + if opcode == IR_MATCH: + return "match" if opcode == IR_JUMP: return "jump" if opcode == IR_LABEL: diff --git a/src/compiler/dump.cr b/src/compiler/dump.cr index 4c36ae9a..990b83f6 100644 --- a/src/compiler/dump.cr +++ b/src/compiler/dump.cr @@ -193,6 +193,11 @@ fn ir_instr_str(instr_idx: int) -> string: if iri_op(instr_idx) == IR_BRANCH: s = s + "if " + ir_var_str(iri_s1(instr_idx)) + " goto label" + int_str(iri_s2(instr_idx)) + " else label" + int_str(iri_s3(instr_idx)) return s + if iri_op(instr_idx) == IR_MATCH: + // CASE rows live in the global table; print the range so the cir dump + // and --dump-objects agree on which rows belong to this node. + s = s + "match " + ir_var_str(iri_s1(instr_idx)) + " cases " + int_str(iri_s2(instr_idx)) + ".." + int_str(iri_s2(instr_idx) + iri_s3(instr_idx)) + return s if iri_op(instr_idx) == IR_JUMP: s = s + "goto label" + int_str(iri_s1(instr_idx)) return s diff --git a/src/compiler/globals.cr b/src/compiler/globals.cr index 942f98a3..eed8d0dd 100644 --- a/src/compiler/globals.cr +++ b/src/compiler/globals.cr @@ -67,6 +67,13 @@ g_ccr_nod_flags_count : int, mut // rows from disk. This file is first in both builds, so it must stay here. g_ccr_case : string, mut g_ccr_case_count : int, mut +// Capacity of g_ccr_case in 24B rows. corec appends rows while lowering a +// match; the capacity is compiler-private and never serialized. +g_ccr_case_cap : int, mut +// Set by ir_gen when the current function lowered a match to IR_MATCH. The +// per-function .cir snapshot does not carry the CASE range, so a function with +// this flag is rebuilt and not saved in this task. +g_cir_has_match : int, mut g_ast : string, mut g_ast_count : int, mut g_ast_cap : int, mut diff --git a/src/compiler/interp.cr b/src/compiler/interp.cr index acaaaf8b..b8d5391d 100644 --- a/src/compiler/interp.cr +++ b/src/compiler/interp.cr @@ -313,6 +313,46 @@ fn ir_interp_call(d: int, s1: int, s2: int, s3: int): break cfi = cfi + 1 +// v10 MATCH dispatch. Returns the NOD id of the selected CASE target, or -1 +// when the table has no default and no value matched. The runtime forms follow +// the existing IR ops: enum scrutinees are pointers whose tag word is the +// variant-name index; int scrutinees are tagged when big-int mode is on; bool +// scrutinees stay raw. +fn interp_match_target(scrutinee: int, scrutinee_ti: int, case_start: int, case_count: int) -> int: + if scrutinee < 0 || case_start < 0 || case_count < 0: + return -1 + cv := cell_load(g_ir_vals, scrutinee * 8) + default_t : ., mut = -1 + ci : ., mut = 0 + loop: + if ci >= case_count: + break + base := (case_start + ci) * CCR_V10_CASE_DISK + kind := cell_load32(g_ccr_case, base + CCR_CASE_KIND) + val := cell_load(g_ccr_case, base + CCR_CASE_VALUE) + target := cell_load_i32(g_ccr_case, base + CCR_CASE_TARGET) + if kind == CCR_CASE_KIND_DEFAULT: + default_t = target + else if kind == CCR_CASE_KIND_ENUM: + tag : ., mut = 0 + if cv != 0: + unsafe: + tag = cell_load(@str_of(cv), 0) + if g_interp_big != 0: + if ir_interp_tag(tag) == ir_interp_tag(val): + return target + else: + if tag == val: + return target + else: + rv := val + if g_interp_big != 0 && scrutinee_ti == TI_INT: + rv = ir_interp_tag(val) + if cv == rv: + return target + ci = ci + 1 + return default_t + // Inline execution of a callee graph. Both the main loop and nested callees use // this function. It builds the label table, copies arguments, executes f_count // nodes, restores the label table, and returns the IR_RETURN value. A function @@ -669,6 +709,15 @@ fn ir_interp_run_fn(cfi: int, arg_base: int, argc: int) -> int: if op2 == 20: if t1 >= 0 && t1 < g_label_count: ip2 = cell_load(g_label_poses, t1 * 8) + // v10 IR_MATCH: dispatch on the scrutinee and jump to the CASE target. + // The callee loop increments ip2 at the bottom, so point one before the + // target NOD (the target is itself a LABEL node). + if op2 == 54: + mt2 := interp_match_target(t1, irv_type(t1), t2, t3) + if mt2 >= 0: + ip2 = mt2 - f_start - 1 + else: + ip2 = f_count ip2 = ip2 + 1 // Restore label state g_label_count = old_lc @@ -1127,6 +1176,14 @@ fn ir_interpret() -> int: continue else: break + // v10 IR_MATCH: the CASE table selects a LABEL node and control jumps + // directly there. ip is function-relative; target is a global NOD id. + if op == 54: + mt := interp_match_target(s1, irv_type(s1), s2, s3) + if mt >= node_start && mt < node_start + node_count: + ip = mt - node_start + continue + break // IR_LABEL: no operation. // IR_AWAIT diff --git a/src/compiler/ir_gen.cr b/src/compiler/ir_gen.cr index 21246a5f..e8f8dd58 100644 --- a/src/compiler/ir_gen.cr +++ b/src/compiler/ir_gen.cr @@ -827,6 +827,40 @@ fn new_label() -> int: g_next_label = g_next_label + 1 return lbl +// ── v10 CASE table helpers ─────────────────────────────────────────────── +// The CASE table is a flat 24B-row buffer. A match reserves its rows before +// emitting arm bodies so nested matches append after the outer range; the +// targets (NOD ids of IR_LABEL nodes) are filled after each body label exists. +fn grow_ccr_case(needed: int): + if needed < g_ccr_case_cap: + return + nc : ., mut = g_ccr_case_cap * 2 + if nc < 16: + nc = 16 + if nc < needed: + nc = needed + 16 + nb := alloc(nc * CCR_V10_CASE_DISK) + cell_copy(g_ccr_case, g_ccr_case_cap * CCR_V10_CASE_DISK, nb) + g_ccr_case = nb + g_ccr_case_cap = nc + +fn ccr_case_append(value: int, kind: int, str_index: int, target: int, flags: int) -> int: + idx := g_ccr_case_count + grow_ccr_case(idx + 1) + base := idx * CCR_V10_CASE_DISK + cell_store(g_ccr_case, base + CCR_CASE_VALUE, value) + cell_store32(g_ccr_case, base + CCR_CASE_KIND, kind) + cell_store32(g_ccr_case, base + CCR_CASE_STR, str_index) + cell_store32(g_ccr_case, base + CCR_CASE_TARGET, target) + cell_store32(g_ccr_case, base + CCR_CASE_FLAGS, flags) + g_ccr_case_count = idx + 1 + return idx + +fn ccr_case_set_target(idx: int, target: int): + if idx < 0 || idx >= g_ccr_case_count: + return + cell_store32(g_ccr_case, idx * CCR_V10_CASE_DISK + CCR_CASE_TARGET, target) + fn bind_local(name_idx: int, var_idx: int): grow_ir_locals(g_ir_local_count + 1) cell_store(g_ir_locals, g_ir_local_count * 16, name_idx) @@ -2694,7 +2728,141 @@ fn gen_expr(node: int) -> int: emit(IR_CONST, mrep_var, 1, 0, 0, TI_INT) irv_set_rep(result_var, mrep_var) merge_lbl := new_label() + // ── Supported-pattern pre-scan ─────────────────────────────────── + // One IR_MATCH requires every arm to be encodable in the CASE table: + // int/bool literals, enum variants without sub-patterns, and one last + // wildcard. Anything else keeps the sequential lowering unchanged. + arm_total : ., mut = 0 + an0 := first_arm + loop: + if an0 < 0: + break + arm_total = arm_total + 1 + an0 = ast_c(an0) + match_mode : ., mut = 1 + if first_arm < 0: + match_mode = 0 + // 可选表示位分派(Some/None 的裸/装箱两态)保留既有逐臂路径。 + if g_optrep_on != 0 && match_val >= 0: + if irv_rep(match_val) >= 0: + match_mode = 0 + vals := alloc((arm_total + 1) * 16) // unique cases: kind@0, value@8 + cis := alloc((arm_total + 1) * 8) // arm -> unique-case index, -1 = duplicate + unique_n : ., mut = 0 + wild_total : ., mut = 0 + saw_int : ., mut = 0 + saw_bool : ., mut = 0 + arm_scan : ., mut = 0 + an0 = first_arm + loop: + if an0 < 0: + break + ap0 := ast_a(an0) + k0 : ., mut = -1 + if ap0 >= 0: + k0 = ast_kind(ap0) + kind0 : ., mut = -1 + value0 : ., mut = 0 + if k0 == EXPR_INT: + iv0 := ast_int_val(ap0) + saw_int = 1 + // The backend tags small integers as value<<1. Values outside + // the 62-bit immediate window need the big-int path, which this + // task does not encode in CASE. + if iv0 > 4611686018427387903 || iv0 < -4611686018427387903: + match_mode = 0 + kind0 = CCR_CASE_KIND_VALUE + value0 = iv0 + else if k0 == EXPR_BOOL: + saw_bool = 1 + bv0 : ., mut = 0 + if ast_int_val(ap0) != 0: + bv0 = 1 + kind0 = CCR_CASE_KIND_VALUE + value0 = bv0 + else if k0 == EXPR_ENUMPAT: + if ast_c(ap0) != 0: + // Payload binding/guard patterns are Task 4/5. + match_mode = 0 + vn0 := get_variant_name_idx(ast_a(ap0)) + if vn0 < 0: + match_mode = 0 + kind0 = CCR_CASE_KIND_ENUM + value0 = vn0 + else if k0 == EXPR_IDENT && str_eq(istr_get(ast_int_val(ap0)), "_") != 0: + // The lexer classifies a bare `_` as an identifier because `_` + // is a valid identifier character; the parser's EXPR_WILDCARD + // branch is therefore unreachable for the common form. + wild_total = wild_total + 1 + if ast_c(an0) >= 0: + // A wildcard shadows every later arm; keep the old path so + // the CASE order cannot change which body wins. + match_mode = 0 + kind0 = CCR_CASE_KIND_DEFAULT + value0 = 0 + else if k0 == EXPR_WILDCARD: + wild_total = wild_total + 1 + if ast_c(an0) >= 0: + // A wildcard shadows every later arm; keep the old path so + // the CASE order cannot change which body wins. + match_mode = 0 + kind0 = CCR_CASE_KIND_DEFAULT + value0 = 0 + else: + match_mode = 0 + ci0 : ., mut = -1 + if match_mode != 0: + j0 : ., mut = 0 + dup0 : ., mut = 0 + loop: + if j0 >= unique_n: + break + if cell_load(vals, j0 * 16) == kind0: + if cell_load(vals, j0 * 16 + 8) == value0: + dup0 = 1 + break + j0 = j0 + 1 + if dup0 == 0: + cell_store(vals, unique_n * 16, kind0) + cell_store(vals, unique_n * 16 + 8, value0) + ci0 = unique_n + unique_n = unique_n + 1 + cell_store(cis, arm_scan * 8, ci0) + arm_scan = arm_scan + 1 + an0 = ast_c(an0) + if match_val < 0: + match_mode = 0 + if saw_int != 0 && irv_type(match_val) != TI_INT: + // The backend compares tagged int slots; a different scrutinee form + // (dex, char, struct) needs its own lowering. + match_mode = 0 + if saw_bool != 0 && irv_type(match_val) != TI_BOOL: + match_mode = 0 + if wild_total > 1: + match_mode = 0 + synth_default : ., mut = -1 + if match_mode != 0 && wild_total == 0: + // No wildcard: an unmatched value falls through to the merge label, + // exactly as the sequential lowering did. Add that edge as DEFAULT. + cell_store(vals, unique_n * 16, CCR_CASE_KIND_DEFAULT) + cell_store(vals, unique_n * 16 + 8, 0) + synth_default = unique_n + unique_n = unique_n + 1 + case_base : ., mut = -1 + if match_mode != 0: + case_base = g_ccr_case_count + emit(IR_MATCH, -1, match_val, case_base, unique_n, 0) + g_cir_has_match = 1 + // Reserve the whole range before emitting bodies: nested matches in + // an arm must append after this range, not splice into it. + cj0 : ., mut = 0 + loop: + if cj0 >= unique_n: + break + ccr_case_append(cell_load(vals, cj0 * 16 + 8), cell_load(vals, cj0 * 16), -1, -1, 0) + cj0 = cj0 + 1 an : ., mut = first_arm + arm_idx : ., mut = 0 loop: if an < 0: break @@ -2706,6 +2874,12 @@ fn gen_expr(node: int) -> int: is_wildcard := 0 if pat_kind == EXPR_WILDCARD: is_wildcard = 1 + if match_mode != 0: + // MATCH owns the dispatch: suppress the sequential compare chain + // and the payload-binding reads (sub-patterns already forced the + // fallback path). + pat_kind = -2 + is_wildcard = 0 // 可选表示位:臂条件与臂体绑定共用(声明在臂作用域) rep_v : ., mut = -1 body_lbl := new_label() @@ -2781,7 +2955,14 @@ fn gen_expr(node: int) -> int: if has_next == 1: fall_lbl = new_label() emit(IR_BRANCH, -1, cmp_var, body_lbl, fall_lbl, 0) + body_nod : ., mut = -1 + if match_mode != 0: + body_nod = g_ir_instr_count emit(IR_LABEL, -1, body_lbl, 0, 0, 0) + if match_mode != 0: + ci_arm := cell_load(cis, arm_idx * 8) + if ci_arm >= 0: + ccr_case_set_target(case_base + ci_arm, body_nod) push_ir_scope() if pat_kind == EXPR_ENUMPAT: sub_count := ast_c(arm_pat) @@ -2842,11 +3023,14 @@ fn gen_expr(node: int) -> int: emit_rep_set(mrep_var, rep_enc_of_expr(arm_body, body_val)) pop_ir_scope() emit(IR_JUMP, -1, merge_lbl, 0, 0, 0) - if is_wildcard == 0: - if has_next == 1: - emit(IR_LABEL, -1, fall_lbl, 0, 0, 0) + if match_mode == 0 && is_wildcard == 0 && has_next == 1: + emit(IR_LABEL, -1, fall_lbl, 0, 0, 0) an = ast_c(an) + arm_idx = arm_idx + 1 + merge_nod := g_ir_instr_count emit(IR_LABEL, -1, merge_lbl, 0, 0, 0) + if match_mode != 0 && synth_default >= 0: + ccr_case_set_target(case_base + synth_default, merge_nod) return result_var // Let binding @@ -3353,6 +3537,7 @@ fn ir_gen_func(fi: int): // Skip generic functions — they will be monomorphized at call sites if fi_generic_count(fi) > 0: return + g_cir_has_match = 0 fn_node := fi_ast_node(fi) name_idx := ast_a(fn_node) first_param := ast_b(fn_node) @@ -4191,6 +4376,10 @@ fn ir_gen_all(): g_next_label = 1 g_ir_loop_depth = 0 g_ir_str_const_count = 0 + g_ccr_case = "" + g_ccr_case_count = 0 + g_ccr_case_cap = 0 + g_cir_has_match = 0 // 可选表示方式:侧表复位 + 启用扫描(先于全局注册,隐藏信道的 var 记录顺序依赖) optrep_begin() diff --git a/src/compiler/main.cr b/src/compiler/main.cr index 26b88b0c..ebb5bee6 100644 --- a/src/compiler/main.cr +++ b/src/compiler/main.cr @@ -451,6 +451,10 @@ fn corec_main() -> int: g_ir_str_const_count = 0 g_ir_source_hash = 0 g_ir_source_hash_ready = 0 + g_ccr_case = "" + g_ccr_case_count = 0 + g_ccr_case_cap = 0 + g_cir_has_match = 0 init_df() // This path does not use ir_gen_all, so initialize optional representation // state here, before ir_gen_globals, because the hidden channels depend on @@ -564,7 +568,9 @@ fn corec_main() -> int: df_end_func(ir_func_idx) // Save only when generation did not change snapshot-uncarried shared state. - if cache_enabled != 0: + // A MATCH function's CASE range is not in the snapshot, so saving it + // would let a warm build restore a MATCH with no dispatch table. + if cache_enabled != 0 && g_cir_has_match == 0: if g_type_count == tc0: save_cir_cache(cache_path, fi, ir_func_idx) diff --git a/src/kernel/bytes.cr b/src/kernel/bytes.cr index 8a1f11b9..c8c8cfe7 100644 --- a/src/kernel/bytes.cr +++ b/src/kernel/bytes.cr @@ -83,6 +83,14 @@ fn cell_store32(buf: string, pos: int, val: int): fn cell_load32(buf: string, pos: int) -> int: return r32(buf, pos) +// Signed 32-bit read. The wire uses signed i32 for STR indexes and NOD +// targets; cell_load32 returns the unsigned bit pattern. +fn cell_load_i32(buf: string, pos: int) -> int: + v := r32(buf, pos) + if v > 2147483647: + return v - 4294967296 + return v + fn cell_store8(buf: string, pos: int, val: int): w8(buf, pos, val) diff --git a/tests/selfhost/test_interp_parity.py b/tests/selfhost/test_interp_parity.py index 34285862..c8f64426 100644 --- a/tests/selfhost/test_interp_parity.py +++ b/tests/selfhost/test_interp_parity.py @@ -252,7 +252,7 @@ EXPECT_OPS = { "enum_payload_in_callee": {"make_enum", "load_enum_tag"}, "enum_construct_callee_match_main": {"call", "load_enum_tag"}, - "enum_tag_variant_in_callee": {"make_enum", "load_enum_tag"}, + "enum_tag_variant_in_callee": {"make_enum", "match"}, "enum_payload_tag_only_in_callee": {"make_enum", "load_enum_tag"}, "bare_ptr_deref_callee": {"addr_index", "deref"}, "ptr_store_callee": {"ref", "store_ptr"}, diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index a97f0a14..b418c3f3 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -79,21 +79,21 @@ class CaseParseError(ValueError): """Raised when a CASE body is not the exact 24B table v10 defines.""" -def _run(cmd, timeout=180): +def _run(cmd, timeout=180, cwd=None): return subprocess.run( [str(part) for part in cmd], capture_output=True, text=True, - cwd=str(BASE), + cwd=str(cwd if cwd is not None else BASE), timeout=timeout, ) -def _build_ccr(tmp, source=BRANCH_SOURCE, name="multibranch"): +def _build_ccr(tmp, source=BRANCH_SOURCE, name="multibranch", cwd=None): src = tmp / f"{name}.cr" ccr = tmp / f"{name}.ccr" src.write_text(source, encoding="utf-8") - r = _run([COREC, "ccr", src, "-o", ccr]) + r = _run([COREC, "ccr", src, "-o", ccr], cwd=cwd) assert r.returncode == 0, f"corec ccr failed: {r.stdout}\n{r.stderr}" data = ccr.read_bytes() validate(data) @@ -379,8 +379,10 @@ def install(name, body): install("case_target_oob", _pack_case([(1, KIND_VALUE, -1, nod_count + 10, 0)])) install("case_target_negative", _pack_case([(1, KIND_VALUE, -1, -2, 0)])) install("case_target_not_label", _pack_case([(1, KIND_VALUE, -1, non_label, 0)])) - install( - "case_two_defaults", + # Two DEFAULT rows inside one MATCH range are invalid. (The table is + # global, so two different matches may each have one default.) + two_defaults = _replace_case( + data, _pack_case( [ (0, KIND_DEFAULT, -1, label, 0), @@ -388,6 +390,14 @@ def install(name, body): ] ), ) + nod_off = next( + offset for tag, offset, _size in read_segment_table(data) if tag == NOD_TAG + ) + d = bytearray(two_defaults) + struct.pack_into(" int: + return match n: + 10 => 1, + 20 => 2, + 30 => 3, + 40 => 4, + _ => 5 + +fn main() -> int: + if pick(10) != 1: + return 10 + if pick(20) != 2: + return 20 + if pick(30) != 3: + return 30 + if pick(40) != 4: + return 40 + if pick(99) != 5: + return 99 + return 42 +""" + +MATCH_BOOL_SOURCE = """fn pickb(b: bool) -> int: + return match b: + true => 7, + false => 8, + _ => 9 + +fn main() -> int: + if pickb(true) != 7: + return 1 + if pickb(false) != 8: + return 2 + return 42 +""" + +MATCH_ENUM_SOURCE = """enum Color: + Red, Green, Blue + +fn pickc(c: Color) -> int: + return match c: + Red => 11, + Green => 22, + Blue => 33, + _ => 44 + +fn main() -> int: + if pickc(Red()) != 11: + return 1 + if pickc(Green()) != 22: + return 2 + if pickc(Blue()) != 33: + return 3 + return 42 +""" + +# Every function here has at least one arm outside the supported set, so each +# must keep the old sequential lowering. The programs are not executed: the old +# path's string/struct semantics are Task 4/5 work, and this fixture only pins +# that corec does not encode them as MATCH. +MATCH_UNSUPPORTED_SOURCE = """struct P: + x: int + +fn pickstr(s: string) -> int: + return match s: + "a" => 1, + _ => 2 + +fn pickid(n: int) -> int: + return match n: + x => x + 1 + +fn pickp(p: P) -> int: + return match p: + P { x = _ } => 1, + _ => 2 + +fn main() -> int: + return 0 +""" + +MATCH_TWO_FUNCS_SOURCE = """fn pick_a(n: int) -> int: + return match n: + 1 => 10, + 2 => 20, + _ => 30 + +fn pick_b(n: int) -> int: + return match n: + 7 => 70, + 8 => 80, + 9 => 90, + _ => 99 + +fn main() -> int: + if pick_a(2) != 20: + return 1 + if pick_b(9) != 90: + return 2 + if pick_b(100) != 99: + return 3 + return 42 +""" + + +def _match_nodes(data): + return [i for i, rec in enumerate(_nod_records(data)) if rec[0] == IR_MATCH] + + +def _single_match(data): + matches = _match_nodes(data) + assert len(matches) == 1, f"expected exactly one MATCH, got {matches}" + return _nod_records(data)[matches[0]] + + +def _assert_targets_are_labels(data, rows): + nodes = _nod_records(data) + for row in rows: + target = row["target_nod"] + assert 0 <= target < len(nodes), f"case target {target} out of range" + assert nodes[target][0] == IR_LABEL, ( + f"case target {target} is op {nodes[target][0]}, expected IR_LABEL" + ) + + +def _assert_program(tmp, source, expected): + """Run the fixture through the interpreter and a static ELF.""" + interp = _run([COREC, "run", source], cwd=BASE) + assert interp.returncode == expected, ( + f"interp returned {interp.returncode}, expected {expected}: " + f"{interp.stdout}\n{interp.stderr}" + ) + src = tmp / "match_program.cr" + src.write_text(source, encoding="utf-8") + elf = tmp / "match_program.elf" + built = _run([COREC, "build", src, "-o", elf, "--static"], cwd=BASE) + assert built.returncode == 0, f"corec build failed: {built.stdout}\n{built.stderr}" + assert elf.exists(), "corec build produced no ELF" + run = subprocess.run( + [str(elf)], capture_output=True, text=True, timeout=30, cwd=str(BASE) + ) + assert run.returncode == expected, ( + f"ELF returned {run.returncode}, expected {expected}: " + f"{run.stdout}\n{run.stderr}" + ) + + +def test_int_match_is_one_match_with_default_case(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, MATCH_INT_SOURCE, "match_int") + rec = _single_match(data) + rows = _parse_case(segment(data, CASE_TAG)) + + assert rec[3] == 0, f"MATCH case start is {rec[3]}, expected 0" + assert rec[4] == 5, f"MATCH case count is {rec[4]}, expected 5" + assert len(rows) == 5, f"CASE has {len(rows)} rows, expected 5" + assert [row["kind"] for row in rows] == [ + KIND_VALUE, + KIND_VALUE, + KIND_VALUE, + KIND_VALUE, + KIND_DEFAULT, + ] + assert [row["value"] for row in rows] == [10, 20, 30, 40, 0] + assert [row["str_index"] for row in rows] == [-1, -1, -1, -1, -1] + assert [row["flags"] for row in rows] == [0, 0, 0, 0, 0] + _assert_targets_are_labels(data, rows) + assert len({row["target_nod"] for row in rows}) == 5, ( + "distinct arms must target distinct body labels" + ) + _assert_program(tmp, MATCH_INT_SOURCE, 42) + + +def test_bool_match_is_one_match_with_value_cases(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, MATCH_BOOL_SOURCE, "match_bool") + rec = _single_match(data) + rows = _parse_case(segment(data, CASE_TAG)) + + assert rec[3] == 0 + assert rec[4] == 3 + assert [row["kind"] for row in rows] == [ + KIND_VALUE, + KIND_VALUE, + KIND_DEFAULT, + ] + assert [row["value"] for row in rows] == [1, 0, 0] + _assert_targets_are_labels(data, rows) + _assert_program(tmp, MATCH_BOOL_SOURCE, 42) + + +def test_enum_match_is_one_match_with_enum_cases(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, MATCH_ENUM_SOURCE, "match_enum") + rec = _single_match(data) + rows = _parse_case(segment(data, CASE_TAG)) + + assert rec[3] == 0 + assert rec[4] == 4 + assert [row["kind"] for row in rows] == [ + KIND_ENUM, + KIND_ENUM, + KIND_ENUM, + KIND_DEFAULT, + ] + enum_values = [row["value"] for row in rows[:3]] + assert all(value > 0 for value in enum_values), enum_values + assert len(set(enum_values)) == 3, enum_values + assert rows[3]["value"] == 0 + _assert_targets_are_labels(data, rows) + _assert_program(tmp, MATCH_ENUM_SOURCE, 42) + + +def test_unsupported_patterns_keep_sequential_lowering(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, MATCH_UNSUPPORTED_SOURCE, "match_unsupported") + assert _match_nodes(data) == [], ( + "string/ident-binding/struct patterns must not become MATCH" + ) + assert segment(data, CASE_TAG) == struct.pack(" Date: Sat, 24 Oct 2026 06:36:33 +0800 Subject: [PATCH 05/22] fix: correct v10 match dispatch and regalloc edges --- src/arch/x86_64/instr.cr | 18 +- src/arch/x86_64/regalloc.cr | 25 ++- src/compiler/ccr_io.cr | 18 ++ src/compiler/interp.cr | 42 +++- src/compiler/ir_gen.cr | 123 ++++++------ src/compiler/opt.cr | 10 +- src/lattice/ent_kernel.cr | 5 +- tests/selfhost/test_multibranch_v10.py | 263 ++++++++++++++++++++++--- 8 files changed, 405 insertions(+), 99 deletions(-) diff --git a/src/arch/x86_64/instr.cr b/src/arch/x86_64/instr.cr index 0eee4c65..d1ef7496 100644 --- a/src/arch/x86_64/instr.cr +++ b/src/arch/x86_64/instr.cr @@ -1797,11 +1797,13 @@ fn emit_instr(instr_idx: int, buf: string, pos: int) -> int: lbl_no = iri_s1(ctarget) if lbl_no >= 0: cval := cell_load(g_ccr_case, cbase + CCR_CASE_VALUE) + // Tagged int form: enum tags are ints in user code, and an + // int scrutinee slot is tagged. In raw-int mode neither is, + // so the enum tag must stay raw too. tagged : ., mut = 0 - if ckind == CCR_CASE_KIND_ENUM: - tagged = 1 - else if g_x86_big_enabled != 0 && ti_s == TI_INT: - tagged = 1 + if g_x86_big_enabled != 0: + if ckind == CCR_CASE_KIND_ENUM || ti_s == TI_INT: + tagged = 1 imm := cval if tagged != 0: imm = cval * 2 @@ -1818,8 +1820,8 @@ fn emit_instr(instr_idx: int, buf: string, pos: int) -> int: g_pending_count = g_pending_count + 1 ci = ci + 1 - // No value hit -> default (or fall through when the loader let an - // invalid target through; the loader rejects that shape). + // No value hit -> default. A range without DEFAULT is a trap: the + // design forbids falling through into the first arm body. if default_lbl >= 0: jmp_pos := pos + cp cp = cp + e2_jmp(buf, pos+cp, 0) @@ -1831,6 +1833,10 @@ fn emit_instr(instr_idx: int, buf: string, pos: int) -> int: cell_store(g_pending_pos, g_pending_count*8, jmp_pos + 1) cell_store(g_pending_label, g_pending_count*8, default_lbl) g_pending_count = g_pending_count + 1 + else: + e2_w8(buf, pos+cp, 15) + e2_w8(buf, pos+cp+1, 11) // ud2: invalid opcode trap + cp = cp + 2 return cp if op == IR_LOAD_ENUM_TAG && d >= 0: diff --git a/src/arch/x86_64/regalloc.cr b/src/arch/x86_64/regalloc.cr index 80566438..ed9d0136 100644 --- a/src/arch/x86_64/regalloc.cr +++ b/src/arch/x86_64/regalloc.cr @@ -580,7 +580,8 @@ fn alloc_registers(): if cell_load(st, lvd * 8) == 0: // dest 定值同指令自读(s1/s2 == d):读先于写,该读 // 可能取上一轮值,按读首引用(携带)处理 - if s1 == d || s2 == d: + // MATCH 的 s2 是 CASE 起始下标,不是变量。 + if s1 == d || (op != IR_MATCH && s2 == d): cell_store(st, lvd * 8, 2) cell_store(car, lvd * 8, 1) else: @@ -637,6 +638,28 @@ fn alloc_registers(): if t3 > pp && t3 <= b0: cell_store(skp, (pp + 1 - t0) * 8, cell_load(skp, (pp + 1 - t0) * 8) + 1) cell_store(skp, (t3 - t0) * 8, cell_load(skp, (t3 - t0) * 8) - 1) + else if iri_op(ist + pp) == IR_MATCH: + // Every CASE target is a conditional successor. Mark + // each forward arm entry as skippable, exactly like a + // BRANCH target. Over-marking only lowers register + // pressure, so duplicate targets are harmless. + mc := iri_s3(ist + pp) + ms := iri_s2(ist + pp) + mj : ., mut = 0 + loop: + if mj >= mc: + break + mtgt := cell_load_i32(g_ccr_case, (ms + mj) * CCR_V10_CASE_DISK + CCR_CASE_TARGET) + mlab : ., mut = -1 + if mtgt >= 0 && mtgt < g_ir_instr_count: + if iri_op(mtgt) == IR_LABEL: + mlab = iri_s1(mtgt) + if mlab >= 0: + mt := label_pos_of(mlab, lab_id, lab_ps, ln) + if mt > pp && mt <= b0: + cell_store(skp, (pp + 1 - t0) * 8, cell_load(skp, (pp + 1 - t0) * 8) + 1) + cell_store(skp, (mt - t0) * 8, cell_load(skp, (mt - t0) * 8) - 1) + mj = mj + 1 else if iri_op(ist + pp) == IR_JUMP: t1 := label_pos_of(iri_s1(ist + pp), lab_id, lab_ps, ln) if t1 > pp && t1 <= b0: diff --git a/src/compiler/ccr_io.cr b/src/compiler/ccr_io.cr index 602ec265..3b427381 100644 --- a/src/compiler/ccr_io.cr +++ b/src/compiler/ccr_io.cr @@ -393,6 +393,8 @@ fn ccr_validate_cases() -> int: if mstart + mcount > g_ccr_case_count: return 0 mdef : ., mut = 0 + menum : ., mut = 0 + mvalue : ., mut = 0 mj : ., mut = 0 loop: if mj >= mcount: @@ -402,7 +404,15 @@ fn ccr_validate_cases() -> int: mdef = mdef + 1 if mdef > 1: return 0 + else if mk == CCR_CASE_KIND_ENUM: + menum = menum + 1 + else if mk == CCR_CASE_KIND_VALUE: + mvalue = mvalue + 1 mj = mj + 1 + if menum > 0 && mvalue > 0: + // The backend selects one scrutinee form (enum tag vs value + // slot) for the whole range. + return 0 mi = mi + 1 return 1 @@ -2782,6 +2792,8 @@ fn load_ccr(data: string, fsize: int) -> int: if mstart4 + mcount4 > case_cnt: return -1 mdef4 : ., mut = 0 + menum4 : ., mut = 0 + mvalue4 : ., mut = 0 mj4 : ., mut = 0 loop: if mj4 >= mcount4: @@ -2791,7 +2803,13 @@ fn load_ccr(data: string, fsize: int) -> int: mdef4 = mdef4 + 1 if mdef4 > 1: return -1 + else if mk4 == CCR_CASE_KIND_ENUM: + menum4 = menum4 + 1 + else if mk4 == CCR_CASE_KIND_VALUE: + mvalue4 = mvalue4 + 1 mj4 = mj4 + 1 + if menum4 > 0 && mvalue4 > 0: + return -1 mi4 = mi4 + 1 g_ccr_case = case_buf g_ccr_case_count = case_cnt diff --git a/src/compiler/interp.cr b/src/compiler/interp.cr index b8d5391d..df35f0a9 100644 --- a/src/compiler/interp.cr +++ b/src/compiler/interp.cr @@ -159,6 +159,26 @@ fn ir_interp_binary(d: int, s1: int, s2: int, s3: int, ti: int): cell_store(g_ir_vals, d * 8, lv % rv) return + if ti == TI_BOOL || irv_type(s1) == TI_BOOL || irv_type(s2) == TI_BOOL: + // Bool slots are raw 0/1, not big-int tagged. ELF's emit_big_binary is + // gated on both operands being TI_INT, so the interpreter must also + // compare these raw instead of calling bi_cmp. `ti` is the result code: + // a bool-returning comparison can still take int operands (for example + // the optional-equality `eqi != _cz`). + if s3 == 6: + cell_store(g_ir_vals, d * 8, lv == rv) + else if s3 == 7: + cell_store(g_ir_vals, d * 8, lv != rv) + else if s3 == 8: + cell_store(g_ir_vals, d * 8, lv < rv) + else if s3 == 9: + cell_store(g_ir_vals, d * 8, lv > rv) + else if s3 == 10: + cell_store(g_ir_vals, d * 8, lv <= rv) + else if s3 == 11: + cell_store(g_ir_vals, d * 8, lv >= rv) + return + if s3 == 1: cell_store(g_ir_vals, d * 8, bi_add(lv, rv)) else if s3 == 2: @@ -711,12 +731,17 @@ fn ir_interp_run_fn(cfi: int, arg_base: int, argc: int) -> int: ip2 = cell_load(g_label_poses, t1 * 8) // v10 IR_MATCH: dispatch on the scrutinee and jump to the CASE target. // The callee loop increments ip2 at the bottom, so point one before the - // target NOD (the target is itself a LABEL node). + // target NOD (the target is itself a LABEL node). A miss with no + // DEFAULT and an out-of-span target both trap. if op2 == 54: mt2 := interp_match_target(t1, irv_type(t1), t2, t3) - if mt2 >= 0: + if mt2 < 0: + g_interp_abort = -1 + ip2 = f_count + else if mt2 >= f_start && mt2 < f_start + f_count: ip2 = mt2 - f_start - 1 else: + g_interp_abort = -1 ip2 = f_count ip2 = ip2 + 1 // Restore label state @@ -1178,12 +1203,17 @@ fn ir_interpret() -> int: break // v10 IR_MATCH: the CASE table selects a LABEL node and control jumps // directly there. ip is function-relative; target is a global NOD id. + // A miss with no DEFAULT and an out-of-span target both trap. if op == 54: mt := interp_match_target(s1, irv_type(s1), s2, s3) - if mt >= node_start && mt < node_start + node_count: - ip = mt - node_start - continue - break + if mt < 0: + g_interp_abort = -1 + return -1 + if mt < node_start || mt >= node_start + node_count: + g_interp_abort = -1 + return -1 + ip = mt - node_start + continue // IR_LABEL: no operation. // IR_AWAIT diff --git a/src/compiler/ir_gen.cr b/src/compiler/ir_gen.cr index e8f8dd58..42de844b 100644 --- a/src/compiler/ir_gen.cr +++ b/src/compiler/ir_gen.cr @@ -2746,13 +2746,13 @@ fn gen_expr(node: int) -> int: if g_optrep_on != 0 && match_val >= 0: if irv_rep(match_val) >= 0: match_mode = 0 - vals := alloc((arm_total + 1) * 16) // unique cases: kind@0, value@8 - cis := alloc((arm_total + 1) * 8) // arm -> unique-case index, -1 = duplicate - unique_n : ., mut = 0 + // Pass A: classify support without resolving enum variant names. This + // keeps an unsupported match's string-interning order byte-identical to + // the old sequential lowering. wild_total : ., mut = 0 saw_int : ., mut = 0 saw_bool : ., mut = 0 - arm_scan : ., mut = 0 + saw_enum : ., mut = 0 an0 = first_arm loop: if an0 < 0: @@ -2761,34 +2761,21 @@ fn gen_expr(node: int) -> int: k0 : ., mut = -1 if ap0 >= 0: k0 = ast_kind(ap0) - kind0 : ., mut = -1 - value0 : ., mut = 0 if k0 == EXPR_INT: - iv0 := ast_int_val(ap0) saw_int = 1 + iv0 := ast_int_val(ap0) // The backend tags small integers as value<<1. Values outside // the 62-bit immediate window need the big-int path, which this // task does not encode in CASE. if iv0 > 4611686018427387903 || iv0 < -4611686018427387903: match_mode = 0 - kind0 = CCR_CASE_KIND_VALUE - value0 = iv0 else if k0 == EXPR_BOOL: saw_bool = 1 - bv0 : ., mut = 0 - if ast_int_val(ap0) != 0: - bv0 = 1 - kind0 = CCR_CASE_KIND_VALUE - value0 = bv0 else if k0 == EXPR_ENUMPAT: + saw_enum = 1 if ast_c(ap0) != 0: // Payload binding/guard patterns are Task 4/5. match_mode = 0 - vn0 := get_variant_name_idx(ast_a(ap0)) - if vn0 < 0: - match_mode = 0 - kind0 = CCR_CASE_KIND_ENUM - value0 = vn0 else if k0 == EXPR_IDENT && str_eq(istr_get(ast_int_val(ap0)), "_") != 0: // The lexer classifies a bare `_` as an identifier because `_` // is a valid identifier character; the parser's EXPR_WILDCARD @@ -2798,37 +2785,12 @@ fn gen_expr(node: int) -> int: // A wildcard shadows every later arm; keep the old path so // the CASE order cannot change which body wins. match_mode = 0 - kind0 = CCR_CASE_KIND_DEFAULT - value0 = 0 else if k0 == EXPR_WILDCARD: wild_total = wild_total + 1 if ast_c(an0) >= 0: - // A wildcard shadows every later arm; keep the old path so - // the CASE order cannot change which body wins. match_mode = 0 - kind0 = CCR_CASE_KIND_DEFAULT - value0 = 0 else: match_mode = 0 - ci0 : ., mut = -1 - if match_mode != 0: - j0 : ., mut = 0 - dup0 : ., mut = 0 - loop: - if j0 >= unique_n: - break - if cell_load(vals, j0 * 16) == kind0: - if cell_load(vals, j0 * 16 + 8) == value0: - dup0 = 1 - break - j0 = j0 + 1 - if dup0 == 0: - cell_store(vals, unique_n * 16, kind0) - cell_store(vals, unique_n * 16 + 8, value0) - ci0 = unique_n - unique_n = unique_n + 1 - cell_store(cis, arm_scan * 8, ci0) - arm_scan = arm_scan + 1 an0 = ast_c(an0) if match_val < 0: match_mode = 0 @@ -2838,19 +2800,73 @@ fn gen_expr(node: int) -> int: match_mode = 0 if saw_bool != 0 && irv_type(match_val) != TI_BOOL: match_mode = 0 + if saw_enum != 0 && (saw_int != 0 || saw_bool != 0): + // The backend picks one scrutinee form per MATCH range (enum tag vs + // value slot). Mixed rows would dispatch differently in the + // interpreter, so keep the sequential path instead. + match_mode = 0 if wild_total > 1: match_mode = 0 - synth_default : ., mut = -1 - if match_mode != 0 && wild_total == 0: - // No wildcard: an unmatched value falls through to the merge label, - // exactly as the sequential lowering did. Add that edge as DEFAULT. - cell_store(vals, unique_n * 16, CCR_CASE_KIND_DEFAULT) - cell_store(vals, unique_n * 16 + 8, 0) - synth_default = unique_n - unique_n = unique_n + 1 + + // Pass B: only for supported matches. Resolve enum tags and dedupe by + // (kind, value), keeping the first arm. + vals := alloc((arm_total + 1) * 16) // unique cases: kind@0, value@8 + cis := alloc((arm_total + 1) * 8) // arm -> unique-case index, -1 = duplicate + unique_n : ., mut = 0 + arm_scan : ., mut = 0 + if match_mode != 0: + an0 = first_arm + loop: + if an0 < 0: + break + ap0 := ast_a(an0) + k0 := ast_kind(ap0) + kind0 : ., mut = -1 + value0 : ., mut = 0 + if k0 == EXPR_INT: + kind0 = CCR_CASE_KIND_VALUE + value0 = ast_int_val(ap0) + else if k0 == EXPR_BOOL: + bv0 : ., mut = 0 + if ast_int_val(ap0) != 0: + bv0 = 1 + kind0 = CCR_CASE_KIND_VALUE + value0 = bv0 + else if k0 == EXPR_ENUMPAT: + kind0 = CCR_CASE_KIND_ENUM + value0 = get_variant_name_idx(ast_a(ap0)) + if value0 < 0: + match_mode = 0 + else: + kind0 = CCR_CASE_KIND_DEFAULT + value0 = 0 + ci0 : ., mut = -1 + if match_mode != 0: + j0 : ., mut = 0 + dup0 : ., mut = 0 + loop: + if j0 >= unique_n: + break + if cell_load(vals, j0 * 16) == kind0: + if cell_load(vals, j0 * 16 + 8) == value0: + dup0 = 1 + break + j0 = j0 + 1 + if dup0 == 0: + cell_store(vals, unique_n * 16, kind0) + cell_store(vals, unique_n * 16 + 8, value0) + ci0 = unique_n + unique_n = unique_n + 1 + cell_store(cis, arm_scan * 8, ci0) + arm_scan = arm_scan + 1 + an0 = ast_c(an0) case_base : ., mut = -1 if match_mode != 0: case_base = g_ccr_case_count + // type_kind 0: MATCH is not a type-face op. The NOD semantic + // type_id is filled by ccr_nod_type_domain from the scrutinee var, + // so the wire records the scrutinee domain instead of an invented + // result domain. emit(IR_MATCH, -1, match_val, case_base, unique_n, 0) g_cir_has_match = 1 // Reserve the whole range before emitting bodies: nested matches in @@ -3027,10 +3043,7 @@ fn gen_expr(node: int) -> int: emit(IR_LABEL, -1, fall_lbl, 0, 0, 0) an = ast_c(an) arm_idx = arm_idx + 1 - merge_nod := g_ir_instr_count emit(IR_LABEL, -1, merge_lbl, 0, 0, 0) - if match_mode != 0 && synth_default >= 0: - ccr_case_set_target(case_base + synth_default, merge_nod) return result_var // Let binding diff --git a/src/compiler/opt.cr b/src/compiler/opt.cr index 4586efb3..9cd9f67c 100644 --- a/src/compiler/opt.cr +++ b/src/compiler/opt.cr @@ -384,10 +384,12 @@ fn pass_cse(): new_v := cell_load(replace_map, rj * 8 + 8) if iri_s1(inst) == old_v: iri_set_s1(inst, new_v) - if iri_s2(inst) == old_v: - iri_set_s2(inst, new_v) - if iri_s3(inst) == old_v: - iri_set_s3(inst, new_v) + // MATCH s2/s3 are CASE start/count, not variables. + if op != IR_MATCH: + if iri_s2(inst) == old_v: + iri_set_s2(inst, new_v) + if iri_s3(inst) == old_v: + iri_set_s3(inst, new_v) rj = rj + 1 ii = ii + 1 diff --git a/src/lattice/ent_kernel.cr b/src/lattice/ent_kernel.cr index eb789d85..11170f4d 100644 --- a/src/lattice/ent_kernel.cr +++ b/src/lattice/ent_kernel.cr @@ -112,6 +112,7 @@ fn compute_live_ranges(): if ii >= ic: break inst := ist + ii + op := nod_op(inst) d := nod_dest(inst) s1 := nod_s1(inst) s2 := nod_s2(inst) @@ -127,7 +128,7 @@ fn compute_live_ranges(): if cell_load(g_ir_live_ranges, st) < 0: cell_store(g_ir_live_ranges, st, ii) cell_store(g_ir_live_ranges, st + 8, ii) - if s2 >= vs && s2 < vs + vc: + if op != IR_MATCH && s2 >= vs && s2 < vs + vc: lv := s2 - vs st := (seg + lv) * 16 if cell_load(g_ir_live_ranges, st) < 0: @@ -922,7 +923,7 @@ fn rl_rule2_func(func_i: int, vs: int, vc: int, nst: int, nc: int, es: int, ec: s2 := nod_s2(inst) if s1 == gv && op != IR_STORE: rd = 1 - if s2 == gv: + if op != IR_MATCH && s2 == gv: rd = 1 if rd != 0 && inst >= ent_def(first_def): // 推进游标至最后一个 def ≤ inst 的版本 diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index b418c3f3..9078f29a 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -270,10 +270,12 @@ def test_ir_match_opcode_is_known_to_loader(): # A MATCH node plus three CASE records, as the schema expects them to # coexist. s2/s3 carry the CASE range: s2=0, s3=count. Removing # IR_MATCH from ccr_nod_src3_semantic would reject s3=3 as a legacy - # target fact, so this fixture pins the semantic field. + # target fact, so this fixture pins the semantic field. The range is + # homogeneous (all VALUE + DEFAULT); mixed ENUM/VALUE is rejected by the + # loader rule and is covered separately. records = [ (7, KIND_VALUE, -1, labels[0], 0), - (2, KIND_ENUM, -1, labels[1], 0), + (2, KIND_VALUE, -1, labels[1], 0), (0, KIND_DEFAULT, -1, labels[2], 0), ] with_match = _replace_case(data, _pack_case(records)) @@ -381,15 +383,16 @@ def install(name, body): install("case_target_not_label", _pack_case([(1, KIND_VALUE, -1, non_label, 0)])) # Two DEFAULT rows inside one MATCH range are invalid. (The table is # global, so two different matches may each have one default.) - two_defaults = _replace_case( - data, - _pack_case( - [ - (0, KIND_DEFAULT, -1, label, 0), - (0, KIND_DEFAULT, -1, labels[1], 0), - ] - ), + two_defaults_body = _pack_case( + [ + (0, KIND_DEFAULT, -1, label, 0), + (0, KIND_DEFAULT, -1, labels[1], 0), + ] ) + two_defaults = _replace_case(data, two_defaults_body) + # Without a MATCH node, the table alone is structurally valid: this + # pins the rejection below to the loader's per-range rule. + _assert_loads(two_defaults, tmp, "case_two_defaults_no_range") nod_off = next( offset for tag, offset, _size in read_segment_table(data) if tag == NOD_TAG ) @@ -397,7 +400,7 @@ def install(name, body): struct.pack_into(" int: + return match c: + Red => 1, + 2 => 2, + _ => 3 + +fn main() -> int: + if pick(Red()) != 1: + return 1 + if pick(Green()) != 3: + return 2 + return 42 +""" + +MATCH_NO_DEFAULT_SOURCE = """fn pick(n: int) -> int: + return match n: + 1 => 11, + 2 => 22 + +fn main() -> int: + return pick(99) +""" + +MATCH_NO_DEFAULT_HIT_SOURCE = """fn pick(n: int) -> int: + return match n: + 1 => 11, + 2 => 22 + +fn main() -> int: + return pick(1) + pick(2) +""" + +MATCH_LOOP_SOURCE = """fn f() -> int: + x : int + sum : int = 0 + i : int = 0 + loop: + if i >= 5: + break + match i: + 0 =>: + x = 10 + 1 =>: + sum = sum + x + _ =>: + sum = sum + x + i = i + 1 + return sum + 2 + +fn main() -> int: + return f() +""" + +MATCH_IDENT_FALLBACK_SOURCE = """fn pickid(n: int) -> int: + return match n: + x => 42 + +fn main() -> int: + return pickid(7) +""" + + def _match_nodes(data): return [i for i, rec in enumerate(_nod_records(data)) if rec[0] == IR_MATCH] @@ -559,26 +627,31 @@ def _assert_targets_are_labels(data, rows): ) -def _assert_program(tmp, source, expected): - """Run the fixture through the interpreter and a static ELF.""" +def _assert_program(tmp, source, expected, opts=(0, 2), name="match_program"): + """Run the fixture through the interpreter and static ELFs at each -O.""" interp = _run([COREC, "run", source], cwd=BASE) assert interp.returncode == expected, ( f"interp returned {interp.returncode}, expected {expected}: " f"{interp.stdout}\n{interp.stderr}" ) - src = tmp / "match_program.cr" + src = tmp / f"{name}.cr" src.write_text(source, encoding="utf-8") - elf = tmp / "match_program.elf" - built = _run([COREC, "build", src, "-o", elf, "--static"], cwd=BASE) - assert built.returncode == 0, f"corec build failed: {built.stdout}\n{built.stderr}" - assert elf.exists(), "corec build produced no ELF" - run = subprocess.run( - [str(elf)], capture_output=True, text=True, timeout=30, cwd=str(BASE) - ) - assert run.returncode == expected, ( - f"ELF returned {run.returncode}, expected {expected}: " - f"{run.stdout}\n{run.stderr}" - ) + for opt in opts: + elf = tmp / f"{name}_O{opt}.elf" + built = _run( + [COREC, "build", src, "-o", elf, "--static", "-O", str(opt)], cwd=BASE + ) + assert built.returncode == 0, ( + f"corec build -O {opt} failed: {built.stdout}\n{built.stderr}" + ) + assert elf.exists(), f"corec build -O {opt} produced no ELF" + run = subprocess.run( + [str(elf)], capture_output=True, text=True, timeout=30, cwd=str(BASE) + ) + assert run.returncode == expected, ( + f"ELF -O {opt} returned {run.returncode}, expected {expected}: " + f"{run.stdout}\n{run.stderr}" + ) def test_int_match_is_one_match_with_default_case(): @@ -588,6 +661,8 @@ def test_int_match_is_one_match_with_default_case(): rec = _single_match(data) rows = _parse_case(segment(data, CASE_TAG)) + assert rec[1] == -1, f"MATCH dest is {rec[1]}, expected -1" + assert rec[2] >= 0, f"MATCH scrutinee is {rec[2]}, expected a var" assert rec[3] == 0, f"MATCH case start is {rec[3]}, expected 0" assert rec[4] == 5, f"MATCH case count is {rec[4]}, expected 5" assert len(rows) == 5, f"CASE has {len(rows)} rows, expected 5" @@ -615,6 +690,8 @@ def test_bool_match_is_one_match_with_value_cases(): rec = _single_match(data) rows = _parse_case(segment(data, CASE_TAG)) + assert rec[1] == -1 + assert rec[2] >= 0 assert rec[3] == 0 assert rec[4] == 3 assert [row["kind"] for row in rows] == [ @@ -634,6 +711,8 @@ def test_enum_match_is_one_match_with_enum_cases(): rec = _single_match(data) rows = _parse_case(segment(data, CASE_TAG)) + assert rec[1] == -1 + assert rec[2] >= 0 assert rec[3] == 0 assert rec[4] == 4 assert [row["kind"] for row in rows] == [ @@ -650,6 +729,22 @@ def test_enum_match_is_one_match_with_enum_cases(): _assert_program(tmp, MATCH_ENUM_SOURCE, 42) +def _run_raw_elf(tmp, data, name): + """Build an ELF through corearch in raw-int mode and return its exit code.""" + ccr = tmp / f"{name}.ccr" + ccr.write_bytes(data) + elf = tmp / f"{name}.elf" + built = _run([COREARCH, ccr, "--elf", "--static", "--raw-int", "-o", elf]) + assert built.returncode == 0, ( + f"corearch --raw-int failed: {built.stdout}\n{built.stderr}" + ) + assert elf.exists(), "corearch --raw-int produced no ELF" + run = subprocess.run( + [str(elf)], capture_output=True, text=True, timeout=30, cwd=str(BASE) + ) + return run.returncode + + def test_unsupported_patterns_keep_sequential_lowering(): with tempfile.TemporaryDirectory() as td: tmp = Path(td) @@ -660,6 +755,124 @@ def test_unsupported_patterns_keep_sequential_lowering(): assert segment(data, CASE_TAG) == struct.pack("= 0 + assert rec[4] == 2 + assert [row["kind"] for row in rows] == [KIND_VALUE, KIND_VALUE] + _assert_targets_are_labels(data, rows) + + interp = _run([COREC, "run", MATCH_NO_DEFAULT_SOURCE], cwd=BASE) + assert interp.returncode != 0, ( + "a MATCH miss with no DEFAULT must abort the interpreter" + ) + assert interp.returncode not in (11, 22), ( + f"interp fell into an arm: rc={interp.returncode}" + ) + + src = tmp / "match_nodefault.cr" + src.write_text(MATCH_NO_DEFAULT_SOURCE, encoding="utf-8") + elf = tmp / "match_nodefault.elf" + built = _run([COREC, "build", src, "-o", elf, "--static", "-O", "2"]) + assert built.returncode == 0, built.stdout + built.stderr + run = subprocess.run( + [str(elf)], capture_output=True, text=True, timeout=30, cwd=str(BASE) + ) + assert run.returncode == -4, ( + f"ELF did not trap with SIGILL: rc={run.returncode}" + ) + + +def test_no_default_match_dispatches_hits(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, MATCH_NO_DEFAULT_HIT_SOURCE, "match_nodefault_hit") + rec = _single_match(data) + rows = _parse_case(segment(data, CASE_TAG)) + assert rec[4] == 2 + assert KIND_DEFAULT not in [row["kind"] for row in rows] + _assert_program( + tmp, MATCH_NO_DEFAULT_HIT_SOURCE, 33, name="match_nodefault_hit" + ) + + +def test_enum_match_raw_int_mode(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, MATCH_ENUM_SOURCE, "match_enum_raw") + assert len(_match_nodes(data)) == 1 + assert _run_raw_elf(tmp, data, "match_enum_raw") == 42 + + +def test_loop_match_all_opt_levels(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, MATCH_LOOP_SOURCE, "match_loop") + rec = _single_match(data) + rows = _parse_case(segment(data, CASE_TAG)) + assert rec[4] == 3 + assert [row["kind"] for row in rows] == [ + KIND_VALUE, + KIND_VALUE, + KIND_DEFAULT, + ] + _assert_program( + tmp, MATCH_LOOP_SOURCE, 42, opts=(0, 2, 3), name="match_loop" + ) + + +def test_ident_fallback_executes_old_path(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, MATCH_IDENT_FALLBACK_SOURCE, "match_ident_fallback") + assert _match_nodes(data) == [] + assert segment(data, CASE_TAG) == struct.pack(" Date: Sat, 24 Oct 2026 07:07:14 +0800 Subject: [PATCH 06/22] backend: emit binary search for v10 match --- src/arch/x86_64/backend_flags.cr | 1 + src/arch/x86_64/corearch.cr | 33 +++ src/arch/x86_64/instr.cr | 264 ++++++++++++++++++++++- src/format/elf/resolve.cr | 18 +- src/targets/x86_64/linux/elf/main.cr | 2 + tests/selfhost/test_multibranch_v10.py | 287 +++++++++++++++++++++++++ 6 files changed, 601 insertions(+), 4 deletions(-) diff --git a/src/arch/x86_64/backend_flags.cr b/src/arch/x86_64/backend_flags.cr index 529f36d2..514f974a 100644 --- a/src/arch/x86_64/backend_flags.cr +++ b/src/arch/x86_64/backend_flags.cr @@ -23,6 +23,7 @@ fn register_backend_flags(): cli_flag_bool_debug("inject-read-gap", "", "Debug hook: truncate last version interval") cli_flag_bool_debug("inject-coexist-oob", "", "Debug hook: probe entries_coexist out of bounds") cli_flag_bool_debug("dump-objects", "", "Debug: dump NOD/EDG objects") + cli_flag_bool_debug("dump-match-plan", "", "Debug: dump MATCH dispatch strategy") cli_flag_bool_debug("dump-nod-types", "", "Debug: dump NOD type_id/flags") cli_flag_bool_debug("dump-alloc-strides", "", "Debug: dump ALLOC_ARRAY element type/stride") cli_flag_bool_debug("dump-types", "", "Debug: dump TYPE segment") diff --git a/src/arch/x86_64/corearch.cr b/src/arch/x86_64/corearch.cr index 7c9b7b55..26cbcbb6 100644 --- a/src/arch/x86_64/corearch.cr +++ b/src/arch/x86_64/corearch.cr @@ -311,6 +311,33 @@ fn dump_object_surface(): nod_inputs(ni) // print outgoing edge records through the kernel helper ni = ni + 1 +// --dump-match-plan: per IR_MATCH, report the CASE row count and the dispatch +// strategy the emitter selects. The strategy comes from e2_match_use_binary, +// the same predicate emit_instr uses, so this is a direct read of the emitted +// plan (linear <=2 value rows, balanced binary search otherwise). Read-only: it +// returns without entering emission. +fn dump_match_plan(): + ni : ., mut = 0 + loop: + if ni >= g_v7_nod_count: + break + if nod_op(ni) == IR_MATCH: + ms := nod_s2(ni) + mc := nod_s3(ni) + print("match ") + print_i(ni) + print(" cases ") + print_i(mc) + print(" nondefault ") + print_i(e2_match_nondefault_count(ms, mc)) + print(" strategy ") + if e2_match_use_binary(ms, mc) != 0: + print("binary") + else: + print("linear") + println("") + ni = ni + 1 + // --dump-nod-types: print each node's semantic type_id and feature flags. The // disk has no term-index field in v10, so this channel exposes the actual wire // semantics. It returns before emission. @@ -475,6 +502,12 @@ fn corearch_main() -> int: dump_object_surface() return 0 + // Hidden channel: per-MATCH dispatch strategy. It shares the emitter's + // predicate and returns before O2 allocation or emission. + if cli_has("dump-match-plan") != 0: + dump_match_plan() + return 0 + // O2 allocation and consistency checks run here, not in corec. The .ccr no // longer carries REG_ASSIGN or ENT. A violation is a compile error. // The instance declaration controls this: x86 has needs_alloc/needs_verify = 1 diff --git a/src/arch/x86_64/instr.cr b/src/arch/x86_64/instr.cr index d1ef7496..c35b0ae8 100644 --- a/src/arch/x86_64/instr.cr +++ b/src/arch/x86_64/instr.cr @@ -574,6 +574,224 @@ fn e2_st_var(buf: string, pos: int, reg: int, var_idx: int) -> int: fn e2_rslot(r: int) -> int: return E2_REG_SLOT_BASE + r +// ── v10 sparse MATCH: balanced binary search ────────────────────────────── +// +// A CASE range with more than two non-default rows is dispatched through a +// balanced binary decision tree instead of the linear cmp/je chain. Nodes are +// laid out in pre-order and every internal node is a fixed 30-byte block: +// +// movabs r11, imm64 (10) +// cmp r10, r11 (3) +// je hit (6) +// jl lower (6) +// jmp upper (5) +// +// Fixed-size nodes make child positions pure arithmetic, so internal edges are +// patched directly. Case hits and the DEFAULT miss still go through the +// g_pending_* label backpatch table; no new address surface is introduced. +// Jump-table/rodata lowering is deliberately deferred. + +E2_MATCH_NODE_SZ : int = 30 + +fn e2_match_nondefault_count(s2: int, s3: int) -> int: + n : ., mut = 0 + i : ., mut = 0 + loop: + if i >= s3: + break + kind := cell_load32(g_ccr_case, (s2 + i) * CCR_V10_CASE_DISK + CCR_CASE_KIND) + if kind != CCR_CASE_KIND_DEFAULT: + n = n + 1 + i = i + 1 + return n + +// The single strategy predicate. The emitter and the --dump-match-plan channel +// both call this, so the observable plan cannot drift from the emitted code. +fn e2_match_use_binary(s2: int, s3: int) -> int: + if e2_match_nondefault_count(s2, s3) > 2: + return 1 + return 0 + +fn e2_match_idx_less(s2: int, a: int, b: int) -> int: + // Compare by value, then by original CASE-relative index. The index + // tie-break makes the order total, so duplicate values deterministically + // keep the first (lowest-index) case. + va := cell_load(g_ccr_case, (s2 + a) * CCR_V10_CASE_DISK + CCR_CASE_VALUE) + vb := cell_load(g_ccr_case, (s2 + b) * CCR_V10_CASE_DISK + CCR_CASE_VALUE) + if va < vb: + return 1 + if va > vb: + return 0 + if a < b: + return 1 + return 0 + +// Collect the non-default rows into idx, sort them with an in-place shell sort, +// and drop duplicate values (first case wins). Returns the unique count. +fn e2_match_sort_cases(s2: int, s3: int, idx: string) -> int: + n : ., mut = 0 + i : ., mut = 0 + loop: + if i >= s3: + break + kind := cell_load32(g_ccr_case, (s2 + i) * CCR_V10_CASE_DISK + CCR_CASE_KIND) + if kind != CCR_CASE_KIND_DEFAULT: + cell_store(idx, n * 8, i) + n = n + 1 + i = i + 1 + + gap : ., mut = n / 2 + loop: + if gap <= 0: + break + i = gap + loop: + if i >= n: + break + tmp := cell_load(idx, i * 8) + j : ., mut = i + loop: + if j < gap: + break + prev := cell_load(idx, (j - gap) * 8) + if e2_match_idx_less(s2, tmp, prev) == 0: + break + cell_store(idx, j * 8, prev) + j = j - gap + cell_store(idx, j * 8, tmp) + i = i + 1 + gap = gap / 2 + + uniq : ., mut = 0 + i = 0 + loop: + if i >= n: + break + ci := cell_load(idx, i * 8) + keep : ., mut = 1 + if uniq > 0: + prev_i := cell_load(idx, (uniq - 1) * 8) + if cell_load(g_ccr_case, (s2 + ci) * CCR_V10_CASE_DISK + CCR_CASE_VALUE) == cell_load(g_ccr_case, (s2 + prev_i) * CCR_V10_CASE_DISK + CCR_CASE_VALUE): + keep = 0 + if keep != 0: + cell_store(idx, uniq * 8, ci) + uniq = uniq + 1 + i = i + 1 + return uniq + +fn e2_match_case_label(ctarget: int) -> int: + if ctarget >= 0 && ctarget < g_ir_instr_count: + if iri_op(ctarget) == IR_LABEL: + return iri_s1(ctarget) + return -1 + +fn e2_match_patch_jcc(buf: string, jmp_pos: int, label_no: int): + if label_no < 0: + return + if cell_load(g_label_poses, label_no * 8) >= 0: + e2_big_patch_jcc(buf, jmp_pos, cell_load(g_label_poses, label_no * 8)) + else: + grow_pending(g_pending_count + 1) + cell_store(g_pending_pos, g_pending_count * 8, jmp_pos + 2) + cell_store(g_pending_label, g_pending_count * 8, label_no) + g_pending_count = g_pending_count + 1 + +fn e2_match_patch_jmp(buf: string, jmp_pos: int, label_no: int): + if label_no < 0: + return + if cell_load(g_label_poses, label_no * 8) >= 0: + e2_big_patch_jmp(buf, jmp_pos, cell_load(g_label_poses, label_no * 8)) + else: + grow_pending(g_pending_count + 1) + cell_store(g_pending_pos, g_pending_count * 8, jmp_pos + 1) + cell_store(g_pending_label, g_pending_count * 8, label_no) + g_pending_count = g_pending_count + 1 + +// A miss edge goes to DEFAULT when one exists, otherwise to the ud2 trap. +fn e2_match_patch_miss(buf: string, jmp_pos: int, is_jcc: int, default_lbl: int, trap_pos: int): + if default_lbl >= 0: + if is_jcc != 0: + e2_match_patch_jcc(buf, jmp_pos, default_lbl) + else: + e2_match_patch_jmp(buf, jmp_pos, default_lbl) + else: + if is_jcc != 0: + e2_big_patch_jcc(buf, jmp_pos, trap_pos) + else: + e2_big_patch_jmp(buf, jmp_pos, trap_pos) + +// Emit the balanced tree. p is the absolute start of the tree, s2 the CASE +// start, idx the sorted CASE-relative indices, n the unique value count. +fn e2_emit_match_binary(buf: string, p: int, s2: int, idx: string, n: int, default_lbl: int, tagged: int) -> int: + tree_base := p + trap_pos := tree_base + n * E2_MATCH_NODE_SZ + k : ., mut = 0 + loop: + if k >= n: + break + // Locate pre-order node k. The pre-order rank arithmetic mirrors the + // recursive layout: left child = rank+1, right child = rank+1+left_size. + lo : ., mut = 0 + hi : ., mut = n + rank : ., mut = 0 + mid : ., mut = -1 + loop: + if mid >= 0: + break + m := lo + (hi - lo) / 2 + if k == rank: + mid = m + break + left_n := m - lo + if k < rank + 1 + left_n: + hi = m + rank = rank + 1 + else: + lo = m + 1 + rank = rank + 1 + left_n + + q := tree_base + k * E2_MATCH_NODE_SZ + ci := cell_load(idx, mid * 8) + cbase := (s2 + ci) * CCR_V10_CASE_DISK + cval := cell_load(g_ccr_case, cbase + CCR_CASE_VALUE) + imm : ., mut = cval + if tagged != 0: + imm = cval * 2 + w : ., mut = q + w = w + e2_big_mov_imm64(buf, w, 11, imm) + w = w + e2_alu(buf, w, 57) // cmp r10, r11 + je_pos := w + w = w + e2_big_jcc(buf, w, 4) // je hit + jl_pos := w + w = w + e2_big_jcc(buf, w, 12) // jl lower + jmp_pos := w + w = w + e2_big_jmp(buf, w) // jmp upper + + // Hit: the case's own arm label, via the standard pending backpatch. + hit_lbl := e2_match_case_label(cell_load_i32(g_ccr_case, cbase + CCR_CASE_TARGET)) + e2_match_patch_jcc(buf, je_pos, hit_lbl) + + // Lower edge: the left subtree's root is the next pre-order node. + left_n2 := mid - lo + if left_n2 > 0: + e2_big_patch_jcc(buf, jl_pos, tree_base + (k + 1) * E2_MATCH_NODE_SZ) + else: + e2_match_patch_miss(buf, jl_pos, 1, default_lbl, trap_pos) + + // Upper edge: skip the whole left subtree. + if mid + 1 < hi: + e2_big_patch_jmp(buf, jmp_pos, tree_base + (k + 1 + left_n2) * E2_MATCH_NODE_SZ) + else: + e2_match_patch_miss(buf, jmp_pos, 0, default_lbl, trap_pos) + k = k + 1 + + if default_lbl < 0: + // No DEFAULT: a miss is a trap. The linear path has the same ud2 shape. + e2_w8(buf, trap_pos, 15) + e2_w8(buf, trap_pos + 1, 11) + return n * E2_MATCH_NODE_SZ + 2 + return n * E2_MATCH_NODE_SZ + fn emit_instr(instr_idx: int, buf: string, pos: int) -> int: op := iri_op(instr_idx) d := iri_dest(instr_idx) @@ -1749,9 +1967,10 @@ fn emit_instr(instr_idx: int, buf: string, pos: int) -> int: return 0 if op == IR_MATCH: - // v10 multi-way dispatch (comparison chain; Task 4 may add tables). - // s1 = scrutinee var, s2 = CASE start, s3 = CASE count. Every target is - // a NOD id whose node is IR_LABEL; the label number drives backpatching. + // v10 multi-way dispatch. s1 = scrutinee var, s2 = CASE start, + // s3 = CASE count. Every target is a NOD id whose node is IR_LABEL; the + // label number drives backpatching. <=2 value rows keep the linear + // cmp/je chain; larger sparse ranges use the balanced tree above. ti_s := irv_type(s1) cp = cp + e2_load_var(buf, pos+cp, 10, s1) @@ -1778,8 +1997,47 @@ fn emit_instr(instr_idx: int, buf: string, pos: int) -> int: if g_x86_big_enabled != 0: cp = cp + e2_big_tag_reg(buf, pos+cp, 10) + // Pre-scan: locate DEFAULT. It emits nothing, so the linear emission + // sequence below stays byte-identical for <=2 value rows. default_lbl : ., mut = -1 ci : ., mut = 0 + loop: + if ci >= s3: + break + cbase := (s2 + ci) * CCR_V10_CASE_DISK + ckind := cell_load32(g_ccr_case, cbase + CCR_CASE_KIND) + if ckind == CCR_CASE_KIND_DEFAULT: + ctarget := cell_load_i32(g_ccr_case, cbase + CCR_CASE_TARGET) + if ctarget >= 0 && ctarget < g_ir_instr_count: + if iri_op(ctarget) == IR_LABEL: + default_lbl = iri_s1(ctarget) + ci = ci + 1 + + // Sparse dispatch: >2 value rows use a balanced binary search tree. + // Dense jump-table/rodata lowering is deferred. The strategy predicate is + // shared with --dump-match-plan. + if e2_match_use_binary(s2, s3) != 0: + idx := alloc((s3 + 1) * 8) + uniq := e2_match_sort_cases(s2, s3, idx) + labels_ok : ., mut = 1 + vi : ., mut = 0 + loop: + if vi >= uniq: + break + vci := cell_load(idx, vi * 8) + vtarget := cell_load_i32(g_ccr_case, (s2 + vci) * CCR_V10_CASE_DISK + CCR_CASE_TARGET) + if e2_match_case_label(vtarget) < 0: + labels_ok = 0 + vi = vi + 1 + if labels_ok != 0: + bin_tagged : ., mut = 0 + if g_x86_big_enabled != 0: + if first_kind == CCR_CASE_KIND_ENUM || ti_s == TI_INT: + bin_tagged = 1 + cp = cp + e2_emit_match_binary(buf, pos+cp, s2, idx, uniq, default_lbl, bin_tagged) + return cp + + ci = 0 loop: if ci >= s3: break diff --git a/src/format/elf/resolve.cr b/src/format/elf/resolve.cr index fa65427d..53a4c4a9 100644 --- a/src/format/elf/resolve.cr +++ b/src/format/elf/resolve.cr @@ -51,7 +51,23 @@ fn res_labels(): g_label_count = 0 off : ., mut = 0 ii : ., mut = 0 - scratch := alloc(64) + // The encoder writes at pos=0 while measuring, and backward-label + // patches write into the same scratch. A sparse MATCH emits a fixed + // 30-byte block per value row, so size the scratch from the largest + // MATCH range in this function. The 512-byte floor covers every other + // single instruction. + scratch_sz : ., mut = 512 + ii = 0 + loop: + if ii >= ic: + break + if iri_op(ist + ii) == IR_MATCH: + cand := iri_s3(ist + ii) * 32 + 64 + if cand > scratch_sz: + scratch_sz = cand + ii = ii + 1 + scratch := alloc(scratch_sz) + ii = 0 loop: if ii >= ic: break diff --git a/src/targets/x86_64/linux/elf/main.cr b/src/targets/x86_64/linux/elf/main.cr index 273a6183..c9f3a1f4 100644 --- a/src/targets/x86_64/linux/elf/main.cr +++ b/src/targets/x86_64/linux/elf/main.cr @@ -62,6 +62,8 @@ fn backend_has_extended_flags() -> int: return 1 if cli_has("dump-objects") != 0: return 1 + if cli_has("dump-match-plan") != 0: + return 1 if cli_has("dump-nod-types") != 0: return 1 if cli_has("dump-alloc-strides") != 0: diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index 9078f29a..3dc3b7aa 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -606,6 +606,108 @@ def test_duplicate_value_cases_are_allowed_by_the_reader(): return pickid(7) """ +MATCH_ONE_CASE_SOURCE = """fn pick(n: int) -> int: + return match n: + 7 => 1, + _ => 2 + +fn main() -> int: + if pick(7) != 1: + return 1 + if pick(8) != 2: + return 2 + return 42 +""" + +# >2 non-default values, so these exercise the balanced binary search path. +# The sparse generator keeps a wide spread (step 13) to prove the tree is not +# relying on table-like density. Negative literals are not match patterns in +# this frontend, so the range starts above zero. +SPARSE_STEP = 13 +SPARSE_BASE = 3 +# Deliberately not a power of two: the balanced tree then has one-child nodes, +# so the pre-order child-position arithmetic is covered both ways. +DENSE_WAYS = 33 + + +def _match_values(ways, base, step): + return [base + i * step for i in range(ways)] + + +def _match_source(values, with_default=True): + """Build an exhaustive checker: every probe must return its arm or the + default. `main` returns 42 only when all probes pass, so one build per -O + covers every hit plus out-of-range misses on both sides.""" + lines = ["fn pick(n: int) -> int:", " return match n:"] + for i, value in enumerate(values): + lines.append(f" {value} => {i + 1},") + if with_default: + lines.append(" _ => 99") + lines += ["", "fn main() -> int:"] + # Probe every arm so every tree node is exercised, plus both out-of-range + # misses when a DEFAULT exists. + probes = list(values) + if with_default: + # -1000 checks the signed jl route through the left spine. + probes += [values[0] - 1, values[-1] + 1, -1000] + for i, probe in enumerate(probes): + expected = values.index(probe) + 1 if probe in values else 99 + lines.append(f" if pick({probe}) != {expected}:") + lines.append(f" return {10 + i}") + lines.append(" return 42") + return "\n".join(lines) + "\n" + + +def _sparse_source(ways): + return _match_source(_match_values(ways, SPARSE_BASE, SPARSE_STEP)) + + +def _dense_source(ways): + return _match_source(_match_values(ways, 0, 1)) + + +def _match_plan(ccr_path): + """Run the read-only plan channel and parse one row per IR_MATCH.""" + r = _run([COREARCH, ccr_path, "--dump-match-plan"]) + assert r.returncode == 0, f"corearch --dump-match-plan failed: {r.stdout}\n{r.stderr}" + plans = [] + for line in r.stdout.splitlines(): + if not line.startswith("match "): + continue + parts = line.split() + assert len(parts) == 8, line + plans.append( + { + "nod": int(parts[1]), + "cases": int(parts[3]), + "nondefault": int(parts[5]), + "strategy": parts[7], + } + ) + return plans + + +def _ccr_file(tmp, data, name): + ccr = tmp / f"{name}.ccr" + ccr.write_bytes(data) + return ccr + + +def _run_corearch_elf(tmp, data, name): + """Build a tagged-mode ELF straight from a .ccr image and return its rc.""" + ccr = _ccr_file(tmp, data, name) + elf = tmp / f"{name}.elf" + built = _run( + [COREARCH, ccr, "--elf", "--static", "-O", "2", "-o", elf] + ) + assert built.returncode == 0, ( + f"corearch --elf failed: {built.stdout}\n{built.stderr}" + ) + run = subprocess.run( + [str(elf)], capture_output=True, text=True, timeout=30, cwd=str(BASE) + ) + return run.returncode + def _match_nodes(data): return [i for i, rec in enumerate(_nod_records(data)) if rec[0] == IR_MATCH] @@ -926,6 +1028,191 @@ def test_cir_cache_does_not_restore_match_without_cases(): assert _single_match(second)[4] == 5 +# --------------------------------------------------------------------------- +# 6. Sparse MATCH uses a balanced binary search (Task 4); dense tables deferred +# --------------------------------------------------------------------------- + + +def test_sparse_16way_match_is_binary_and_correct(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + source = _sparse_source(16) + data = _build_ccr(tmp, source, "match_sparse16") + rec = _single_match(data) + assert rec[4] == 17, f"MATCH case count {rec[4]} != 16 values + default" + plans = _match_plan(_ccr_file(tmp, data, "match_sparse16_plan")) + assert len(plans) == 1, plans + assert plans[0]["cases"] == 17, plans + assert plans[0]["nondefault"] == 16, plans + assert plans[0]["strategy"] == "binary", plans + _assert_program( + tmp, source, 42, opts=(0, 2, 3), name="match_sparse16" + ) + + +def test_sparse_64way_match_is_binary_and_correct(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + source = _sparse_source(64) + data = _build_ccr(tmp, source, "match_sparse64") + rec = _single_match(data) + assert rec[4] == 65, f"MATCH case count {rec[4]} != 64 values + default" + plans = _match_plan(_ccr_file(tmp, data, "match_sparse64_plan")) + assert len(plans) == 1, plans + assert plans[0]["cases"] == 65, plans + assert plans[0]["nondefault"] == 64, plans + assert plans[0]["strategy"] == "binary", plans + _assert_program( + tmp, source, 42, opts=(0, 2, 3), name="match_sparse64" + ) + + +def test_dense_match_range_is_binary_and_correct(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + source = _dense_source(DENSE_WAYS) + data = _build_ccr(tmp, source, "match_dense") + rec = _single_match(data) + assert rec[4] == DENSE_WAYS + 1, rec[4] + plans = _match_plan(_ccr_file(tmp, data, "match_dense_plan")) + assert len(plans) == 1, plans + assert plans[0]["nondefault"] == DENSE_WAYS, plans + # A jump-table lowering is deferred; binary search is the accepted path. + assert plans[0]["strategy"] == "binary", plans + _assert_program(tmp, source, 42, opts=(0, 2, 3), name="match_dense") + + +def test_match_plan_threshold_keeps_1_and_2_case_linear(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + one = _build_ccr(tmp, MATCH_ONE_CASE_SOURCE, "match_one") + one_plans = _match_plan(_ccr_file(tmp, one, "match_one_plan")) + assert len(one_plans) == 1, one_plans + assert one_plans[0]["nondefault"] == 1, one_plans + assert one_plans[0]["strategy"] == "linear", one_plans + + two = _build_ccr(tmp, MATCH_NO_DEFAULT_HIT_SOURCE, "match_two_values") + two_plans = _match_plan(_ccr_file(tmp, two, "match_two_values_plan")) + assert len(two_plans) == 1, two_plans + assert two_plans[0]["nondefault"] == 2, two_plans + assert two_plans[0]["strategy"] == "linear", two_plans + + # One source, two functions: 2 values stays linear, 3 goes binary. + mixed = _build_ccr(tmp, MATCH_TWO_FUNCS_SOURCE, "match_threshold") + mixed_plans = _match_plan(_ccr_file(tmp, mixed, "match_threshold_plan")) + assert [p["nondefault"] for p in mixed_plans] == [2, 3], mixed_plans + assert [p["strategy"] for p in mixed_plans] == ["linear", "binary"], mixed_plans + + _assert_program(tmp, MATCH_ONE_CASE_SOURCE, 42, name="match_one") + + +def test_enum_match_uses_binary_plan(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, MATCH_ENUM_SOURCE, "match_enum_plan") + plans = _match_plan(_ccr_file(tmp, data, "match_enum_plan")) + assert len(plans) == 1, plans + assert plans[0]["nondefault"] == 3, plans + assert plans[0]["strategy"] == "binary", plans + _assert_program( + tmp, MATCH_ENUM_SOURCE, 42, opts=(0, 2, 3), name="match_enum_plan_run" + ) + + +def test_sparse_no_default_binary_match_traps_on_miss(): + values = _match_values(16, SPARSE_BASE, SPARSE_STEP) + hit_source = _match_source(values, with_default=False) + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, hit_source, "match_sparse_nodefault") + rec = _single_match(data) + assert rec[4] == 16, rec[4] + plans = _match_plan(_ccr_file(tmp, data, "match_sparse_nodefault_plan")) + assert plans[0]["nondefault"] == 16, plans + assert plans[0]["strategy"] == "binary", plans + _assert_program( + tmp, + hit_source, + 42, + opts=(0, 2, 3), + name="match_sparse_nodefault_hit", + ) + + # The generated checker only probes arm hits; build a single-miss main + # so the binary tree's miss edge must reach ud2. + miss_lines = ["fn pick(n: int) -> int:", " return match n:"] + for i, value in enumerate(values): + miss_lines.append(f" {value} => {i + 1},") + miss_lines += [ + "", + "fn main() -> int:", + f" return pick({values[-1] + 7})", + ] + miss_source = "\n".join(miss_lines) + "\n" + src = tmp / "match_sparse_nodefault_miss.cr" + src.write_text(miss_source, encoding="utf-8") + miss_data = _build_ccr(tmp, miss_source, "match_sparse_nodefault_miss") + plans = _match_plan( + _ccr_file(tmp, miss_data, "match_sparse_nodefault_miss_plan") + ) + assert plans[0]["strategy"] == "binary", plans + elf = tmp / "match_sparse_nodefault_miss.elf" + built = _run([COREC, "build", src, "-o", elf, "--static", "-O", "2"]) + assert built.returncode == 0, built.stdout + built.stderr + run = subprocess.run( + [str(elf)], capture_output=True, text=True, timeout=30, cwd=str(BASE) + ) + assert run.returncode == -4, ( + f"binary MATCH miss did not trap with SIGILL: rc={run.returncode}" + ) + + +def test_duplicate_value_binary_match_keeps_first_case(): + source = """fn pick(n: int) -> int: + return match n: + 10 => 1, + 20 => 2, + 30 => 3, + 40 => 4, + _ => 5 + +fn main() -> int: + if pick(10) != 1: + return 10 + if pick(40) != 5: + return 40 + if pick(20) != 2: + return 20 + if pick(99) != 5: + return 99 + return 42 +""" + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, source, "match_dup") + rows = _parse_case(segment(data, CASE_TAG)) + records = [ + (row["value"], row["kind"], row["str_index"], row["target_nod"], row["flags"]) + for row in rows + ] + # Turn the 40 arm into a duplicate of 10. First-case semantics keep the + # 10 arm's target, so pick(10) must stay 1 and pick(40) falls through to + # the default. A last-wins dedupe would return 4 for pick(10). + records[3] = ( + records[0][0], + records[3][1], + records[3][2], + records[3][3], + records[3][4], + ) + dup = _replace_case(data, _pack_case(records)) + plans = _match_plan(_ccr_file(tmp, dup, "match_dup_plan")) + assert len(plans) == 1, plans + assert plans[0]["nondefault"] == 4, plans + assert plans[0]["strategy"] == "binary", plans + assert _run_corearch_elf(tmp, dup, "match_dup") == 42 + + def main(): tests = [ value From 1bbcc48a9e861c19823bdbf4d5f272b67816ddf5 Mon Sep 17 00:00:00 2001 From: DslsDZC Date: Sat, 24 Oct 2026 07:21:00 +0800 Subject: [PATCH 07/22] backend: verify v10 match binary emitter runs --- src/arch/x86_64/backend_globals.cr | 4 + src/arch/x86_64/corearch.cr | 65 +++++++++++++-- src/arch/x86_64/instr.cr | 1 + src/format/elf/elf.cr | 3 + tests/selfhost/test_multibranch_v10.py | 108 ++++++++++++++++--------- 5 files changed, 140 insertions(+), 41 deletions(-) diff --git a/src/arch/x86_64/backend_globals.cr b/src/arch/x86_64/backend_globals.cr index a4d3cceb..f2910b93 100644 --- a/src/arch/x86_64/backend_globals.cr +++ b/src/arch/x86_64/backend_globals.cr @@ -66,6 +66,10 @@ g_x86_alloc_patch_pos : string, mut g_x86_alloc_patch_cap : int, mut g_x86_alloc_patch_count : int, mut g_x86_emit_rt_stubs : int, mut // 1 = pure-static: backend emits g_set_curg/g_get_curg stubs +// Count of IR_MATCH nodes that actually entered e2_emit_match_binary. This is a +// verification counter, not a strategy predicate: a scratch-only emitter pass in +// --dump-match-plan and the real Phase 3 emission both increment it. +g_match_bin_emits : int, mut // --big-int: user int slots hold tagged values (immediate v*2 or BigInt pointer+1). // The compiler itself and the BigInt runtime blob stay raw. See int_rep.cr. g_x86_big_enabled : int, mut diff --git a/src/arch/x86_64/corearch.cr b/src/arch/x86_64/corearch.cr index 26cbcbb6..3125a75f 100644 --- a/src/arch/x86_64/corearch.cr +++ b/src/arch/x86_64/corearch.cr @@ -311,12 +311,32 @@ fn dump_object_surface(): nod_inputs(ni) // print outgoing edge records through the kernel helper ni = ni + 1 -// --dump-match-plan: per IR_MATCH, report the CASE row count and the dispatch -// strategy the emitter selects. The strategy comes from e2_match_use_binary, -// the same predicate emit_instr uses, so this is a direct read of the emitted -// plan (linear <=2 value rows, balanced binary search otherwise). Read-only: it -// returns without entering emission. +// --dump-match-plan: per IR_MATCH, report the CASE row count, the strategy +// predicate, and how many MATCH nodes actually entered e2_emit_match_binary. +// `binary_emits` is the verification channel: it is incremented inside the real +// emitter, not in the predicate, so a strategy/path mismatch shows up as +// `strategy binary` with `binary_emits 0`. +// +// The read-only guarantee is about artifacts: this runs the real emitter once +// per MATCH into a throwaway buffer and writes no ELF, no .ccr, and no source. +// A label table of -1 forces every case hit through pending backpatching, so no +// absolute target needs to exist yet. fn dump_match_plan(): + g_match_bin_emits = 0 + g_pending_count = 0 + // Mirror elf_gen's pre-emission global marking so e2_load_var's global/local + // dispatch reads an initialized table during the scratch pass. + grow_is_global(g_ir_var_count) + gi : ., mut = 0 + loop: + if gi >= g_ir_global_count: + break + gv := cell_load(g_ir_globals, gi * 24 + 8) + if gv >= 0: + grow_is_global(gv + 1) + cell_store(g_x86_is_global, gv * 8, 1) + gi = gi + 1 + max_lbl : ., mut = 0 ni : ., mut = 0 loop: if ni >= g_v7_nod_count: @@ -324,6 +344,38 @@ fn dump_match_plan(): if nod_op(ni) == IR_MATCH: ms := nod_s2(ni) mc := nod_s3(ni) + cj : ., mut = 0 + loop: + if cj >= mc: + break + ct := cell_load_i32(g_ccr_case, (ms + cj) * CCR_V10_CASE_DISK + CCR_CASE_TARGET) + if ct >= 0 && ct < g_v7_nod_count: + if nod_op(ct) == IR_LABEL: + ln := nod_s1(ct) + if ln + 1 > max_lbl: + max_lbl = ln + 1 + cj = cj + 1 + ni = ni + 1 + if max_lbl > 0: + grow_label_poses(max_lbl) + lj : ., mut = 0 + loop: + if lj >= max_lbl: + break + cell_store(g_label_poses, lj * 8, -1) + lj = lj + 1 + + ni = 0 + loop: + if ni >= g_v7_nod_count: + break + if nod_op(ni) == IR_MATCH: + ms := nod_s2(ni) + mc := nod_s3(ni) + // Scratch-only real-emitter pass: the returned byte count is not + // used, only the side effects that the channel reports. + scratch := alloc(mc * 32 + 256) + emit_instr(ni, scratch, 0) print("match ") print_i(ni) print(" cases ") @@ -337,6 +389,9 @@ fn dump_match_plan(): print("linear") println("") ni = ni + 1 + print("binary_emits ") + print_i(g_match_bin_emits) + println("") // --dump-nod-types: print each node's semantic type_id and feature flags. The // disk has no term-index field in v10, so this channel exposes the actual wire diff --git a/src/arch/x86_64/instr.cr b/src/arch/x86_64/instr.cr index c35b0ae8..43213446 100644 --- a/src/arch/x86_64/instr.cr +++ b/src/arch/x86_64/instr.cr @@ -723,6 +723,7 @@ fn e2_match_patch_miss(buf: string, jmp_pos: int, is_jcc: int, default_lbl: int, // Emit the balanced tree. p is the absolute start of the tree, s2 the CASE // start, idx the sorted CASE-relative indices, n the unique value count. fn e2_emit_match_binary(buf: string, p: int, s2: int, idx: string, n: int, default_lbl: int, tagged: int) -> int: + g_match_bin_emits = g_match_bin_emits + 1 tree_base := p trap_pos := tree_base + n * E2_MATCH_NODE_SZ k : ., mut = 0 diff --git a/src/format/elf/elf.cr b/src/format/elf/elf.cr index d9f2adb4..63d9328f 100644 --- a/src/format/elf/elf.cr +++ b/src/format/elf/elf.cr @@ -1798,6 +1798,9 @@ fn elf_gen(buf: string) -> int: g_x86_rodataref_count = 0 g_x86_alloc_patch_count = 0 g_x86_ext_rel_count = 0 + // Per-run emission counter: reset before the sizing-result Phase 3 pass so + // it counts real emission only, not the Phase 2 sizing calls. + g_match_bin_emits = 0 fi = 0 loop: if fi >= g_ir_func_count: diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index 3dc3b7aa..65a87514 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -667,24 +667,27 @@ def _dense_source(ways): def _match_plan(ccr_path): - """Run the read-only plan channel and parse one row per IR_MATCH.""" + """Run the read-only plan channel; return (one row per IR_MATCH, binary_emits).""" r = _run([COREARCH, ccr_path, "--dump-match-plan"]) assert r.returncode == 0, f"corearch --dump-match-plan failed: {r.stdout}\n{r.stderr}" plans = [] + binary_emits = None for line in r.stdout.splitlines(): - if not line.startswith("match "): - continue - parts = line.split() - assert len(parts) == 8, line - plans.append( - { - "nod": int(parts[1]), - "cases": int(parts[3]), - "nondefault": int(parts[5]), - "strategy": parts[7], - } - ) - return plans + if line.startswith("match "): + parts = line.split() + assert len(parts) == 8, line + plans.append( + { + "nod": int(parts[1]), + "cases": int(parts[3]), + "nondefault": int(parts[5]), + "strategy": parts[7], + } + ) + elif line.startswith("binary_emits "): + binary_emits = int(line.split()[1]) + assert binary_emits is not None, f"missing binary_emits line: {r.stdout!r}" + return plans, binary_emits def _ccr_file(tmp, data, name): @@ -693,12 +696,12 @@ def _ccr_file(tmp, data, name): return ccr -def _run_corearch_elf(tmp, data, name): +def _run_corearch_elf(tmp, data, name, opt=2): """Build a tagged-mode ELF straight from a .ccr image and return its rc.""" ccr = _ccr_file(tmp, data, name) elf = tmp / f"{name}.elf" built = _run( - [COREARCH, ccr, "--elf", "--static", "-O", "2", "-o", elf] + [COREARCH, ccr, "--elf", "--static", "-O", str(opt), "-o", elf] ) assert built.returncode == 0, ( f"corearch --elf failed: {built.stdout}\n{built.stderr}" @@ -1040,11 +1043,14 @@ def test_sparse_16way_match_is_binary_and_correct(): data = _build_ccr(tmp, source, "match_sparse16") rec = _single_match(data) assert rec[4] == 17, f"MATCH case count {rec[4]} != 16 values + default" - plans = _match_plan(_ccr_file(tmp, data, "match_sparse16_plan")) + plans, binary_emits = _match_plan( + _ccr_file(tmp, data, "match_sparse16_plan") + ) assert len(plans) == 1, plans assert plans[0]["cases"] == 17, plans assert plans[0]["nondefault"] == 16, plans assert plans[0]["strategy"] == "binary", plans + assert binary_emits == 1, f"binary emitter ran {binary_emits} times" _assert_program( tmp, source, 42, opts=(0, 2, 3), name="match_sparse16" ) @@ -1057,11 +1063,14 @@ def test_sparse_64way_match_is_binary_and_correct(): data = _build_ccr(tmp, source, "match_sparse64") rec = _single_match(data) assert rec[4] == 65, f"MATCH case count {rec[4]} != 64 values + default" - plans = _match_plan(_ccr_file(tmp, data, "match_sparse64_plan")) + plans, binary_emits = _match_plan( + _ccr_file(tmp, data, "match_sparse64_plan") + ) assert len(plans) == 1, plans assert plans[0]["cases"] == 65, plans assert plans[0]["nondefault"] == 64, plans assert plans[0]["strategy"] == "binary", plans + assert binary_emits == 1, f"binary emitter ran {binary_emits} times" _assert_program( tmp, source, 42, opts=(0, 2, 3), name="match_sparse64" ) @@ -1074,11 +1083,14 @@ def test_dense_match_range_is_binary_and_correct(): data = _build_ccr(tmp, source, "match_dense") rec = _single_match(data) assert rec[4] == DENSE_WAYS + 1, rec[4] - plans = _match_plan(_ccr_file(tmp, data, "match_dense_plan")) + plans, binary_emits = _match_plan( + _ccr_file(tmp, data, "match_dense_plan") + ) assert len(plans) == 1, plans assert plans[0]["nondefault"] == DENSE_WAYS, plans # A jump-table lowering is deferred; binary search is the accepted path. assert plans[0]["strategy"] == "binary", plans + assert binary_emits == 1, f"binary emitter ran {binary_emits} times" _assert_program(tmp, source, 42, opts=(0, 2, 3), name="match_dense") @@ -1086,22 +1098,31 @@ def test_match_plan_threshold_keeps_1_and_2_case_linear(): with tempfile.TemporaryDirectory() as td: tmp = Path(td) one = _build_ccr(tmp, MATCH_ONE_CASE_SOURCE, "match_one") - one_plans = _match_plan(_ccr_file(tmp, one, "match_one_plan")) + one_plans, one_emits = _match_plan( + _ccr_file(tmp, one, "match_one_plan") + ) assert len(one_plans) == 1, one_plans assert one_plans[0]["nondefault"] == 1, one_plans assert one_plans[0]["strategy"] == "linear", one_plans + assert one_emits == 0, f"linear 1-case match ran binary emitter {one_emits}x" two = _build_ccr(tmp, MATCH_NO_DEFAULT_HIT_SOURCE, "match_two_values") - two_plans = _match_plan(_ccr_file(tmp, two, "match_two_values_plan")) + two_plans, two_emits = _match_plan( + _ccr_file(tmp, two, "match_two_values_plan") + ) assert len(two_plans) == 1, two_plans assert two_plans[0]["nondefault"] == 2, two_plans assert two_plans[0]["strategy"] == "linear", two_plans + assert two_emits == 0, f"linear 2-case match ran binary emitter {two_emits}x" # One source, two functions: 2 values stays linear, 3 goes binary. mixed = _build_ccr(tmp, MATCH_TWO_FUNCS_SOURCE, "match_threshold") - mixed_plans = _match_plan(_ccr_file(tmp, mixed, "match_threshold_plan")) + mixed_plans, mixed_emits = _match_plan( + _ccr_file(tmp, mixed, "match_threshold_plan") + ) assert [p["nondefault"] for p in mixed_plans] == [2, 3], mixed_plans assert [p["strategy"] for p in mixed_plans] == ["linear", "binary"], mixed_plans + assert mixed_emits == 1, f"binary emitter ran {mixed_emits} times" _assert_program(tmp, MATCH_ONE_CASE_SOURCE, 42, name="match_one") @@ -1110,10 +1131,13 @@ def test_enum_match_uses_binary_plan(): with tempfile.TemporaryDirectory() as td: tmp = Path(td) data = _build_ccr(tmp, MATCH_ENUM_SOURCE, "match_enum_plan") - plans = _match_plan(_ccr_file(tmp, data, "match_enum_plan")) + plans, binary_emits = _match_plan( + _ccr_file(tmp, data, "match_enum_plan") + ) assert len(plans) == 1, plans assert plans[0]["nondefault"] == 3, plans assert plans[0]["strategy"] == "binary", plans + assert binary_emits == 1, f"binary emitter ran {binary_emits} times" _assert_program( tmp, MATCH_ENUM_SOURCE, 42, opts=(0, 2, 3), name="match_enum_plan_run" ) @@ -1127,9 +1151,12 @@ def test_sparse_no_default_binary_match_traps_on_miss(): data = _build_ccr(tmp, hit_source, "match_sparse_nodefault") rec = _single_match(data) assert rec[4] == 16, rec[4] - plans = _match_plan(_ccr_file(tmp, data, "match_sparse_nodefault_plan")) + plans, binary_emits = _match_plan( + _ccr_file(tmp, data, "match_sparse_nodefault_plan") + ) assert plans[0]["nondefault"] == 16, plans assert plans[0]["strategy"] == "binary", plans + assert binary_emits == 1, f"binary emitter ran {binary_emits} times" _assert_program( tmp, hit_source, @@ -1152,19 +1179,24 @@ def test_sparse_no_default_binary_match_traps_on_miss(): src = tmp / "match_sparse_nodefault_miss.cr" src.write_text(miss_source, encoding="utf-8") miss_data = _build_ccr(tmp, miss_source, "match_sparse_nodefault_miss") - plans = _match_plan( + plans, binary_emits = _match_plan( _ccr_file(tmp, miss_data, "match_sparse_nodefault_miss_plan") ) assert plans[0]["strategy"] == "binary", plans - elf = tmp / "match_sparse_nodefault_miss.elf" - built = _run([COREC, "build", src, "-o", elf, "--static", "-O", "2"]) - assert built.returncode == 0, built.stdout + built.stderr - run = subprocess.run( - [str(elf)], capture_output=True, text=True, timeout=30, cwd=str(BASE) - ) - assert run.returncode == -4, ( - f"binary MATCH miss did not trap with SIGILL: rc={run.returncode}" - ) + assert binary_emits == 1, f"binary emitter ran {binary_emits} times" + for opt in (0, 2, 3): + elf = tmp / f"match_sparse_nodefault_miss_O{opt}.elf" + built = _run( + [COREC, "build", src, "-o", elf, "--static", "-O", str(opt)] + ) + assert built.returncode == 0, built.stdout + built.stderr + run = subprocess.run( + [str(elf)], capture_output=True, text=True, timeout=30, cwd=str(BASE) + ) + assert run.returncode == -4, ( + f"binary MATCH miss at -O{opt} did not trap with SIGILL: " + f"rc={run.returncode}" + ) def test_duplicate_value_binary_match_keeps_first_case(): @@ -1206,11 +1238,15 @@ def test_duplicate_value_binary_match_keeps_first_case(): records[3][4], ) dup = _replace_case(data, _pack_case(records)) - plans = _match_plan(_ccr_file(tmp, dup, "match_dup_plan")) + plans, binary_emits = _match_plan( + _ccr_file(tmp, dup, "match_dup_plan") + ) assert len(plans) == 1, plans assert plans[0]["nondefault"] == 4, plans assert plans[0]["strategy"] == "binary", plans - assert _run_corearch_elf(tmp, dup, "match_dup") == 42 + assert binary_emits == 1, f"binary emitter ran {binary_emits} times" + for opt in (0, 2, 3): + assert _run_corearch_elf(tmp, dup, f"match_dup_O{opt}", opt) == 42 def main(): From 2f7ea1250a41dce7f0396f030deb5f2d99204593 Mon Sep 17 00:00:00 2001 From: DslsDZC Date: Sat, 24 Oct 2026 07:38:09 +0800 Subject: [PATCH 08/22] backend: add dense jump tables for v10 match --- src/arch/x86_64/backend_arrays.cr | 13 + src/arch/x86_64/backend_globals.cr | 12 + src/arch/x86_64/corearch.cr | 9 +- src/arch/x86_64/instr.cr | 211 +++++++++++- src/format/elf/elf.cr | 18 ++ tests/selfhost/test_multibranch_v10.py | 426 ++++++++++++++++++++++++- 6 files changed, 675 insertions(+), 14 deletions(-) diff --git a/src/arch/x86_64/backend_arrays.cr b/src/arch/x86_64/backend_arrays.cr index 1c0d1e67..b68260bc 100644 --- a/src/arch/x86_64/backend_arrays.cr +++ b/src/arch/x86_64/backend_arrays.cr @@ -215,6 +215,19 @@ fn grow_rodataref(needed: int): g_x86_rodataref_ro = n2 g_x86_rodataref_cap = nc +fn grow_label_abs(needed: int): + if needed < g_label_abs_cap: + return + nc : ., mut = g_label_abs_cap * 2 + if nc < 64: + nc = 64 + if nc < needed: + nc = needed + 64 + nb := alloc(nc * 8) + _dyncpy(g_label_abs, g_label_abs_cap * 8, nb) + g_label_abs = nb + g_label_abs_cap = nc + fn grow_alloc_patch(needed: int): if needed < g_x86_alloc_patch_cap: return diff --git a/src/arch/x86_64/backend_globals.cr b/src/arch/x86_64/backend_globals.cr index f2910b93..ae985393 100644 --- a/src/arch/x86_64/backend_globals.cr +++ b/src/arch/x86_64/backend_globals.cr @@ -70,6 +70,18 @@ g_x86_emit_rt_stubs : int, mut // 1 = pure-static: backend emits g_set_c // verification counter, not a strategy predicate: a scratch-only emitter pass in // --dump-match-plan and the real Phase 3 emission both increment it. g_match_bin_emits : int, mut +// Dense jump-table state. g_match_table_cursor counts table bytes during code +// emission; the rodata writer resets a separate cursor and rescans the same +// functions in the same order, so a table's recorded in-rodata offset is +// reproducible without a second code pass. +g_match_table_emits : int, mut +g_match_table_cursor : int, mut +g_match_table_ro_cursor : int, mut +// Label number -> absolute code-buffer position. g_label_poses is per-function +// and overwritten; case targets are global label numbers, so the rodata phase +// needs this global map to resolve table entries. +g_label_abs : string, mut +g_label_abs_cap : int, mut // --big-int: user int slots hold tagged values (immediate v*2 or BigInt pointer+1). // The compiler itself and the BigInt runtime blob stay raw. See int_rep.cr. g_x86_big_enabled : int, mut diff --git a/src/arch/x86_64/corearch.cr b/src/arch/x86_64/corearch.cr index 3125a75f..5ded3fdb 100644 --- a/src/arch/x86_64/corearch.cr +++ b/src/arch/x86_64/corearch.cr @@ -323,6 +323,8 @@ fn dump_object_surface(): // absolute target needs to exist yet. fn dump_match_plan(): g_match_bin_emits = 0 + g_match_table_emits = 0 + g_match_table_cursor = 0 g_pending_count = 0 // Mirror elf_gen's pre-emission global marking so e2_load_var's global/local // dispatch reads an initialized table during the scratch pass. @@ -383,7 +385,9 @@ fn dump_match_plan(): print(" nondefault ") print_i(e2_match_nondefault_count(ms, mc)) print(" strategy ") - if e2_match_use_binary(ms, mc) != 0: + if e2_match_table_span(ms, mc) > 0: + print("table") + else if e2_match_use_binary(ms, mc) != 0: print("binary") else: print("linear") @@ -392,6 +396,9 @@ fn dump_match_plan(): print("binary_emits ") print_i(g_match_bin_emits) println("") + print("table_emits ") + print_i(g_match_table_emits) + println("") // --dump-nod-types: print each node's semantic type_id and feature flags. The // disk has no term-index field in v10, so this channel exposes the actual wire diff --git a/src/arch/x86_64/instr.cr b/src/arch/x86_64/instr.cr index 43213446..7c2022d4 100644 --- a/src/arch/x86_64/instr.cr +++ b/src/arch/x86_64/instr.cr @@ -793,6 +793,196 @@ fn e2_emit_match_binary(buf: string, p: int, s2: int, idx: string, n: int, defau return n * E2_MATCH_NODE_SZ + 2 return n * E2_MATCH_NODE_SZ +// ── v10 dense MATCH: rodata jump table ──────────────────────────────────── +// +// A dense integer range lowers to a rodata table of signed 32-bit offsets +// relative to the table base: +// +// bounds check (min/max miss -> DEFAULT) +// index = (scrutinee - min) >> (tagged ? 1 : 0) +// lea r11, [rip+table] +// movsxd rax, dword [r11+r10*4] +// add rax, r11 +// jmp rax +// +// The table is written during the rodata phase, after strings and before the +// HIT pool. Every slot is present for [min,max]; holes point at DEFAULT. +// Tables are only selected when there are >=8 unique values, the range is at +// most twice as wide as the value count, and a DEFAULT arm exists. Everything +// else keeps the linear/binary behavior above. + +g_match_dense_min_unique : int = 8 + +fn e2_match_default_label(s2: int, s3: int) -> int: + ci : ., mut = 0 + loop: + if ci >= s3: + break + cbase := (s2 + ci) * CCR_V10_CASE_DISK + if cell_load32(g_ccr_case, cbase + CCR_CASE_KIND) == CCR_CASE_KIND_DEFAULT: + lbl := e2_match_case_label(cell_load_i32(g_ccr_case, cbase + CCR_CASE_TARGET)) + if lbl >= 0: + return lbl + ci = ci + 1 + return -1 + +fn e2_match_case_value(s2: int, ci: int) -> int: + return cell_load(g_ccr_case, (s2 + ci) * CCR_V10_CASE_DISK + CCR_CASE_VALUE) + +// Return the table width (max-min+1) when the dense predicate holds, else 0. +// This is the single predicate shared by the plan channel, the emitter, and the +// rodata table writer. All target labels must resolve: the table stores absolute +// positions, so a missing label would desync code and data. +fn e2_match_table_span(s2: int, s3: int) -> int: + if s3 <= 0: + return 0 + if e2_match_default_label(s2, s3) < 0: + return 0 + idx := alloc((s3 + 1) * 8) + n := e2_match_sort_cases(s2, s3, idx) + if n < g_match_dense_min_unique: + return 0 + vi : ., mut = 0 + loop: + if vi >= n: + break + vci := cell_load(idx, vi * 8) + vt := cell_load_i32(g_ccr_case, (s2 + vci) * CCR_V10_CASE_DISK + CCR_CASE_TARGET) + if e2_match_case_label(vt) < 0: + return 0 + vi = vi + 1 + first := cell_load(idx, 0) + last := cell_load(idx, (n - 1) * 8) + span := e2_match_case_value(s2, last) - e2_match_case_value(s2, first) + 1 + if span <= 0: + return 0 + if span > n * 2: + return 0 + return span + +fn e2_label_abs_pos(lbl: int) -> int: + if lbl < 0: + return -1 + if lbl >= g_label_abs_cap: + return -1 + return cell_load(g_label_abs, lbl * 8) + +// Emit the dense dispatch. span comes from e2_match_table_span; the caller has +// already loaded r10 (and tagged it for enum/tagged-int) exactly as the other +// MATCH paths do. +fn e2_emit_match_table(buf: string, p: int, s2: int, s3: int, span: int, tagged: int) -> int: + g_match_table_emits = g_match_table_emits + 1 + idx := alloc((s3 + 1) * 8) + n := e2_match_sort_cases(s2, s3, idx) + first := cell_load(idx, 0) + last := cell_load(idx, (n - 1) * 8) + minv := e2_match_case_value(s2, first) + maxv := e2_match_case_value(s2, last) + default_lbl := e2_match_default_label(s2, s3) + min_imm : ., mut = minv + max_imm : ., mut = maxv + if tagged != 0: + min_imm = minv * 2 + max_imm = maxv * 2 + + w : ., mut = p + w = w + e2_big_mov_imm64(buf, w, 11, min_imm) + w = w + e2_alu(buf, w, 57) // cmp r10, r11 + lo_pos := w + w = w + e2_big_jcc(buf, w, 12) // jl default + w = w + e2_big_mov_imm64(buf, w, 11, max_imm) + w = w + e2_alu(buf, w, 57) // cmp r10, r11 + hi_pos := w + w = w + e2_big_jcc(buf, w, 15) // jg default + w = w + e2_big_mov_imm64(buf, w, 11, min_imm) + w = w + e2_alu(buf, w, 41) // sub r10, r11 + if tagged != 0: + w = w + e2_big_untag_reg(buf, w, 10) // sar r10, 1 + + // Table lives after the strings. Record the in-rodata offset for the LEA + // patch, exactly like a string reference. + table_off := g2_rodata_sz() + g_match_table_cursor + grow_rodataref(g_x86_rodataref_count + 1) + cell_store(g_x86_rodataref_pos, g_x86_rodataref_count * 8, w) + cell_store(g_x86_rodataref_ro, g_x86_rodataref_count * 8, table_off) + g_x86_rodataref_count = g_x86_rodataref_count + 1 + w = w + e2_lrb(buf, w, 0) // lea r11, [rip+table] + + // movsxd rax, dword [r11+r10*4] + w = w + emit_rex(buf, w, 1, 0, 1, 1) + e2_w8(buf, w, 99) + w = w + 1 + w = w + emit_modrm(buf, w, 0, 0, 4) + w = w + emit_sib(buf, w, 2, 2, 3) + // add rax, r11 + w = w + emit_rex(buf, w, 1, 1, 0, 0) + e2_w8(buf, w, 1) + w = w + 1 + w = w + emit_modrm(buf, w, 3, 3, 0) + // jmp rax + e2_w8(buf, w, 255) + w = w + 1 + w = w + emit_modrm(buf, w, 3, 4, 0) + + // A table is only chosen with a DEFAULT arm, so both miss edges land there. + e2_match_patch_miss(buf, lo_pos, 1, default_lbl, w) + e2_match_patch_miss(buf, hi_pos, 1, default_lbl, w) + g_match_table_cursor = g_match_table_cursor + span * 4 + return w - p + +// Write dense tables into rodata at ro_tables_start (absolute buffer position, +// immediately after the strings). Rescans functions/instrs in emission order +// and uses the same e2_match_table_span predicate, so offsets line up with the +// LEA references recorded during code emission. Returns the table bytes written. +fn e2_match_write_tables(buf: string, ro_tables_start: int) -> int: + g_match_table_ro_cursor = 0 + fi : ., mut = 0 + loop: + if fi >= g_ir_func_count: + break + ist := cell_load(g_ir_func_instr_start, fi * 8) + ic := cell_load(g_ir_func_instr_count, fi * 8) + ii : ., mut = 0 + loop: + if ii >= ic: + break + inst := ist + ii + if iri_op(inst) == IR_MATCH: + s2 := iri_s2(inst) + s3 := iri_s3(inst) + span := e2_match_table_span(s2, s3) + if span > 0: + idx := alloc((s3 + 1) * 8) + n := e2_match_sort_cases(s2, s3, idx) + first := cell_load(idx, 0) + minv := e2_match_case_value(s2, first) + default_lbl := e2_match_default_label(s2, s3) + table_abs := ro_tables_start + g_match_table_ro_cursor + def_abs := e2_label_abs_pos(default_lbl) + if def_abs < 0: + def_abs = table_abs + vi : ., mut = 0 + k : ., mut = 0 + loop: + if k >= span: + break + v := minv + k + target_abs : ., mut = def_abs + if vi < n: + vci := cell_load(idx, vi * 8) + if e2_match_case_value(s2, vci) == v: + vt := cell_load_i32(g_ccr_case, (s2 + vci) * CCR_V10_CASE_DISK + CCR_CASE_TARGET) + ta := e2_label_abs_pos(e2_match_case_label(vt)) + if ta >= 0: + target_abs = ta + vi = vi + 1 + w32(buf, table_abs + k * 4, target_abs - table_abs) + k = k + 1 + g_match_table_ro_cursor = g_match_table_ro_cursor + span * 4 + ii = ii + 1 + fi = fi + 1 + return g_match_table_ro_cursor + fn emit_instr(instr_idx: int, buf: string, pos: int) -> int: op := iri_op(instr_idx) d := iri_dest(instr_idx) @@ -1953,6 +2143,11 @@ fn emit_instr(instr_idx: int, buf: string, pos: int) -> int: if li >= 0: grow_label_poses(li + 1) cell_store(g_label_poses, li * 8, pos) + // Global map: label numbers are process-global and the rodata table + // writer runs after all functions, when g_label_poses only holds the + // last function's labels. + grow_label_abs(li + 1) + cell_store(g_label_abs, li * 8, pos) if li + 1 > g_label_count: g_label_count = li + 1 // Patch all pending forward jumps targeting this label @@ -2014,9 +2209,19 @@ fn emit_instr(instr_idx: int, buf: string, pos: int) -> int: default_lbl = iri_s1(ctarget) ci = ci + 1 - // Sparse dispatch: >2 value rows use a balanced binary search tree. - // Dense jump-table/rodata lowering is deferred. The strategy predicate is - // shared with --dump-match-plan. + // Dense dispatch: >=8 unique values in a range at most twice as wide + // as the value count, with a DEFAULT arm, use a rodata jump table. + // Sparse (>2 rows, not dense) keeps the balanced binary search tree. + // Both predicates are shared with --dump-match-plan. + table_span := e2_match_table_span(s2, s3) + if table_span > 0: + tab_tagged : ., mut = 0 + if g_x86_big_enabled != 0: + if first_kind == CCR_CASE_KIND_ENUM || ti_s == TI_INT: + tab_tagged = 1 + cp = cp + e2_emit_match_table(buf, pos+cp, s2, s3, table_span, tab_tagged) + return cp + if e2_match_use_binary(s2, s3) != 0: idx := alloc((s3 + 1) * 8) uniq := e2_match_sort_cases(s2, s3, idx) diff --git a/src/format/elf/elf.cr b/src/format/elf/elf.cr index 63d9328f..caf8d38e 100644 --- a/src/format/elf/elf.cr +++ b/src/format/elf/elf.cr @@ -1801,6 +1801,18 @@ fn elf_gen(buf: string) -> int: // Per-run emission counter: reset before the sizing-result Phase 3 pass so // it counts real emission only, not the Phase 2 sizing calls. g_match_bin_emits = 0 + g_match_table_emits = 0 + g_match_table_cursor = 0 + // Global label -> absolute position map for the rodata table writer. g_label_poses + // is overwritten per function; table targets are global label numbers. + if g_label_abs_cap < g_label_count: + grow_label_abs(g_label_count) + lab_abs_i : ., mut = 0 + loop: + if lab_abs_i >= g_label_count: + break + cell_store(g_label_abs, lab_abs_i * 8, -1) + lab_abs_i = lab_abs_i + 1 fi = 0 loop: if fi >= g_ir_func_count: @@ -2242,6 +2254,12 @@ fn elf_gen(buf: string) -> int: cp = cp + 1 si = si + 1 + // Dense MATCH jump tables: after strings, before the HIT pool, 4-aligned. + // The writer rescans functions in emission order with the same predicate, so + // each table lands at the in-rodata offset recorded by its LEA reference. + // Strings end 8-aligned, so no pad is needed here. + cp = cp + e2_match_write_tables(buf, cp) + // HIT constant pool: lower the constant pool into rodata. // 池引用(mov r64, [rip+disp32] 占位)在函数发射期间记录(instr.cr 编码器); // 字符串区定稿后回填 disp(池区紧随字符串,8B/槽、8 对齐不破)。 diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index 65a87514..e91af449 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -625,8 +625,7 @@ def test_duplicate_value_cases_are_allowed_by_the_reader(): # this frontend, so the range starts above zero. SPARSE_STEP = 13 SPARSE_BASE = 3 -# Deliberately not a power of two: the balanced tree then has one-child nodes, -# so the pre-order child-position arithmetic is covered both ways. +# Not a power of two, and dense (0..32), so the rodata jump table is selected. DENSE_WAYS = 33 @@ -666,12 +665,17 @@ def _dense_source(ways): return _match_source(_match_values(ways, 0, 1)) -def _match_plan(ccr_path): - """Run the read-only plan channel; return (one row per IR_MATCH, binary_emits).""" +def _match_plan_full(ccr_path): + """Run the read-only plan channel. + + Returns (one row per IR_MATCH, binary_emits, table_emits). Table emits is the + verification channel for the dense jump-table path, mirroring binary_emits. + """ r = _run([COREARCH, ccr_path, "--dump-match-plan"]) assert r.returncode == 0, f"corearch --dump-match-plan failed: {r.stdout}\n{r.stderr}" plans = [] binary_emits = None + table_emits = 0 for line in r.stdout.splitlines(): if line.startswith("match "): parts = line.split() @@ -686,7 +690,16 @@ def _match_plan(ccr_path): ) elif line.startswith("binary_emits "): binary_emits = int(line.split()[1]) + elif line.startswith("table_emits "): + table_emits = int(line.split()[1]) assert binary_emits is not None, f"missing binary_emits line: {r.stdout!r}" + assert "table_emits " in r.stdout, f"missing table_emits line: {r.stdout!r}" + return plans, binary_emits, table_emits + + +def _match_plan(ccr_path): + """Backward-compatible two-value view of the plan channel.""" + plans, binary_emits, _table_emits = _match_plan_full(ccr_path) return plans, binary_emits @@ -712,6 +725,43 @@ def _run_corearch_elf(tmp, data, name, opt=2): return run.returncode +def _build_corearch_elf(tmp, data, name, opt=2, raw=False): + """Build a static ELF from a .ccr and return (path, bytes).""" + ccr = _ccr_file(tmp, data, name) + elf = tmp / f"{name}.elf" + args = [COREARCH, ccr, "--elf", "--static", "-O", str(opt), "-o", elf] + if raw: + args.insert(3, "--raw-int") + built = _run(args) + assert built.returncode == 0, ( + f"corearch --elf failed: {built.stdout}\n{built.stderr}" + ) + return elf, elf.read_bytes() + + +TABLE_STUB = bytes.fromhex("4b6304934c01d8ffe0") +# lea r11, [rip+disp32] is 4c 8d 1d ; the dense table stub follows it. +TABLE_LEA_PREFIX = bytes.fromhex("4c8d1d") + + +def _table_region(data, span): + """Locate the dense table via its lea/movsxd/add/jmp stub. + + File offsets equal code-buffer positions (the code segment maps at zero + offset), so table[+entry] is the absolute target position. Returns + (lea_pos, table_pos, entries) with entries as signed 32-bit offsets. + """ + stub = data.find(TABLE_STUB) + assert stub >= 0, "no dense jump-table stub in ELF" + assert data.find(TABLE_STUB, stub + 1) < 0, "more than one table stub" + lea = stub - 7 + assert data[lea : lea + 3] == TABLE_LEA_PREFIX, data[lea : lea + 7].hex() + rel = struct.unpack_from(" lea, (lea, table) + assert table % 4 == 0, table + assert len(set(entries)) == DENSE_WAYS, entries + for k, entry in enumerate(entries): + target = table + entry + assert 0 <= target < table, (k, entry, target, table) + assert abs(entry) < table, (k, entry) + assert elf_data[target : target + 2] == b"\x49\xba", ( + k, + target, + elf_data[target : target + 8].hex(), + ) + imm = struct.unpack_from(" int:", " return match n:"] + for value in range(8): + lines.append(f" {value} => {value + 1},") + lines += [" _ => 99", "", "fn pick_b(n: int) -> int:", " return match n:"] + for value in range(100, 116): + lines.append(f" {value} => {value - 99},") + lines += [" _ => 98", "", "fn main() -> int:"] + for value in range(8): + lines.append(f" if pick_a({value}) != {value + 1}:") + lines.append(f" return {10 + value}") + for value in range(100, 116): + lines.append(f" if pick_b({value}) != {value - 99}:") + lines.append(f" return {30 + value - 100}") + lines += [" if pick_a(50) != 99:", " return 70"] + lines += [" if pick_b(50) != 98:", " return 71"] + lines += [" return 42"] + source = "\n".join(lines) + "\n" + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, source, "match_two_dense") + assert len(_match_nodes(data)) == 2 + plans, binary_emits, table_emits = _match_plan_full( + _ccr_file(tmp, data, "match_two_dense_plan") + ) + assert [p["strategy"] for p in plans] == ["table", "table"], plans + assert [p["nondefault"] for p in plans] == [8, 16], plans + assert binary_emits == 0 + assert table_emits == 2, table_emits + _assert_program(tmp, source, 42, opts=(0, 2, 3), name="match_two_dense") + + _elf, elf_data = _build_corearch_elf( + tmp, data, "match_two_dense_elf", opt=2 + ) + stubs = [] + pos = elf_data.find(TABLE_STUB) + while pos >= 0: + stubs.append(pos) + pos = elf_data.find(TABLE_STUB, pos + 1) + assert len(stubs) == 2, stubs + for stub, span in zip(stubs, (8, 16)): + lea = stub - 7 + assert elf_data[lea : lea + 3] == TABLE_LEA_PREFIX + rel = struct.unpack_from(" int:", " return match n:"] + for i, value in enumerate(patterns): + lines.append(f" {value} => {i + 1},") + lines.append(" _ => 99") + lines += ["", "fn main() -> int:"] + for i, value in enumerate(patterns): + lines.append(f" if pick({value} - 16) != {i + 1}:") + lines.append(f" return {10 + i}") + lines += [ + " if pick(0 - 9) != 99:", + " return 30", + " if pick(0) != 99:", + " return 31", + " if pick(0 - 1000) != 99:", + " return 32", + " return 42", + ] + source = "\n".join(lines) + "\n" + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, source, "match_negative") + rows = _parse_case(segment(data, CASE_TAG)) + shifted = [ + ( + row["value"] - 16, + row["kind"], + row["str_index"], + row["target_nod"], + row["flags"], + ) + for row in rows + ] + neg = _replace_case(data, _pack_case(shifted)) + plans, binary_emits, table_emits = _match_plan_full( + _ccr_file(tmp, neg, "match_negative_plan") + ) + assert plans[0]["nondefault"] == 8, plans + assert plans[0]["strategy"] == "table", plans + assert binary_emits == 0 + assert table_emits == 1 + # Tagged mode represents negative ints as BigInt pointers, so the dense + # signed range is exercised in raw-int mode (the mode that stores signed + # machine words directly). + for opt in (0, 2, 3): + elf, _elf_data = _build_corearch_elf( + tmp, neg, f"match_negative_O{opt}", opt=opt, raw=True + ) + run = subprocess.run( + [str(elf)], capture_output=True, text=True, timeout=30 + ) + assert run.returncode == 42, ( + f"raw-int negative table -O{opt} returned {run.returncode}" + ) + + +def test_dense_enum_tagged_and_raw_int_table(): + variants = [f"V{i}" for i in range(10)] + lines = ["enum E:", " " + ", ".join(variants), "", "fn pick(e: E) -> int:", + " return match e:"] + for i, variant in enumerate(variants): + lines.append(f" {variant} => {i + 1},") + lines.append(" _ => 99") + lines += ["", "fn main() -> int:"] + for i, variant in enumerate(variants): + lines.append(f" if pick({variant}()) != {i + 1}:") + lines.append(f" return {10 + i}") + lines.append(" return 42") + source = "\n".join(lines) + "\n" + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, source, "match_enum10") + rec = _single_match(data) + assert rec[4] == 11, rec[4] + plans, binary_emits, table_emits = _match_plan_full( + _ccr_file(tmp, data, "match_enum10_plan") + ) + assert plans[0]["nondefault"] == 10, plans + assert plans[0]["strategy"] == "table", plans + assert binary_emits == 0 + assert table_emits == 1 + for opt in (0, 2, 3): + elf, _elf_data = _build_corearch_elf( + tmp, data, f"match_enum10_O{opt}", opt=opt + ) + run = subprocess.run([str(elf)], capture_output=True, text=True, timeout=30) + assert run.returncode == 42, ( + f"tagged enum table -O{opt} returned {run.returncode}" + ) + raw_elf, _raw_data = _build_corearch_elf( + tmp, data, "match_enum10_raw", opt=2, raw=True + ) + run = subprocess.run([str(raw_elf)], capture_output=True, text=True, timeout=30) + assert run.returncode == 42, f"raw-int enum table returned {run.returncode}" + + +def test_no_default_dense_match_stays_binary_and_traps(): + values = list(range(16)) + hit_source = _match_source(values, with_default=False) + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, hit_source, "match_dense_nodefault") + rec = _single_match(data) + assert rec[4] == 16, rec[4] + plans, binary_emits, table_emits = _match_plan_full( + _ccr_file(tmp, data, "match_dense_nodefault_plan") + ) + assert plans[0]["nondefault"] == 16, plans + # A dense range without DEFAULT must not select a table: holes/misses + # have no target. Binary keeps the ud2 trap. + assert plans[0]["strategy"] == "binary", plans + assert binary_emits == 1, binary_emits + assert table_emits == 0, table_emits + _assert_program( + tmp, hit_source, 42, opts=(0, 2, 3), name="match_dense_nodefault" + ) + + miss_lines = ["fn pick(n: int) -> int:", " return match n:"] + for i, value in enumerate(values): + miss_lines.append(f" {value} => {i + 1},") + miss_lines += ["", "fn main() -> int:", " return pick(99)"] + miss_source = "\n".join(miss_lines) + "\n" + miss_data = _build_ccr(tmp, miss_source, "match_dense_nodefault_miss") + miss_plans, miss_binary, miss_tables = _match_plan_full( + _ccr_file(tmp, miss_data, "match_dense_nodefault_miss_plan") + ) + assert miss_plans[0]["strategy"] == "binary", miss_plans + assert miss_binary == 1 + assert miss_tables == 0 + for opt in (0, 2, 3): + elf, _elf_data = _build_corearch_elf( + tmp, miss_data, f"match_dense_nodefault_miss_O{opt}", opt=opt + ) + run = subprocess.run( + [str(elf)], capture_output=True, text=True, timeout=30 + ) + assert run.returncode == -4, ( + f"dense no-default miss at -O{opt} did not SIGILL: " + f"rc={run.returncode}" + ) + + +def test_dense_threshold_boundary(): + # 8 unique values, span exactly 16 -> table. One wider value makes span 17, + # which must fall back to the binary tree. + table_values = list(range(7)) + [15] + binary_values = list(range(7)) + [16] + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + table_source = _match_source(table_values) + table_data = _build_ccr(tmp, table_source, "match_boundary_table") + plans, binary_emits, table_emits = _match_plan_full( + _ccr_file(tmp, table_data, "match_boundary_table_plan") + ) + assert plans[0]["nondefault"] == 8, plans + assert plans[0]["strategy"] == "table", plans + assert binary_emits == 0 + assert table_emits == 1 + _assert_program( + tmp, table_source, 42, opts=(0, 2, 3), name="match_boundary_table" + ) + + binary_source = _match_source(binary_values) + binary_data = _build_ccr(tmp, binary_source, "match_boundary_binary") + plans, binary_emits, table_emits = _match_plan_full( + _ccr_file(tmp, binary_data, "match_boundary_binary_plan") + ) + assert plans[0]["nondefault"] == 8, plans + assert plans[0]["strategy"] == "binary", plans + assert binary_emits == 1, binary_emits + assert table_emits == 0, table_emits + _assert_program( + tmp, binary_source, 42, opts=(0, 2, 3), name="match_boundary_binary" + ) + + +def test_table_follows_strings_in_rodata(): + values = list(range(8)) + lines = ["fn pick(n: int) -> int:", " return match n:"] + for i, value in enumerate(values): + lines.append(f" {value} => {i + 1},") + lines.append(" _ => 99") + lines += [ + "", + "fn main() -> int:", + ' s := "DENSETABLESTRING"', + ' if s == "SECONDRODATASTRING":', + " return 1", + ] + for i, value in enumerate(values): + lines.append(f" if pick({value}) != {i + 1}:") + lines.append(f" return {10 + i}") + lines.append(" return 42") + source = "\n".join(lines) + "\n" + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, source, "match_strings") + plans, _binary_emits, table_emits = _match_plan_full( + _ccr_file(tmp, data, "match_strings_plan") + ) + assert plans[0]["strategy"] == "table", plans + assert table_emits == 1 + _elf, elf_data = _build_corearch_elf(tmp, data, "match_strings_elf", opt=2) + _lea, table, entries = _table_region(elf_data, 8) + assert len(entries) == 8 + # Tables are written after every string. Confirm both string bodies end + # before the table base, and the table does not run past the file (the + # HIT pool, when present, is appended after the tables). + for text in (b"DENSETABLESTRING", b"SECONDRODATASTRING"): + pos = elf_data.find(text) + assert pos >= 0, text + end = ((pos + len(text) + 1 + 7) // 8) * 8 + assert table >= end, (text, table, end) + assert table + 8 * 4 <= len(elf_data), (table, len(elf_data)) + + +def test_project_mode_rejects_match_plan_channel(): + # The project-mode entry (.ccr -> ELF only) must keep rejecting the + # dump-match-plan debug channel; the concat entry owns it. + text = ( + BASE / "src/targets/x86_64/linux/elf/main.cr" + ).read_text(encoding="utf-8") + start = text.index("fn backend_has_extended_flags") + end = text.index("fn corearch_main") + guard = text[start:end] + assert 'cli_has("dump-match-plan")' in guard, guard + assert "ELF emission only" in text + def test_match_plan_threshold_keeps_1_and_2_case_linear(): with tempfile.TemporaryDirectory() as td: From 2b1652c9e6b54ec06f0e8701489b4b5a05e34af8 Mon Sep 17 00:00:00 2001 From: DslsDZC Date: Sat, 24 Oct 2026 07:50:10 +0800 Subject: [PATCH 09/22] fix: defer dense match table LEA patching to rodata --- src/arch/x86_64/backend_arrays.cr | 16 +++++ src/arch/x86_64/backend_globals.cr | 12 ++-- src/arch/x86_64/corearch.cr | 3 + src/arch/x86_64/instr.cr | 44 ++++++++----- src/format/elf/elf.cr | 1 + tests/selfhost/test_multibranch_v10.py | 85 ++++++++++++++++++++++++++ 6 files changed, 143 insertions(+), 18 deletions(-) diff --git a/src/arch/x86_64/backend_arrays.cr b/src/arch/x86_64/backend_arrays.cr index b68260bc..0006192b 100644 --- a/src/arch/x86_64/backend_arrays.cr +++ b/src/arch/x86_64/backend_arrays.cr @@ -215,6 +215,22 @@ fn grow_rodataref(needed: int): g_x86_rodataref_ro = n2 g_x86_rodataref_cap = nc +fn grow_match_table_ref(needed: int): + if needed < g_match_table_ref_cap: + return + nc : ., mut = g_match_table_ref_cap * 2 + if nc < 32: + nc = 32 + if nc < needed: + nc = needed + 32 + n1 := alloc(nc * 8) + _dyncpy(g_match_table_ref_pos, g_match_table_ref_cap * 8, n1) + g_match_table_ref_pos = n1 + n2 := alloc(nc * 8) + _dyncpy(g_match_table_ref_id, g_match_table_ref_cap * 8, n2) + g_match_table_ref_id = n2 + g_match_table_ref_cap = nc + fn grow_label_abs(needed: int): if needed < g_label_abs_cap: return diff --git a/src/arch/x86_64/backend_globals.cr b/src/arch/x86_64/backend_globals.cr index ae985393..3cb7784d 100644 --- a/src/arch/x86_64/backend_globals.cr +++ b/src/arch/x86_64/backend_globals.cr @@ -70,13 +70,17 @@ g_x86_emit_rt_stubs : int, mut // 1 = pure-static: backend emits g_set_c // verification counter, not a strategy predicate: a scratch-only emitter pass in // --dump-match-plan and the real Phase 3 emission both increment it. g_match_bin_emits : int, mut -// Dense jump-table state. g_match_table_cursor counts table bytes during code -// emission; the rodata writer resets a separate cursor and rescans the same -// functions in the same order, so a table's recorded in-rodata offset is -// reproducible without a second code pass. +// Dense jump-table state. g_match_table_cursor is the table ordinal during code +// emission; each emitted LEA is recorded with that ordinal and patched only when +// the rodata writer has the final table address. This cannot use a +// g2_rodata_sz()-based offset: Phase 3 can still intern BigInt decimal strings. g_match_table_emits : int, mut g_match_table_cursor : int, mut g_match_table_ro_cursor : int, mut +g_match_table_ref_pos : string, mut +g_match_table_ref_id : string, mut +g_match_table_ref_count : int, mut +g_match_table_ref_cap : int, mut // Label number -> absolute code-buffer position. g_label_poses is per-function // and overwritten; case targets are global label numbers, so the rodata phase // needs this global map to resolve table entries. diff --git a/src/arch/x86_64/corearch.cr b/src/arch/x86_64/corearch.cr index 5ded3fdb..86f46a67 100644 --- a/src/arch/x86_64/corearch.cr +++ b/src/arch/x86_64/corearch.cr @@ -325,6 +325,7 @@ fn dump_match_plan(): g_match_bin_emits = 0 g_match_table_emits = 0 g_match_table_cursor = 0 + g_match_table_ref_count = 0 g_pending_count = 0 // Mirror elf_gen's pre-emission global marking so e2_load_var's global/local // dispatch reads an initialized table during the scratch pass. @@ -399,6 +400,8 @@ fn dump_match_plan(): print("table_emits ") print_i(g_match_table_emits) println("") + // Scratch pass records refs but never patches them; drop them. + g_match_table_ref_count = 0 // --dump-nod-types: print each node's semantic type_id and feature flags. The // disk has no term-index field in v10, so this channel exposes the actual wire diff --git a/src/arch/x86_64/instr.cr b/src/arch/x86_64/instr.cr index 7c2022d4..66f437aa 100644 --- a/src/arch/x86_64/instr.cr +++ b/src/arch/x86_64/instr.cr @@ -899,13 +899,15 @@ fn e2_emit_match_table(buf: string, p: int, s2: int, s3: int, span: int, tagged: if tagged != 0: w = w + e2_big_untag_reg(buf, w, 10) // sar r10, 1 - // Table lives after the strings. Record the in-rodata offset for the LEA - // patch, exactly like a string reference. - table_off := g2_rodata_sz() + g_match_table_cursor - grow_rodataref(g_x86_rodataref_count + 1) - cell_store(g_x86_rodataref_pos, g_x86_rodataref_count * 8, w) - cell_store(g_x86_rodataref_ro, g_x86_rodataref_count * 8, table_off) - g_x86_rodataref_count = g_x86_rodataref_count + 1 + // Table lives after the strings, whose final size is not known during code + // emission (Phase 3 may still intern BigInt decimal strings). Record the LEA + // displacement position with this table's ordinal; the rodata writer patches + // it once the table has an actual address. + table_id := g_match_table_cursor + grow_match_table_ref(g_match_table_ref_count + 1) + cell_store(g_match_table_ref_pos, g_match_table_ref_count * 8, w) + cell_store(g_match_table_ref_id, g_match_table_ref_count * 8, table_id) + g_match_table_ref_count = g_match_table_ref_count + 1 w = w + e2_lrb(buf, w, 0) // lea r11, [rip+table] // movsxd rax, dword [r11+r10*4] @@ -927,15 +929,18 @@ fn e2_emit_match_table(buf: string, p: int, s2: int, s3: int, span: int, tagged: // A table is only chosen with a DEFAULT arm, so both miss edges land there. e2_match_patch_miss(buf, lo_pos, 1, default_lbl, w) e2_match_patch_miss(buf, hi_pos, 1, default_lbl, w) - g_match_table_cursor = g_match_table_cursor + span * 4 + g_match_table_cursor = table_id + 1 return w - p // Write dense tables into rodata at ro_tables_start (absolute buffer position, -// immediately after the strings). Rescans functions/instrs in emission order -// and uses the same e2_match_table_span predicate, so offsets line up with the -// LEA references recorded during code emission. Returns the table bytes written. +// immediately after the strings). Rescans functions/instrs in emission order, +// uses the same e2_match_table_span predicate, and patches each table's recorded +// LEA with the actual address. Returns the table bytes written. The LEA patch is +// deferred because Phase 3 may intern BigInt decimal strings after code emission, +// so no in-rodata offset is stable at e2_emit_match_table time. fn e2_match_write_tables(buf: string, ro_tables_start: int) -> int: g_match_table_ro_cursor = 0 + table_bytes : ., mut = 0 fi : ., mut = 0 loop: if fi >= g_ir_func_count: @@ -957,7 +962,8 @@ fn e2_match_write_tables(buf: string, ro_tables_start: int) -> int: first := cell_load(idx, 0) minv := e2_match_case_value(s2, first) default_lbl := e2_match_default_label(s2, s3) - table_abs := ro_tables_start + g_match_table_ro_cursor + table_id := g_match_table_ro_cursor + table_abs := ro_tables_start + table_bytes def_abs := e2_label_abs_pos(default_lbl) if def_abs < 0: def_abs = table_abs @@ -978,10 +984,20 @@ fn e2_match_write_tables(buf: string, ro_tables_start: int) -> int: vi = vi + 1 w32(buf, table_abs + k * 4, target_abs - table_abs) k = k + 1 - g_match_table_ro_cursor = g_match_table_ro_cursor + span * 4 + // Patch every LEA that refers to this table ordinal. + ri : ., mut = 0 + loop: + if ri >= g_match_table_ref_count: + break + if cell_load(g_match_table_ref_id, ri * 8) == table_id: + ref_pos := cell_load(g_match_table_ref_pos, ri * 8) + w32(buf, ref_pos + 3, table_abs - (ref_pos + 7)) + ri = ri + 1 + g_match_table_ro_cursor = table_id + 1 + table_bytes = table_bytes + span * 4 ii = ii + 1 fi = fi + 1 - return g_match_table_ro_cursor + return table_bytes fn emit_instr(instr_idx: int, buf: string, pos: int) -> int: op := iri_op(instr_idx) diff --git a/src/format/elf/elf.cr b/src/format/elf/elf.cr index caf8d38e..42c17560 100644 --- a/src/format/elf/elf.cr +++ b/src/format/elf/elf.cr @@ -1803,6 +1803,7 @@ fn elf_gen(buf: string) -> int: g_match_bin_emits = 0 g_match_table_emits = 0 g_match_table_cursor = 0 + g_match_table_ref_count = 0 // Global label -> absolute position map for the rodata table writer. g_label_poses // is overwritten per function; table targets are global label numbers. if g_label_abs_cap < g_label_count: diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index e91af449..9bb2caa4 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -1447,6 +1447,91 @@ def test_dense_threshold_boundary(): ) +def test_dense_table_with_bigint_string_interning(): + # Regression: IR_CONST_BIG interns a decimal string during Phase 3 via + # g2_str_off. A g2_rodata_sz()-derived table offset recorded during code + # emission then pointed into the string area and the table jump crashed. + source = """fn pick(n: int) -> int: + return match n: + 0 => 1, + 1 => 2, + 2 => 3, + 3 => 4, + 4 => 5, + 5 => 6, + 6 => 7, + 7 => 8, + _ => 99 + +fn main() -> int: + big := 1180591620717411303424 + if big == 0: + return 1 + if pick(0) != 1: + return 10 + if pick(7) != 8: + return 11 + if pick(9) != 99: + return 12 + return 42 +""" + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, source, "match_bigint_string") + plans, binary_emits, table_emits = _match_plan_full( + _ccr_file(tmp, data, "match_bigint_string_plan") + ) + assert plans[0]["nondefault"] == 8, plans + assert plans[0]["strategy"] == "table", plans + assert binary_emits == 0 + assert table_emits == 1 + _assert_program( + tmp, source, 42, opts=(0, 2, 3), name="match_bigint_string" + ) + + # Same-function ordering: the MATCH is emitted before the BigInt literal + # is interned, so the LEA must still be patched later. + same_source = """fn same(n: int) -> int: + r := match n: + 0 => 1, + 1 => 2, + 2 => 3, + 3 => 4, + 4 => 5, + 5 => 6, + 6 => 7, + 7 => 8, + _ => 99 + big := 1180591620717411303424 + if big == 0: + return 1 + return r + +fn main() -> int: + if same(0) != 1: + return 10 + if same(7) != 8: + return 11 + if same(9) != 99: + return 12 + return 42 +""" + same_data = _build_ccr(tmp, same_source, "match_bigint_same_fn") + same_plans, same_binary, same_tables = _match_plan_full( + _ccr_file(tmp, same_data, "match_bigint_same_fn_plan") + ) + assert same_plans[0]["strategy"] == "table", same_plans + assert same_binary == 0 + assert same_tables == 1 + _assert_program( + tmp, + same_source, + 42, + opts=(0, 2, 3), + name="match_bigint_same_fn", + ) + + def test_table_follows_strings_in_rodata(): values = list(range(8)) lines = ["fn pick(n: int) -> int:", " return match n:"] From f2eb6b319264bd55201b51b108142981a4d9ffd6 Mon Sep 17 00:00:00 2001 From: DslsDZC Date: Sat, 24 Oct 2026 08:14:14 +0800 Subject: [PATCH 10/22] ccr: cover string char and ident match patterns --- src/compiler/checker.cr | 65 +++- src/compiler/ir_gen.cr | 160 +++++++--- src/compiler/ty_shadow.cr | 24 ++ tests/selfhost/test_match_exhaust.py | 17 +- tests/selfhost/test_multibranch_v10.py | 419 ++++++++++++++++++++++--- 5 files changed, 584 insertions(+), 101 deletions(-) diff --git a/src/compiler/checker.cr b/src/compiler/checker.cr index 2fd36aec..eaa75a81 100644 --- a/src/compiler/checker.cr +++ b/src/compiler/checker.cr @@ -3392,6 +3392,8 @@ fn infer_expr(node: int) -> int: mc_save := mc_begin() // 覆盖位图入栈,容量无界 m_unmappable : ., mut = 0 m_wild : ., mut = 0 // 通配/绑定已见(冗余臂判据用;非穷尽性判据本体) + m_literal : ., mut = 0 // 任一 int/bool/string/char 字面量臂已见(无限域 -> 需 irrefutable 臂) + m_order_rep : ., mut = 0 // 通配/绑定后仍有臂(TM05 只报一次) m_dup : ., mut = -1 // 首个冗余臂模式节点(-1 = 无) loop: if an < 0: @@ -3400,6 +3402,12 @@ fn infer_expr(node: int) -> int: arm_body := ast_b(an) // EXPR_ARM: b = body if arm_pat >= 0: pk := sh_match_pat_kind(arm_pat) + // 通配/绑定模式吞掉后续所有臂:后续臂不可达,报 TM05。 + if m_wild != 0 && m_order_rep == 0: + check_error(EC_TM_WILDCARD_ORDER, + "Wildcard or binding pattern must be the last arm", + ast_line(arm_pat), ast_col(arm_pat)) + m_order_rep = 1 if pk == 1: if m_wild != 0: if m_dup < 0: @@ -3407,6 +3415,27 @@ fn infer_expr(node: int) -> int: m_wild = 1 m_pats = tt_cons(tt_top(), m_pats) else if pk == 2: + // 嵌套子模式只在 Task 5b 完整支持;ident/`_` 之外的模式 + // (字面量、嵌套枚举、struct…)现在不得静默当成“变体匹配”。 + sub_count := ast_c(arm_pat) + spi : ., mut = 0 + spn := ast_b(arm_pat) + bad_nested : ., mut = 0 + loop: + if spi >= sub_count: + break + if spn >= 0: + sk := ast_kind(spn) + if sk != EXPR_IDENT && sk != EXPR_WILDCARD: + check_error(EC_TM_NESTED, + "Nested match patterns are not supported yet", + ast_line(spn), ast_col(spn)) + bad_nested = 1 + break + spn = spn + 1 + spi = spi + 1 + if bad_nested != 0: + m_unmappable = 1 vi := sh_match_pat_variant(match_ti, arm_pat) vt : ., mut = -1 if vi >= 0: @@ -3434,7 +3463,21 @@ fn infer_expr(node: int) -> int: mc_add(ci) m_pats = tt_cons(vt, m_pats) else: - m_unmappable = 1 // 字面量/struct 模式,因此不判(登记) + lit_k := sh_match_lit_kind(arm_pat) + if lit_k >= 0: + m_literal = 1 + if sh_match_lit_seen(first_arm, an, lit_k, ast_int_val(arm_pat)) != 0: + if m_dup < 0: + m_dup = arm_pat + else: + // 插值字符串 pattern 不是字面量:parser 产出 EXPR_BINARY/ + // EXPR_INTERP_HOLE。它既不能按值比较,也不得当成“任意串匹配”。 + ak0 := ast_kind(arm_pat) + if ak0 == EXPR_BINARY || ak0 == EXPR_INTERP_HOLE: + check_error(EC_TM_NESTED, + "String interpolation is not supported in a match pattern; use a literal", + ast_line(arm_pat), ast_col(arm_pat)) + m_unmappable = 1 // struct / 插值 / EXPR_BIGINT 等,不判穷尽 // Bind pattern variables in new scope push_scope() if arm_pat >= 0: @@ -3453,7 +3496,9 @@ fn infer_expr(node: int) -> int: spn = spn + 1 spi = spi + 1 if ast_kind(arm_pat) == EXPR_IDENT: - def_sym(ast_int_val(arm_pat), SYM_LOCAL, TI_INT, -1) + // 绑定臂的值就是 scrutinee:类型取 scrutinee 类型(旧实现 + // 一律 TI_INT,字符串/char/bool scrutinee 会错型)。 + def_sym(ast_int_val(arm_pat), SYM_LOCAL, match_ti, -1) push_borrow_scope() arm_ti := infer_expr(arm_body) pop_borrow_scope() @@ -3466,12 +3511,24 @@ fn infer_expr(node: int) -> int: // 判定结果:0 = 不穷尽(诊断 + 具体反例变体名);1 = 穷尽;-1 = 不判(非枚举域/不可映射 // 模式/引擎未知),零诊断(判定约定:未知不得当不穷尽,也不得当穷尽)。 ty_budget_reset(200000) - m_verdict := sh_match_exhaustive(match_ti, m_pats, m_unmappable) + m_verdict : ., mut = -1 + if m_unmappable == 0 && m_literal != 0: + // 字面量域(int/string/char)无限;bool 也按设计要求显式列出 `_`/绑定臂。 + // 没有 irrefutable 臂 -> 不穷尽,不再静默放行。 + if m_wild != 0: + m_verdict = 1 + else: + m_verdict = 0 + else: + m_verdict = sh_match_exhaustive(match_ti, m_pats, m_unmappable) ty_budget_reset(200000) if m_verdict == 0: mi : ., mut = -1 msg := "Non-exhaustive match" - if get_type_kind(match_ti) == TYP_OPTIONAL: + if m_literal != 0: + // 字面量域没有具体变体可命名;保持与枚举反例同一诊断码。 + msg = msg + ": add a wildcard or binding arm" + else if get_type_kind(match_ti) == TYP_OPTIONAL: // `T?` 两分支反例命名:0 = 有值(Some)/ 1 = null(None) mi = mc_first_missing(2) if mi == 0: diff --git a/src/compiler/ir_gen.cr b/src/compiler/ir_gen.cr index 42de844b..8db02f53 100644 --- a/src/compiler/ir_gen.cr +++ b/src/compiler/ir_gen.cr @@ -2890,12 +2890,43 @@ fn gen_expr(node: int) -> int: is_wildcard := 0 if pat_kind == EXPR_WILDCARD: is_wildcard = 1 + // A binding ident is irrefutable: `x` (or `_` if the lexer ever routes + // it here) matches every value and binds the scrutinee. `_` is a + // wildcard; every other name is a binding arm. + is_ident_bind := 0 + ident_ni : ., mut = -1 + if pat_kind == EXPR_IDENT: + ident_ni = ast_int_val(arm_pat) + if str_eq(istr_get(ident_ni), "_") != 0: + is_wildcard = 1 + else: + is_ident_bind = 1 + // Nested refutable sub-patterns (string/char/int/... inside an enum + // payload) are Task 5b. Do not silently treat the outer variant as an + // unconditional match: route the arm to the fall edge instead. + nested_bad : ., mut = 0 + if pat_kind == EXPR_ENUMPAT: + en_sub_count := ast_c(arm_pat) + en_i : ., mut = 0 + en_p := ast_b(arm_pat) + loop: + if en_i >= en_sub_count: + break + if en_p >= 0: + en_k := ast_kind(en_p) + if en_k != EXPR_IDENT && en_k != EXPR_WILDCARD: + nested_bad = 1 + break + en_p = en_p + 1 + en_i = en_i + 1 if match_mode != 0: // MATCH owns the dispatch: suppress the sequential compare chain // and the payload-binding reads (sub-patterns already forced the // fallback path). pat_kind = -2 is_wildcard = 0 + is_ident_bind = 0 + nested_bad = 0 // 可选表示位:臂条件与臂体绑定共用(声明在臂作用域) rep_v : ., mut = -1 body_lbl := new_label() @@ -2903,55 +2934,60 @@ fn gen_expr(node: int) -> int: has_next := 0 if ast_c(an) >= 0: has_next = 1 - if is_wildcard == 1: + if is_wildcard == 1 || is_ident_bind == 1: emit(IR_JUMP, -1, body_lbl, 0, 0, 0) else if pat_kind == EXPR_ENUMPAT: - variant_ni := get_variant_name_idx(ast_a(arm_pat)) - // 解包侧判表示,scrutinee 配表示位(可选槽)时按位分派: - // 裸值(0)-> 值即携带值:Some 模式命中臂体(不读 tag,裸值非对象, - // 读 tag 即 SIGSEGV = 原红态);None 模式不命中(裸值非 null)。 - // 装箱(≠0)-> 既有 tag 分派(逐字不变)。 - // 无表示位(非可选槽 / 未覆盖情形)-> 既有路径逐字不变(装箱假定)。 - if g_optrep_on != 0 && match_val >= 0: - rep_v = irv_rep(match_val) - if rep_v < 0: - tag_var := new_ir_var("tag", TI_INT) - emit(IR_LOAD_ENUM_TAG, tag_var, match_val, 0, 0, 0) - vtag := new_ir_var("vtag", TI_INT) - emit(IR_CONST, vtag, variant_ni, 0, 0, TI_INT) - cmp_var := new_ir_var("cmp", TI_INT) - emit(IR_BINARY, cmp_var, tag_var, vtag, OP_EQ, 0) + if nested_bad != 0: if has_next == 1: fall_lbl = new_label() - emit(IR_BRANCH, -1, cmp_var, body_lbl, fall_lbl, 0) + emit(IR_JUMP, -1, fall_lbl, 0, 0, 0) else: - is_some_pat : ., mut = 0 - if str_eq(istr_get(variant_ni), "Some") != 0: - is_some_pat = 1 - tag_lbl := new_label() - if has_next == 1: - fall_lbl = new_label() - zb := new_ir_var("_rz", TI_INT) - emit(IR_CONST, zb, 0, 0, 0, TI_INT) - isb := new_ir_var("rep_isbare", TI_INT) - if is_some_pat != 0: - emit(IR_BINARY, isb, rep_v, zb, OP_EQ, 0) - else: - emit(IR_BINARY, isb, rep_v, zb, OP_NE, 0) - // Some:裸 -> 直接命中;装箱 -> 落 tag 比对。None:裸 -> 不命中(落 fall); - // 装箱 -> 落 tag 比对。 - if is_some_pat != 0: - emit(IR_BRANCH, -1, isb, body_lbl, tag_lbl, 0) + variant_ni := get_variant_name_idx(ast_a(arm_pat)) + // 解包侧判表示,scrutinee 配表示位(可选槽)时按位分派: + // 裸值(0)-> 值即携带值:Some 模式命中臂体(不读 tag,裸值非对象, + // 读 tag 即 SIGSEGV = 原红态);None 模式不命中(裸值非 null)。 + // 装箱(≠0)-> 既有 tag 分派(逐字不变)。 + // 无表示位(非可选槽 / 未覆盖情形)-> 既有路径逐字不变(装箱假定)。 + if g_optrep_on != 0 && match_val >= 0: + rep_v = irv_rep(match_val) + if rep_v < 0: + tag_var := new_ir_var("tag", TI_INT) + emit(IR_LOAD_ENUM_TAG, tag_var, match_val, 0, 0, 0) + vtag := new_ir_var("vtag", TI_INT) + emit(IR_CONST, vtag, variant_ni, 0, 0, TI_INT) + cmp_var := new_ir_var("cmp", TI_INT) + emit(IR_BINARY, cmp_var, tag_var, vtag, OP_EQ, 0) + if has_next == 1: + fall_lbl = new_label() + emit(IR_BRANCH, -1, cmp_var, body_lbl, fall_lbl, 0) else: - emit(IR_BRANCH, -1, isb, tag_lbl, fall_lbl, 0) - emit(IR_LABEL, -1, tag_lbl, 0, 0, 0) - tag_var := new_ir_var("tag", TI_INT) - emit(IR_LOAD_ENUM_TAG, tag_var, match_val, 0, 0, 0) - vtag := new_ir_var("vtag", TI_INT) - emit(IR_CONST, vtag, variant_ni, 0, 0, TI_INT) - cmp_var := new_ir_var("cmp", TI_INT) - emit(IR_BINARY, cmp_var, tag_var, vtag, OP_EQ, 0) - emit(IR_BRANCH, -1, cmp_var, body_lbl, fall_lbl, 0) + is_some_pat : ., mut = 0 + if str_eq(istr_get(variant_ni), "Some") != 0: + is_some_pat = 1 + tag_lbl := new_label() + if has_next == 1: + fall_lbl = new_label() + zb := new_ir_var("_rz", TI_INT) + emit(IR_CONST, zb, 0, 0, 0, TI_INT) + isb := new_ir_var("rep_isbare", TI_INT) + if is_some_pat != 0: + emit(IR_BINARY, isb, rep_v, zb, OP_EQ, 0) + else: + emit(IR_BINARY, isb, rep_v, zb, OP_NE, 0) + // Some:裸 -> 直接命中;装箱 -> 落 tag 比对。None:裸 -> 不命中(落 fall); + // 装箱 -> 落 tag 比对。 + if is_some_pat != 0: + emit(IR_BRANCH, -1, isb, body_lbl, tag_lbl, 0) + else: + emit(IR_BRANCH, -1, isb, tag_lbl, fall_lbl, 0) + emit(IR_LABEL, -1, tag_lbl, 0, 0, 0) + tag_var := new_ir_var("tag", TI_INT) + emit(IR_LOAD_ENUM_TAG, tag_var, match_val, 0, 0, 0) + vtag := new_ir_var("vtag", TI_INT) + emit(IR_CONST, vtag, variant_ni, 0, 0, TI_INT) + cmp_var := new_ir_var("cmp", TI_INT) + emit(IR_BINARY, cmp_var, tag_var, vtag, OP_EQ, 0) + emit(IR_BRANCH, -1, cmp_var, body_lbl, fall_lbl, 0) else if pat_kind == EXPR_INT: pat_val := new_ir_var("pval", TI_INT) emit(IR_CONST, pat_val, ast_int_val(arm_pat), 0, 0, TI_INT) @@ -2971,6 +3007,39 @@ fn gen_expr(node: int) -> int: if has_next == 1: fall_lbl = new_label() emit(IR_BRANCH, -1, cmp_var, body_lbl, fall_lbl, 0) + else if pat_kind == EXPR_CHAR: + // char 运行期值 = 字符驻留串索引;模式存同一索引(ast_int_val)。 + // 直接比较当前值,不改 char 语义(不是 Unicode 码点)。 + pat_val := new_ir_var("pval", TI_CHAR) + track_str(ast_int_val(arm_pat)) + emit(IR_CONST, pat_val, ast_int_val(arm_pat), 0, 0, TI_CHAR) + cmp_var := new_ir_var("cmp", TI_INT) + emit(IR_BINARY, cmp_var, match_val, pat_val, OP_EQ, 0) + if has_next == 1: + fall_lbl = new_label() + emit(IR_BRANCH, -1, cmp_var, body_lbl, fall_lbl, 0) + else if pat_kind == EXPR_STRING: + // String patterns do not enter CASE: compare through str_eq and + // branch on the boolean result (the sequential path). + pat_val := new_ir_var("spat", TI_STR) + track_str(ast_int_val(arm_pat)) + emit(IR_CONST, pat_val, ast_int_val(arm_pat), 0, 0, TI_STR) + eq0 := new_ir_var("_seq0", TI_STR) + eq1 := new_ir_var("_seq1", TI_STR) + emit(IR_STORE, -1, eq0, match_val, 0, 0) + emit(IR_STORE, -1, eq1, pat_val, 0, 0) + cmp_var := new_ir_var("cmp", TI_BOOL) + emit(IR_CALL, cmp_var, eq0, 2, str_intern("str_eq"), TI_BOOL) + if has_next == 1: + fall_lbl = new_label() + emit(IR_BRANCH, -1, cmp_var, body_lbl, fall_lbl, 0) + else: + // Unsupported pattern kind (struct/tuple/interpolated string/bigint). + // Preserve refutability: never fall through to the body. + if match_mode == 0: + if has_next == 1: + fall_lbl = new_label() + emit(IR_JUMP, -1, fall_lbl, 0, 0, 0) body_nod : ., mut = -1 if match_mode != 0: body_nod = g_ir_instr_count @@ -3030,6 +3099,9 @@ fn gen_expr(node: int) -> int: if spn >= 0 && ast_kind(spn) == EXPR_IDENT: bind_local(ast_int_val(spn), fv) fi = fi + 1 + if is_ident_bind == 1: + // Irrefutable binding arm: the name is the scrutinee itself. + bind_local(ident_ni, match_val) body_val := gen_expr(arm_body) body_val = force_if_thunk(body_val) if body_val >= 0: @@ -3039,7 +3111,7 @@ fn gen_expr(node: int) -> int: emit_rep_set(mrep_var, rep_enc_of_expr(arm_body, body_val)) pop_ir_scope() emit(IR_JUMP, -1, merge_lbl, 0, 0, 0) - if match_mode == 0 && is_wildcard == 0 && has_next == 1: + if match_mode == 0 && is_wildcard == 0 && is_ident_bind == 0 && has_next == 1: emit(IR_LABEL, -1, fall_lbl, 0, 0, 0) an = ast_c(an) arm_idx = arm_idx + 1 diff --git a/src/compiler/ty_shadow.cr b/src/compiler/ty_shadow.cr index e1fdd23e..ee317314 100644 --- a/src/compiler/ty_shadow.cr +++ b/src/compiler/ty_shadow.cr @@ -1216,6 +1216,30 @@ fn sh_match_pat_kind(pat_node: int) -> int: return 2 return 0 // 字面量 / struct 模式 / EXPR_NONE -> 不可映射(登记) +// 字面量模式种类(int/bool/string/char),-1 = 非字面量模式。 +// 这些模式在 checker 侧可逐值比较,但域是无限的(bool 除外),因此没有 +// `_`/绑定臂时一律按不穷尽处理;枚举域仍走类型引擎。 +fn sh_match_lit_kind(pat_node: int) -> int: + if pat_node < 0: + return -1 + k := ast_kind(pat_node) + if k == EXPR_INT || k == EXPR_BOOL || k == EXPR_STRING || k == EXPR_CHAR: + return k + return -1 + +// 同一字面量(kind + value)是否已在前面的臂出现(首个匹配语义)。 +fn sh_match_lit_seen(first_arm: int, cur_arm: int, kind: int, value: int) -> int: + an := first_arm + loop: + if an < 0 || an == cur_arm: + break + p := ast_a(an) + if p >= 0: + if ast_kind(p) == kind && ast_int_val(p) == value: + return 1 + an = ast_c(an) + return 0 + // 变体模式 -> 本枚举变体下标(-1 = 不属本枚举 / 名字不可解析 / scrutinee 不可展开)。 // 名字有两种形态(parser.cr parse_pattern 的产物):裸变体名 Red,或限定名 // Color.Red(parser 用 name + "." + variant 拼接)。限定名取最后一个点之后的段 diff --git a/tests/selfhost/test_match_exhaust.py b/tests/selfhost/test_match_exhaust.py index 73c303f9..95a98679 100644 --- a/tests/selfhost/test_match_exhaust.py +++ b/tests/selfhost/test_match_exhaust.py @@ -7,7 +7,8 @@ / payload 变体 / 泛型枚举实例 / 单变体枚举 / 枚举形参位 负:缺臂 ⇒ rc=1 + `error[TM03]` + 反例含具体缺失**变体名** + **无产物**(绝不静默 rc=0—— 修复前实测:缺臂产物照出,未匹配值静默得 0);无臂 ⇒ 同上;`check` 子命令同拒 - 登记:非枚举域不判穷尽(int 匹配 rc=0);不可映射模式(枚举匹配里混字面量臂)不判穷尽 rc=0 + 字面量域:int/string/char 无 `_`/绑定臂 ⇒ rc=1 + `error[TM03]` + 无产物(Task 5a); + 枚举匹配里混字面量臂同样按字面量域判(不再静默放行) 软面:重复臂 ⇒ `error[TM04]` **非硬门**(rc=0 + 产物照出)——诊断面登记,非 rc 面收紧 背景(Task 3 前实测,本套件即其回归):`EC_TM_*`(9001-9009)此前仅定义零 raise,match @@ -195,12 +196,14 @@ def main(): case_run_reject("run_path_rejected", COLOR + "fn main() -> int:\n c := Blue()\n return match c:\n Red => 1, Green => 2,\n", ["error[TM03]", "missing variant 'Blue'"]), - # 登记:非枚举域不判穷尽(int 字面量匹配 ⇒ 零诊断,rc=0) - case_run("non_enum_domain_not_judged", - "fn main() -> int:\n n := 3\n return match n:\n 1 => 10, 2 => 20,\n", 0), - # 登记:不可映射模式(枚举匹配混字面量臂)不判穷尽(-1 = 不判,不得当「不穷尽」) - case_run("unmappable_pattern_not_judged", - "enum Color2:\n Red2, Green2, Blue2\nfn main() -> int:\n c := Blue2()\n return match c:\n Red2 => 1, 5 => 2,\n", 0), + # 字面量域(Task 5a):int 无通配/绑定臂 ⇒ rc=1 + TM03 + 无产物 + case_reject("literal_domain_requires_wildcard", + "fn main() -> int:\n n := 3\n return match n:\n 1 => 10, 2 => 20,\n", + ["error[TM03]", "Non-exhaustive match", "add a wildcard or binding arm"]), + # 枚举混字面量臂:字面量仍是 refutable,无通配/绑定臂 ⇒ 同拒 + case_reject("mixed_literal_enum_requires_wildcard", + "enum Color2:\n Red2, Green2, Blue2\nfn main() -> int:\n c := Blue2()\n return match c:\n Red2 => 1, 5 => 2,\n", + ["error[TM03]", "Non-exhaustive match"]), # 软面:重复臂 ⇒ TM04 非硬门(rc=0 + 产物照出) case_soft("redundant_arm_soft", COLOR + "fn main() -> int:\n c := Red()\n return match c:\n Red => 1, Red => 2, Green => 3, Blue => 4,\n", diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index 9bb2caa4..ca914905 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -191,6 +191,32 @@ def _assert_load_rejected(data, tmp, name): assert "invalid .ccr file" in out, f"{name}: missing loader diagnostic: {out}" +def _assert_compile_rejected(tmp, source, name, needles): + src = tmp / f"{name}.cr" + src.write_text(source, encoding="utf-8") + out = tmp / f"{name}.elf" + r = _run([COREC, "build", src, "-o", out, "--static"]) + text = r.stdout + r.stderr + assert r.returncode == 1, f"{name}: expected rc=1, got {r.returncode}: {text}" + for needle in needles: + assert needle in text, f"{name}: missing {needle!r}: {text}" + assert not out.exists(), f"{name}: rejected build still produced {out}" + return text + + +def _assert_soft_diagnostic(tmp, source, name, needles): + src = tmp / f"{name}.cr" + src.write_text(source, encoding="utf-8") + out = tmp / f"{name}.elf" + r = _run([COREC, "build", src, "-o", out, "--static"]) + text = r.stdout + r.stderr + assert r.returncode == 0, f"{name}: expected rc=0, got {r.returncode}: {text}" + for needle in needles: + assert needle in text, f"{name}: missing {needle!r}: {text}" + assert out.exists(), f"{name}: soft diagnostic produced no artifact" + return text + + # --------------------------------------------------------------------------- # 1. CASE record layout, 24B offsets, order, deterministic bytes # --------------------------------------------------------------------------- @@ -559,10 +585,14 @@ def test_duplicate_value_cases_are_allowed_by_the_reader(): return 42 """ +# The checker requires a wildcard/binding arm for literal matches. These two +# sources keep one so they compile; the no-DEFAULT backend shape is produced by +# _drop_match_default on the resulting .ccr image. MATCH_NO_DEFAULT_SOURCE = """fn pick(n: int) -> int: return match n: 1 => 11, - 2 => 22 + 2 => 22, + _ => 5 fn main() -> int: return pick(99) @@ -571,7 +601,8 @@ def test_duplicate_value_cases_are_allowed_by_the_reader(): MATCH_NO_DEFAULT_HIT_SOURCE = """fn pick(n: int) -> int: return match n: 1 => 11, - 2 => 22 + 2 => 22, + _ => 0 fn main() -> int: return pick(1) + pick(2) @@ -606,6 +637,159 @@ def test_duplicate_value_cases_are_allowed_by_the_reader(): return pickid(7) """ +# Task 5a: string, char, and ident patterns use the sequential path. None of +# them may become a CASE row (strings are not integers; char/binding rows would +# change evaluation order). +MATCH_STRING_SOURCE = """fn pick(s: string) -> int: + return match s: + "a" => 1, + "b" => 2, + _ => 3 + +fn main() -> int: + if pick("a") != 1: + return 10 + if pick("b") != 2: + return 11 + if pick("zzz") != 3: + return 12 + return 42 +""" + +MATCH_CHAR_SOURCE = """fn code_a() -> int: + return 'A' as int + +fn pick(c: char) -> int: + return match c: + 'A' => 1, + 'B' => 2, + _ => 3 + +fn main() -> int: + a := code_a() + if a == 0: + return 9 + c := a as char + if pick(c) != 1: + return 10 + if pick('B') != 2: + return 11 + if pick('C') != 3: + return 12 + return 42 +""" + +MATCH_IDENT_BIND_SOURCE = """fn inc(n: int) -> int: + return match n: + x => x + 1 + +fn pick(s: string) -> string: + return match s: + x => x + +fn main() -> int: + if inc(7) != 8: + return 1 + if pick("hi") != "hi": + return 2 + v := match 5: + 1 => 10, + rest => rest + if v != 5: + return 3 + return 42 +""" + +MATCH_STRING_NO_DEFAULT_SOURCE = """fn pick(s: string) -> int: + return match s: + "a" => 1, + "b" => 2 + +fn main() -> int: + return pick("a") +""" + +MATCH_CHAR_NO_DEFAULT_SOURCE = """fn pick(c: char) -> int: + return match c: + 'A' => 1, + 'B' => 2 + +fn main() -> int: + return pick('A') +""" + +MATCH_INT_NO_DEFAULT_SOURCE = """fn pick(n: int) -> int: + return match n: + 1 => 1, + 2 => 2 + +fn main() -> int: + return pick(1) +""" + +MATCH_BOOL_NO_DEFAULT_SOURCE = """fn pick(b: bool) -> int: + return match b: + true => 1, + false => 2 + +fn main() -> int: + return pick(true) +""" + +MATCH_WILDCARD_ORDER_SOURCE = """fn pick(n: int) -> int: + return match n: + _ => 1, + 2 => 2 + +fn main() -> int: + return pick(2) +""" + +MATCH_IDENT_ORDER_SOURCE = """fn pick(n: int) -> int: + return match n: + x => 1, + 2 => 2 + +fn main() -> int: + return pick(2) +""" + +MATCH_DUPLICATE_STRING_CHAR_SOURCE = """fn picks(s: string) -> int: + return match s: + "a" => 1, + "a" => 2, + _ => 3 + +fn pickc(c: char) -> int: + return match c: + 'A' => 1, + 'A' => 2, + _ => 3 + +fn main() -> int: + return picks("a") + pickc('A') +""" + +MATCH_INTERP_PATTERN_SOURCE = """fn pick(x: int) -> int: + s := "a" + return match s: + "a${x}" => 1, + _ => 2 + +fn main() -> int: + return pick(1) +""" + +MATCH_NESTED_LITERAL_SOURCE = """enum E: + A(int), B + +fn main() -> int: + e := A(1) + return match e: + A(1) => 1, + _ => 2 +""" + MATCH_ONE_CASE_SOURCE = """fn pick(n: int) -> int: return match n: 7 => 1, @@ -633,15 +817,22 @@ def _match_values(ways, base, step): return [base + i * step for i in range(ways)] -def _match_source(values, with_default=True): +def _match_source(values, with_default=True, checker_default=False): """Build an exhaustive checker: every probe must return its arm or the default. `main` returns 42 only when all probes pass, so one build per -O - covers every hit plus out-of-range misses on both sides.""" + covers every hit plus out-of-range misses on both sides. + + `checker_default` emits a wildcard arm only to satisfy the checker's literal + exhaustiveness rule; callers strip the DEFAULT CASE row afterwards when they + need a no-DEFAULT backend image. + """ lines = ["fn pick(n: int) -> int:", " return match n:"] for i, value in enumerate(values): lines.append(f" {value} => {i + 1},") if with_default: lines.append(" _ => 99") + elif checker_default: + lines.append(" _ => 0") lines += ["", "fn main() -> int:"] # Probe every arm so every tree node is exercised, plus both out-of-range # misses when a DEFAULT exists. @@ -772,6 +963,43 @@ def _single_match(data): return _nod_records(data)[matches[0]] +def _drop_match_default(data, name="nodefault"): + """Remove the DEFAULT row from the single MATCH range. + + The checker now rejects a literal match without a wildcard/binding arm, but + the backend still needs a no-DEFAULT MATCH image to exercise the trap edge. + Build the same program with a wildcard and strip the DEFAULT row at the .ccr + layer. Only single-MATCH fixtures are handled; later ranges would need their + case_start adjusted. + """ + nodes = _nod_records(data) + matches = [i for i, rec in enumerate(nodes) if rec[0] == IR_MATCH] + assert len(matches) == 1, f"{name}: expected one MATCH, got {matches}" + match_idx = matches[0] + rec = nodes[match_idx] + start, count = rec[3], rec[4] + rows = _parse_case(segment(data, CASE_TAG)) + default_idx = None + for j in range(start, start + count): + if rows[j]["kind"] == KIND_DEFAULT: + default_idx = j + break + assert default_idx is not None, f"{name}: MATCH has no DEFAULT row" + records = [ + (row["value"], row["kind"], row["str_index"], row["target_nod"], row["flags"]) + for row in rows + ] + del records[default_idx] + out = bytearray(_replace_case(data, _pack_case(records))) + nod_off = next( + offset for tag, offset, _size in read_segment_table(data) if tag == NOD_TAG + ) + struct.pack_into("= 2, text + + +def test_interpolated_string_pattern_rejected(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + _assert_compile_rejected( + tmp, + MATCH_INTERP_PATTERN_SOURCE, + "interp_pattern", + ["error[TM09]", "String interpolation is not supported"], + ) + + +def test_nested_literal_pattern_rejected(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + _assert_compile_rejected( + tmp, + MATCH_NESTED_LITERAL_SOURCE, + "nested_literal", + ["error[TM09]", "Nested match patterns are not supported"], + ) + + def test_dump_objects_prints_match_cases(): with tempfile.TemporaryDirectory() as td: tmp = Path(td) @@ -1369,10 +1687,11 @@ def test_dense_enum_tagged_and_raw_int_table(): def test_no_default_dense_match_stays_binary_and_traps(): values = list(range(16)) - hit_source = _match_source(values, with_default=False) + hit_source = _match_source(values, with_default=False, checker_default=True) with tempfile.TemporaryDirectory() as td: tmp = Path(td) - data = _build_ccr(tmp, hit_source, "match_dense_nodefault") + checked = _build_ccr(tmp, hit_source, "match_dense_nodefault_checked") + data = _drop_match_default(checked, "match_dense_nodefault") rec = _single_match(data) assert rec[4] == 16, rec[4] plans, binary_emits, table_emits = _match_plan_full( @@ -1384,16 +1703,20 @@ def test_no_default_dense_match_stays_binary_and_traps(): assert plans[0]["strategy"] == "binary", plans assert binary_emits == 1, binary_emits assert table_emits == 0, table_emits - _assert_program( - tmp, hit_source, 42, opts=(0, 2, 3), name="match_dense_nodefault" - ) + for opt in (0, 2, 3): + assert _run_corearch_elf( + tmp, data, f"match_dense_nodefault_O{opt}", opt + ) == 42 miss_lines = ["fn pick(n: int) -> int:", " return match n:"] for i, value in enumerate(values): miss_lines.append(f" {value} => {i + 1},") - miss_lines += ["", "fn main() -> int:", " return pick(99)"] + miss_lines += [" _ => 0", "", "fn main() -> int:", " return pick(99)"] miss_source = "\n".join(miss_lines) + "\n" - miss_data = _build_ccr(tmp, miss_source, "match_dense_nodefault_miss") + miss_checked = _build_ccr( + tmp, miss_source, "match_dense_nodefault_miss_checked" + ) + miss_data = _drop_match_default(miss_checked, "match_dense_nodefault_miss") miss_plans, miss_binary, miss_tables = _match_plan_full( _ccr_file(tmp, miss_data, "match_dense_nodefault_miss_plan") ) @@ -1636,10 +1959,11 @@ def test_enum_match_uses_binary_plan(): def test_sparse_no_default_binary_match_traps_on_miss(): values = _match_values(16, SPARSE_BASE, SPARSE_STEP) - hit_source = _match_source(values, with_default=False) + hit_source = _match_source(values, with_default=False, checker_default=True) with tempfile.TemporaryDirectory() as td: tmp = Path(td) - data = _build_ccr(tmp, hit_source, "match_sparse_nodefault") + checked = _build_ccr(tmp, hit_source, "match_sparse_nodefault_checked") + data = _drop_match_default(checked, "match_sparse_nodefault") rec = _single_match(data) assert rec[4] == 16, rec[4] plans, binary_emits = _match_plan( @@ -1648,39 +1972,42 @@ def test_sparse_no_default_binary_match_traps_on_miss(): assert plans[0]["nondefault"] == 16, plans assert plans[0]["strategy"] == "binary", plans assert binary_emits == 1, f"binary emitter ran {binary_emits} times" - _assert_program( - tmp, - hit_source, - 42, - opts=(0, 2, 3), - name="match_sparse_nodefault_hit", - ) + for opt in (0, 2, 3): + assert _run_corearch_elf( + tmp, data, f"match_sparse_nodefault_hit_O{opt}", opt + ) == 42 # The generated checker only probes arm hits; build a single-miss main - # so the binary tree's miss edge must reach ud2. + # (with a checker-satisfying wildcard) so the stripped binary tree's miss + # edge must reach ud2. miss_lines = ["fn pick(n: int) -> int:", " return match n:"] for i, value in enumerate(values): miss_lines.append(f" {value} => {i + 1},") miss_lines += [ + " _ => 0", "", "fn main() -> int:", f" return pick({values[-1] + 7})", ] miss_source = "\n".join(miss_lines) + "\n" - src = tmp / "match_sparse_nodefault_miss.cr" - src.write_text(miss_source, encoding="utf-8") - miss_data = _build_ccr(tmp, miss_source, "match_sparse_nodefault_miss") + miss_checked = _build_ccr( + tmp, miss_source, "match_sparse_nodefault_miss_checked" + ) + miss_data = _drop_match_default( + miss_checked, "match_sparse_nodefault_miss" + ) plans, binary_emits = _match_plan( _ccr_file(tmp, miss_data, "match_sparse_nodefault_miss_plan") ) assert plans[0]["strategy"] == "binary", plans assert binary_emits == 1, f"binary emitter ran {binary_emits} times" for opt in (0, 2, 3): - elf = tmp / f"match_sparse_nodefault_miss_O{opt}.elf" - built = _run( - [COREC, "build", src, "-o", elf, "--static", "-O", str(opt)] + elf, _elf_data = _build_corearch_elf( + tmp, + miss_data, + f"match_sparse_nodefault_miss_O{opt}", + opt=opt, ) - assert built.returncode == 0, built.stdout + built.stderr run = subprocess.run( [str(elf)], capture_output=True, text=True, timeout=30, cwd=str(BASE) ) From 09cead05772360a2236c4027829d06afff6da0ab Mon Sep 17 00:00:00 2001 From: DslsDZC Date: Sat, 24 Oct 2026 08:30:07 +0800 Subject: [PATCH 11/22] ccr: reject nested enum payload patterns --- src/compiler/checker.cr | 25 ++---- src/compiler/ir_gen.cr | 19 ++-- src/compiler/ty_shadow.cr | 51 +++++++++++ tests/selfhost/test_multibranch_v10.py | 115 +++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 32 deletions(-) diff --git a/src/compiler/checker.cr b/src/compiler/checker.cr index eaa75a81..b55be229 100644 --- a/src/compiler/checker.cr +++ b/src/compiler/checker.cr @@ -3417,24 +3417,13 @@ fn infer_expr(node: int) -> int: else if pk == 2: // 嵌套子模式只在 Task 5b 完整支持;ident/`_` 之外的模式 // (字面量、嵌套枚举、struct…)现在不得静默当成“变体匹配”。 - sub_count := ast_c(arm_pat) - spi : ., mut = 0 - spn := ast_b(arm_pat) - bad_nested : ., mut = 0 - loop: - if spi >= sub_count: - break - if spn >= 0: - sk := ast_kind(spn) - if sk != EXPR_IDENT && sk != EXPR_WILDCARD: - check_error(EC_TM_NESTED, - "Nested match patterns are not supported yet", - ast_line(spn), ast_col(spn)) - bad_nested = 1 - break - spn = spn + 1 - spi = spi + 1 - if bad_nested != 0: + // 子模式节点不一定连续(parser 先分配复合子模式的后代), + // 因此扫描整个 payload 子树区间,而不是 ast_b + i。 + bad_nested := sh_match_payload_bad(arm_pat) + if bad_nested >= 0: + check_error(EC_TM_NESTED, + "Nested match patterns are not supported yet", + ast_line(bad_nested), ast_col(bad_nested)) m_unmappable = 1 vi := sh_match_pat_variant(match_ti, arm_pat) vt : ., mut = -1 diff --git a/src/compiler/ir_gen.cr b/src/compiler/ir_gen.cr index 8db02f53..d876ce63 100644 --- a/src/compiler/ir_gen.cr +++ b/src/compiler/ir_gen.cr @@ -2903,22 +2903,13 @@ fn gen_expr(node: int) -> int: is_ident_bind = 1 // Nested refutable sub-patterns (string/char/int/... inside an enum // payload) are Task 5b. Do not silently treat the outer variant as an - // unconditional match: route the arm to the fall edge instead. + // unconditional match: route the arm to the fall edge instead. The + // scan must cover the full payload subtree: composite sub-patterns + // are not contiguous from ast_b (e.g. A(x, X(y))). nested_bad : ., mut = 0 if pat_kind == EXPR_ENUMPAT: - en_sub_count := ast_c(arm_pat) - en_i : ., mut = 0 - en_p := ast_b(arm_pat) - loop: - if en_i >= en_sub_count: - break - if en_p >= 0: - en_k := ast_kind(en_p) - if en_k != EXPR_IDENT && en_k != EXPR_WILDCARD: - nested_bad = 1 - break - en_p = en_p + 1 - en_i = en_i + 1 + if sh_match_payload_bad(arm_pat) >= 0: + nested_bad = 1 if match_mode != 0: // MATCH owns the dispatch: suppress the sequential compare chain // and the payload-binding reads (sub-patterns already forced the diff --git a/src/compiler/ty_shadow.cr b/src/compiler/ty_shadow.cr index ee317314..9a8f1406 100644 --- a/src/compiler/ty_shadow.cr +++ b/src/compiler/ty_shadow.cr @@ -1240,6 +1240,57 @@ fn sh_match_lit_seen(first_arm: int, cur_arm: int, kind: int, value: int) -> int an = ast_c(an) return 0 +// 模式子树的最小节点下标。parser 先分配子模式节点、最后分配外层模式节点; +// 复合子模式(X(y))的根下标高于自己的后代,因此 ast_b 不是子树起点,必须递归到 +// 第一个子模式(parser.cr parse_pattern 的分配序:子先父后)。 +fn sh_match_pat_subtree_start(p: int) -> int: + if p < 0: + return -1 + k := ast_kind(p) + if k == EXPR_ENUMPAT: + if ast_c(p) > 0: + c := ast_b(p) + if c >= 0 && c < p: + s := sh_match_pat_subtree_start(c) + if s >= 0 && s < p: + return s + else if k == EXPR_STRUCTPAT: + if ast_c(p) > 0: + w := ast_b(p) + if w >= 0 && w < p: + c := ast_a(w) + if c >= 0 && c < p: + s := sh_match_pat_subtree_start(c) + if s >= 0 && s < p: + return s + return p + +// Enum payload 内的首个 refutable 节点(-1 = 全部为 ident/wildcard)。 +// 扫描区间 = [首个子模式子树起点, pat_node)。parser 在分配外层节点前连续分配 +// 完整 payload 子树,因此该区间覆盖所有子模式,包括“复合子模式不在首位”的 +// A(x, X(y)) —— 只按 ast_b + i 会漏掉后面的复合子模式。 +fn sh_match_payload_bad(pat_node: int) -> int: + if pat_node < 0: + return -1 + count := ast_c(pat_node) + if count <= 0: + return -1 + first := ast_b(pat_node) + if first < 0 || first >= pat_node: + return pat_node + start := sh_match_pat_subtree_start(first) + if start < 0 || start >= pat_node: + return pat_node + i := start + loop: + if i >= pat_node: + return -1 + k := ast_kind(i) + if k != EXPR_IDENT && k != EXPR_WILDCARD: + return i + i = i + 1 + return -1 + // 变体模式 -> 本枚举变体下标(-1 = 不属本枚举 / 名字不可解析 / scrutinee 不可展开)。 // 名字有两种形态(parser.cr parse_pattern 的产物):裸变体名 Red,或限定名 // Color.Red(parser 用 name + "." + variant 拼接)。限定名取最后一个点之后的段 diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index ca914905..38b90418 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -790,6 +790,81 @@ def test_duplicate_value_cases_are_allowed_by_the_reader(): _ => 2 """ +# Composite sub-patterns are allocated before the outer EXPR_ENUMPAT node, so +# they are not contiguous from ast_b. These fixtures pin both orders and a +# deeper nesting; all must be rejected rather than matched as a bare variant. +MATCH_NESTED_SECOND_BYPASS_SOURCE = """enum Inner: + X(int), Y(int) + +enum Outer: + A(Inner, Inner), B + +fn main() -> int: + o := A(X(1), X(2)) + return match o: + A(x, X(y)) => 1, + _ => 2 +""" + +MATCH_NESTED_FIRST_BYPASS_SOURCE = """enum Inner: + X(int), Y(int) + +enum Outer: + A(Inner, Inner), B + +fn main() -> int: + o := A(X(1), X(2)) + return match o: + A(X(y), x) => 1, + _ => 2 +""" + +MATCH_NESTED_DEEP_BYPASS_SOURCE = """enum L3: + Z(int) + +enum L2: + Y(L3) + +enum L1: + X(L2) + +enum Outer: + A(L1), B + +fn f(o: Outer) -> int: + return match o: + A(X(Y(z))) => 1, + _ => 2 + +fn main() -> int: + return f(B()) +""" + +MATCH_MULTI_PAYLOAD_BIND_SOURCE = """enum Pair: + A(int, int), B + +fn sum(p: Pair) -> int: + return match p: + A(x, y) => x + y, + B => 0 + +fn second(p: Pair) -> int: + return match p: + A(_, x) => x, + B => 0 + +fn main() -> int: + if sum(A(3, 4)) != 7: + return 1 + if sum(B()) != 0: + return 2 + if second(A(9, 8)) != 8: + return 3 + if second(B()) != 0: + return 4 + return 42 +""" + MATCH_ONE_CASE_SOURCE = """fn pick(n: int) -> int: return match n: 7 => 1, @@ -1346,6 +1421,46 @@ def test_nested_literal_pattern_rejected(): ) +def test_nested_composite_payload_bypass_is_rejected(): + cases = [ + ("nested_second", MATCH_NESTED_SECOND_BYPASS_SOURCE), + ("nested_first", MATCH_NESTED_FIRST_BYPASS_SOURCE), + ("nested_deep", MATCH_NESTED_DEEP_BYPASS_SOURCE), + ] + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + needles = ["error[TM09]", "Nested match patterns are not supported"] + for name, source in cases: + _assert_compile_rejected(tmp, source, name, needles) + # `run` shares the same checker; the interpreter must reject the + # pattern too, not silently return one of the divergent values. + run = _run([COREC, "run", source], cwd=BASE) + text = run.stdout + run.stderr + assert run.returncode == 1, ( + f"{name}: interpreter accepted a nested payload pattern: " + f"rc={run.returncode}: {text}" + ) + for needle in needles: + assert needle in text, f"{name}: interpreter missing {needle!r}: {text}" + + +def test_multi_payload_ident_bindings_work(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr( + tmp, MATCH_MULTI_PAYLOAD_BIND_SOURCE, "multi_payload_bind" + ) + assert _match_nodes(data) == [], "payload binding keeps the sequential path" + assert segment(data, CASE_TAG) == struct.pack(" Date: Sat, 24 Oct 2026 08:47:22 +0800 Subject: [PATCH 12/22] ccr: implement struct match patterns --- src/compiler/ast.cr | 2 +- src/compiler/checker.cr | 131 +++++++++++++ src/compiler/ir_gen.cr | 142 ++++++++++++++ src/compiler/monomorph.cr | 10 +- src/compiler/parser.cr | 10 +- tests/selfhost/test_multibranch_v10.py | 248 +++++++++++++++++++++++++ 6 files changed, 538 insertions(+), 5 deletions(-) diff --git a/src/compiler/ast.cr b/src/compiler/ast.cr index d94b4970..63251a5e 100644 --- a/src/compiler/ast.cr +++ b/src/compiler/ast.cr @@ -243,7 +243,7 @@ EXPR_AT : int = 46 // @builtin: a=name_ni, b=args_node, c=0, iv=0, t // Desugared constructs EXPR_TRY : int = 33 // a=expr being tried (? operator) EXPR_UNSAFE : int = 34 // a=block body -EXPR_STRUCTPAT : int = 35 // struct pattern: a=name ni, b=first wrapper(连续;wrapper kind=EXPR_NONE 且 a=子模式节点), c=field count +EXPR_STRUCTPAT : int = 35 // struct pattern: a=name ni, b=first wrapper(连续;wrapper kind=EXPR_NONE,a=子模式节点,b=字段名 ni), c=field count EXPR_AS : int = 36 // a=expr, b=type node (cast: expr as Type) EXPR_PTRTYPE : int = 47 // a=inner_type (for *T in type position) EXPR_EXTERN : int = 48 // a=name_ni, b=first_param, c=param_count, data=ffi_lang_ni diff --git a/src/compiler/checker.cr b/src/compiler/checker.cr index b55be229..0ae1c847 100644 --- a/src/compiler/checker.cr +++ b/src/compiler/checker.cr @@ -1640,6 +1640,124 @@ fn struct_field_index_by_name(si: int, name_ni: int) -> int: j = j + 1 return -1 +// ─── Struct match pattern(Task 5b1)─── +// EXPR_STRUCTPAT layout: a = struct name ni, b = first wrapper, c = field count. +// wrapper.a = subpattern, wrapper.b = field name ni. Validate the pattern against +// the struct row: unknown type/field, duplicate field, and literal field type +// mismatches are hard diagnostics. Nested struct/enum/tuple subpatterns are +// rejected with TM09 rather than silently treated as irrefutable. Returns +// 0 when the pattern is usable, 1 when a diagnostic was reported. +fn check_struct_pattern(pat: int, scrut_ti: int) -> int: + if pat < 0: + return 1 + name_ni := ast_a(pat) + si := find_struct_by_name(name_ni) + if si < 0: + check_error(EC_N_UNDEFINED, + "Unknown struct type '" + istr_get(name_ni) + "' in struct pattern", + ast_line(pat), ast_col(pat)) + return 1 + // The scrutinee must be this struct whenever its shape is statically known. + sk := get_type_kind(scrut_ti) + if sk == TYP_NAMED || sk == TYP_GENERIC_APPLY: + sni := decl_name_of_ti(scrut_ti) + if sni >= 0 && sni != name_ni: + check_error(EC_TS_FIELD_TYPE, + "Struct pattern '" + istr_get(name_ni) + "' does not match scrutinee type '" + istr_get(sni) + "'", + ast_line(pat), ast_col(pat)) + return 1 + else if sk != TYP_GENERIC_PARAM: + check_error(EC_TS_FIELD_TYPE, + "Struct pattern '" + istr_get(name_ni) + "' requires a struct scrutinee", + ast_line(pat), ast_col(pat)) + return 1 + c := ast_c(pat) + w0 := ast_b(pat) + if c > 0 && w0 < 0: + return 1 + fc := si_field_count(si) + used : string, mut = alloc((fc + 1) * 8) + j : ., mut = 0 + loop: + if j >= fc: + break + cell_store(used, j * 8, 0) + j = j + 1 + bad : ., mut = 0 + i : ., mut = 0 + loop: + if i >= c: + return bad + w := w0 + i + sub : ., mut = -1 + fni : ., mut = -1 + if w >= 0: + sub = ast_a(w) + fni = ast_b(w) + jdi := struct_field_index_by_name(si, fni) + if jdi < 0: + check_error(EC_TS_UNKNOWN_FIELD, + "Unknown field '" + istr_get(fni) + "' in struct pattern " + istr_get(name_ni), + ast_line(w), ast_col(w)) + bad = 1 + i = i + 1 + continue + if cell_load(used, jdi * 8) != 0: + check_error(EC_TS_FIELD_DUP, + "Field '" + istr_get(fni) + "' matched more than once in struct pattern " + istr_get(name_ni), + ast_line(w), ast_col(w)) + bad = 1 + cell_store(used, jdi * 8, 1) + if sub >= 0: + k := ast_kind(sub) + if k != EXPR_IDENT && k != EXPR_WILDCARD: + if k == EXPR_INT || k == EXPR_BOOL || k == EXPR_CHAR || k == EXPR_STRING: + ft := res_type_node(si_field_type_node(si, jdi)) + st := infer_expr(sub) + if ft >= 0 && ti_has_generic_param(ft) == 0: + verdict := type_compat_strict(st, ft) + if verdict != 1: + diag_type_incompatible(verdict, EC_TS_FIELD_TYPE, + "Field '" + istr_get(fni) + "' in struct pattern " + istr_get(name_ni) + + ": expected " + type_display(ft) + ", got " + type_display(st), + ast_line(sub), ast_col(sub)) + bad = 1 + else: + check_error(EC_TM_NESTED, + "Nested match patterns are not supported yet", + ast_line(sub), ast_col(sub)) + bad = 1 + i = i + 1 + return bad + +// Bind the ident subpatterns of a validated struct pattern. Each binding gets +// the declared type of its field. `_` and literal subpatterns bind nothing. +fn bind_struct_pattern_idents(pat: int): + if pat < 0: + return + name_ni := ast_a(pat) + si := find_struct_by_name(name_ni) + c := ast_c(pat) + w0 := ast_b(pat) + if si < 0 || (c > 0 && w0 < 0): + return + i : ., mut = 0 + loop: + if i >= c: + break + w := w0 + i + if w >= 0: + sub := ast_a(w) + if sub >= 0 && ast_kind(sub) == EXPR_IDENT: + nn := ast_int_val(sub) + if str_eq(istr_get(nn), "_") == 0: + jdi := struct_field_index_by_name(si, ast_b(w)) + ft : ., mut = TI_INT + if jdi >= 0: + ft = res_type_node(si_field_type_node(si, jdi)) + def_sym(nn, SYM_LOCAL, ft, -1) + i = i + 1 + // 类型里是否仍含未实例化的泛型参数(TYP_GENERIC_PARAM,可嵌套在数组 / 指针 / 引用 / // 切片 / 元组 / 泛型应用内)。字面量的字段类型 vs 声明元素同质性判定在任一侧 // 含未实例化参数时跳过,参数未实例化时比较不成立,强行比较会假拒(泛型函数体 @@ -3451,6 +3569,13 @@ fn infer_expr(node: int) -> int: m_dup = arm_pat mc_add(ci) m_pats = tt_cons(vt, m_pats) + else if ast_kind(arm_pat) == EXPR_STRUCTPAT: + // Struct patterns are refutable: like literals, they need an + // irrefutable arm. Field syntax/type errors are diagnosed by + // check_struct_pattern; TM09 covers nested subpatterns. + m_literal = 1 + if check_struct_pattern(arm_pat, match_ti) != 0: + m_unmappable = 1 else: lit_k := sh_match_lit_kind(arm_pat) if lit_k >= 0: @@ -3484,6 +3609,10 @@ fn infer_expr(node: int) -> int: def_sym(ast_int_val(spn), SYM_LOCAL, TI_INT, -1) spn = spn + 1 spi = spi + 1 + if ast_kind(arm_pat) == EXPR_STRUCTPAT: + // Struct pattern field bindings: each ident subpattern gets the + // declared field type (Task 5b1). + bind_struct_pattern_idents(arm_pat) if ast_kind(arm_pat) == EXPR_IDENT: // 绑定臂的值就是 scrutinee:类型取 scrutinee 类型(旧实现 // 一律 TI_INT,字符串/char/bool scrutinee 会错型)。 @@ -3994,6 +4123,8 @@ fn infer_expr(node: int) -> int: return get_type_data(inner_ti) return inner_ti if ast_kind(node) == EXPR_STRUCTPAT: + // Patterns are not value expressions. Match-arm validation/binding lives + // in the EXPR_MATCH branch (check_struct_pattern / bind_struct_pattern_idents). return TI_UNIT if ast_kind(node) == EXPR_AS: // expr as Type — type cast diff --git a/src/compiler/ir_gen.cr b/src/compiler/ir_gen.cr index d876ce63..269ae025 100644 --- a/src/compiler/ir_gen.cr +++ b/src/compiler/ir_gen.cr @@ -887,6 +887,102 @@ fn find_global(name_idx: int) -> int: i = i - 1 return -1 +// Field load form for struct-pattern tests/bindings. Aggregate reads lose the +// declared type; string patterns must keep TI_STR so str_eq compares contents, +// bool/char keep their declared form so literal comparisons use the same +// representation as the existing bool/char arms, and other fields use the exact +// integer slot form (TI_INT). +fn structpat_field_load_ti(si: int, jdi: int) -> int: + ft := struct_row_field_ti(si, jdi) + if ft == TI_STR: + return TI_STR + if ft == TI_DEX: + return TI_DEX_S + if ft == TI_BOOL: + return TI_BOOL + if ft == TI_CHAR: + return TI_CHAR + return TI_INT + +// ─── Struct match pattern lowering(Task 5b1)─── +// Emit the comparison for one field subpattern. `fall` is taken when the +// comparison fails. Returns 1 when a test was emitted, 0 for ident/wildcard +// (no test), and -1 for an unsupported nested subpattern (caller must fail +// closed instead of jumping to the body). +fn gen_structpat_field_test(sub: int, fv: int, fall: int) -> int: + if sub < 0: + return 0 + k := ast_kind(sub) + if k == EXPR_IDENT || k == EXPR_WILDCARD: + return 0 + ok_lbl := new_label() + cmp_var : ., mut = -1 + if k == EXPR_INT: + pat_val := new_ir_var("pval", TI_INT) + emit(IR_CONST, pat_val, ast_int_val(sub), 0, 0, TI_INT) + cmp_var = new_ir_var("cmp", TI_INT) + emit(IR_BINARY, cmp_var, fv, pat_val, OP_EQ, 0) + else if k == EXPR_BOOL: + pat_val := new_ir_var("pval", TI_BOOL) + pat_bool : ., mut = 0 + if ast_int_val(sub) != 0: + pat_bool = 1 + emit(IR_CONST, pat_val, pat_bool, 0, 0, TI_BOOL) + cmp_var = new_ir_var("cmp", TI_INT) + emit(IR_BINARY, cmp_var, fv, pat_val, OP_EQ, 0) + else if k == EXPR_CHAR: + // char runtime value = interned string index; the pattern stores the same + // index in ast_int_val (same contract as the existing char arm). + pat_val := new_ir_var("pval", TI_CHAR) + track_str(ast_int_val(sub)) + emit(IR_CONST, pat_val, ast_int_val(sub), 0, 0, TI_CHAR) + cmp_var = new_ir_var("cmp", TI_INT) + emit(IR_BINARY, cmp_var, fv, pat_val, OP_EQ, 0) + else if k == EXPR_STRING: + pat_val := new_ir_var("spat", TI_STR) + track_str(ast_int_val(sub)) + emit(IR_CONST, pat_val, ast_int_val(sub), 0, 0, TI_STR) + eq0 := new_ir_var("_seq0", TI_STR) + eq1 := new_ir_var("_seq1", TI_STR) + emit(IR_STORE, -1, eq0, fv, 0, 0) + emit(IR_STORE, -1, eq1, pat_val, 0, 0) + cmp_var = new_ir_var("cmp", TI_BOOL) + emit(IR_CALL, cmp_var, eq0, 2, str_intern("str_eq"), TI_BOOL) + else: + return -1 + emit(IR_BRANCH, -1, cmp_var, ok_lbl, fall, 0) + emit(IR_LABEL, -1, ok_lbl, 0, 0, 0) + return 1 + +// Bind ident subpatterns for a struct pattern in the arm scope. Each binding +// reads its field again and gets the declared field type. Literal and `_` +// subpatterns bind nothing. +fn gen_structpat_binds(pat: int, match_val: int): + if pat < 0 || match_val < 0: + return + si := find_struct_by_name(ast_a(pat)) + c := ast_c(pat) + w0 := ast_b(pat) + if si < 0 || w0 < 0: + return + i : ., mut = 0 + loop: + if i >= c: + break + w := w0 + i + if w >= 0: + sub := ast_a(w) + if sub >= 0 && ast_kind(sub) == EXPR_IDENT: + nn := ast_int_val(sub) + if str_eq(istr_get(nn), "_") == 0: + jdi := struct_field_index_by_name(si, ast_b(w)) + if jdi >= 0: + fv := new_ir_var("fld", TI_INT) + irv_set_type(fv, structpat_field_load_ti(si, jdi)) + emit(IR_LOAD_FIELD, fv, match_val, 0, jdi, 0) + bind_local(nn, fv) + i = i + 1 + // Return the literal initializer AST node for an immutable module-level // constant. The ELF backend has no data initializers, so leaving these as // BSS globals turns values such as T_FN and ESZ_TOKEN into zero at runtime. @@ -2979,6 +3075,50 @@ fn gen_expr(node: int) -> int: cmp_var := new_ir_var("cmp", TI_INT) emit(IR_BINARY, cmp_var, tag_var, vtag, OP_EQ, 0) emit(IR_BRANCH, -1, cmp_var, body_lbl, fall_lbl, 0) + else if pat_kind == EXPR_STRUCTPAT: + // Struct pattern: sequential field tests. The lowerer never + // classifies struct patterns as MATCH, so this is the only path. + // A missing struct/field or an unsupported nested subpattern + // fails closed: jump to the fall edge, never to the body. + si := find_struct_by_name(ast_a(arm_pat)) + sc := ast_c(arm_pat) + w0 := ast_b(arm_pat) + can_match : ., mut = 1 + if si < 0 || w0 < 0: + can_match = 0 + if has_next == 1: + fall_lbl = new_label() + si_i : ., mut = 0 + loop: + if si_i >= sc: + break + w := w0 + si_i + sub : ., mut = -1 + fni : ., mut = -1 + if w >= 0: + sub = ast_a(w) + fni = ast_b(w) + jdi : ., mut = -1 + if si >= 0: + jdi = struct_field_index_by_name(si, fni) + if jdi < 0: + can_match = 0 + break + if sub >= 0: + sk := ast_kind(sub) + if sk != EXPR_IDENT && sk != EXPR_WILDCARD: + fv := new_ir_var("fld", TI_INT) + ft := structpat_field_load_ti(si, jdi) + irv_set_type(fv, ft) + emit(IR_LOAD_FIELD, fv, match_val, 0, jdi, 0) + if gen_structpat_field_test(sub, fv, fall_lbl) < 0: + can_match = 0 + break + si_i = si_i + 1 + if can_match != 0: + emit(IR_JUMP, -1, body_lbl, 0, 0, 0) + else: + emit(IR_JUMP, -1, fall_lbl, 0, 0, 0) else if pat_kind == EXPR_INT: pat_val := new_ir_var("pval", TI_INT) emit(IR_CONST, pat_val, ast_int_val(arm_pat), 0, 0, TI_INT) @@ -3040,6 +3180,8 @@ fn gen_expr(node: int) -> int: if ci_arm >= 0: ccr_case_set_target(case_base + ci_arm, body_nod) push_ir_scope() + if pat_kind == EXPR_STRUCTPAT: + gen_structpat_binds(arm_pat, match_val) if pat_kind == EXPR_ENUMPAT: sub_count := ast_c(arm_pat) fi : ., mut = 0 diff --git a/src/compiler/monomorph.cr b/src/compiler/monomorph.cr index e3d10228..711c2ccf 100644 --- a/src/compiler/monomorph.cr +++ b/src/compiler/monomorph.cr @@ -987,10 +987,12 @@ fn gen_clone_tree(node: int) -> int: return n // EXPR_STRUCTPAT: a = name, b = first wrapper (consecutive), c = field count. - // wrapper.a = subpattern node. + // wrapper.a = subpattern node, wrapper.b = field name index (same contract as + // struct literal wrappers). Preserve the name so a clone still binds by name. if k == EXPR_STRUCTPAT: if b >= 0 && c > 0: nvs : string, mut = alloc(c * 8) + nns : string, mut = alloc(c * 8) i : ., mut = 0 loop: if i >= c: @@ -999,13 +1001,17 @@ fn gen_clone_tree(node: int) -> int: if b + i >= 0: vn = ast_a(b + i) cell_store(nvs, i * 8, gen_clone_tree(vn)) + nn : ., mut = -1 + if b + i >= 0: + nn = ast_b(b + i) + cell_store(nns, i * 8, nn) i = i + 1 new_first := g_ast_count i = 0 loop: if i >= c: break - ast_alloc(EXPR_NONE, cell_load(nvs, i * 8), 0, 0, 0, 0, 0, ln, cl) + ast_alloc(EXPR_NONE, cell_load(nvs, i * 8), cell_load(nns, i * 8), 0, 0, 0, 0, ln, cl) i = i + 1 n := ast_alloc(k, a, new_first, c, iv, tv, d, ln, cl) gen_dedup_add(node, n) diff --git a/src/compiler/parser.cr b/src/compiler/parser.cr index 7a93b56e..d7f88b4e 100644 --- a/src/compiler/parser.cr +++ b/src/compiler/parser.cr @@ -1487,12 +1487,14 @@ fn parse_pattern() -> int: if check(T_LBRACE): // Struct pattern: Name { field = pat, ... } // EXPR_STRUCTPAT layout: a = name index, b = first wrapper, c = field - // count. Wrappers are contiguous; wrapper.a points to the subpattern. + // count. Wrappers are contiguous; wrapper.a points to the subpattern and + // wrapper.b is the field name index (same contract as struct literals). // Composite subpatterns occupy several nodes, so parse all subpatterns // first, then build the wrappers. advance_tok() cap : ., mut = 8 vals : string, mut = alloc(cap * 8) + names : string, mut = alloc(cap * 8) fc : ., mut = 0 loop: if check(T_RBRACE) || check(T_EOF): @@ -1511,8 +1513,12 @@ fn parse_pattern() -> int: nv := alloc(ncap * 8) cell_copy(vals, cap * 8, nv) vals = nv + nn := alloc(ncap * 8) + cell_copy(names, cap * 8, nn) + names = nn cap = ncap cell_store(vals, fc * 8, parse_pattern()) + cell_store(names, fc * 8, fni) fc = fc + 1 if check(T_COMMA): advance_tok() @@ -1528,7 +1534,7 @@ fn parse_pattern() -> int: if vn >= 0: ln = ast_line(vn) cl = ast_col(vn) - wl := ast_alloc(EXPR_NONE, vn, 0, 0, 0, 0, 0, ln, cl) + wl := ast_alloc(EXPR_NONE, vn, cell_load(names, fi2 * 8), 0, 0, 0, 0, ln, cl) if fi2 == 0: ff = wl fi2 = fi2 + 1 diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index 38b90418..d0da8a58 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -865,6 +865,152 @@ def test_duplicate_value_cases_are_allowed_by_the_reader(): return 42 """ +# Task 5b1: struct patterns. Field names are bound by name (the pattern below +# reverses the declaration order in the second arm), ident subpatterns bind the +# field value, and a miss must fall to the wildcard instead of the first arm. +MATCH_STRUCT_SOURCE = """struct Point: + x: int + y: int + +fn pick(p: Point) -> int: + return match p: + Point { x = 1, y = yv } => yv, + Point { y = 9, x = xv } => xv, + _ => 0 + +fn main() -> int: + if pick(Point { x = 1, y = 7 }) != 7: + return 1 + if pick(Point { x = 4, y = 9 }) != 4: + return 2 + if pick(Point { x = 2, y = 3 }) != 0: + return 3 + return 42 +""" + +# Scalar field kinds: bool/char/string/int literals all compare through the +# sequential path (strings via str_eq, not a CASE row). +MATCH_STRUCT_SCALARS_SOURCE = """struct Rec: + flag: bool + ch: char + name: string + n: int + +fn pick(r: Rec) -> int: + return match r: + Rec { n = 3, name = "hi", ch = 'A', flag = true } => 1, + Rec { flag = false, ch = 'B', name = "yo", n = 4 } => 2, + _ => 3 + +fn main() -> int: + if pick(Rec { flag = true, ch = 'A', name = "hi", n = 3 }) != 1: + return 1 + if pick(Rec { flag = false, ch = 'B', name = "yo", n = 4 }) != 2: + return 2 + if pick(Rec { flag = true, ch = 'B', name = "hi", n = 3 }) != 3: + return 3 + if pick(Rec { flag = true, ch = 'A', name = "no", n = 3 }) != 3: + return 4 + return 42 +""" + +# An irrefutable binding arm may close a struct match. +MATCH_STRUCT_IDENT_LAST_SOURCE = """struct Point: + x: int + y: int + +fn pick(p: Point) -> int: + return match p: + Point { x = 1, y = 2 } => 1, + rest => 2 + +fn main() -> int: + if pick(Point { x = 1, y = 2 }) != 1: + return 1 + if pick(Point { x = 0, y = 0 }) != 2: + return 2 + return 42 +""" + +MATCH_STRUCT_UNKNOWN_TYPE_SOURCE = """struct Point: + x: int + y: int + +fn main() -> int: + p := Point { x = 1, y = 2 } + return match p: + Nope { x = 1 } => 1, + _ => 0 +""" + +MATCH_STRUCT_UNKNOWN_FIELD_SOURCE = """struct Point: + x: int + y: int + +fn main() -> int: + p := Point { x = 1, y = 2 } + return match p: + Point { z = 1 } => 1, + _ => 0 +""" + +MATCH_STRUCT_WRONG_FIELD_TYPE_SOURCE = """struct Point: + x: int + y: int + +fn main() -> int: + p := Point { x = 1, y = 2 } + return match p: + Point { x = true } => 1, + _ => 0 +""" + +MATCH_STRUCT_DUP_FIELD_SOURCE = """struct Point: + x: int + y: int + +fn main() -> int: + p := Point { x = 1, y = 2 } + return match p: + Point { x = 1, x = 2 } => 1, + _ => 0 +""" + +MATCH_STRUCT_NESTED_SOURCE = """struct Point: + x: int + y: int + +struct Outer: + p: Point + +fn main() -> int: + o := Outer { p = Point { x = 1, y = 2 } } + return match o: + Outer { p = Point { x = 1 } } => 1, + _ => 0 +""" + +MATCH_STRUCT_NO_WILDCARD_SOURCE = """struct Point: + x: int + y: int + +fn main() -> int: + p := Point { x = 1, y = 2 } + return match p: + Point { x = 1 } => 1 +""" + +MATCH_STRUCT_WILDCARD_ORDER_SOURCE = """struct Point: + x: int + y: int + +fn main() -> int: + p := Point { x = 1, y = 2 } + return match p: + _ => 0, + Point { x = 1 } => 1 +""" + MATCH_ONE_CASE_SOURCE = """fn pick(n: int) -> int: return match n: 7 => 1, @@ -1461,6 +1607,108 @@ def test_multi_payload_ident_bindings_work(): ) +def test_struct_pattern_binds_fields_and_is_refutable(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, MATCH_STRUCT_SOURCE, "match_struct") + assert _match_nodes(data) == [], "struct patterns must not become MATCH" + assert segment(data, CASE_TAG) == struct.pack(" Date: Sat, 24 Oct 2026 09:14:07 +0800 Subject: [PATCH 13/22] ccr: reject generic struct patterns before field loads --- src/compiler/checker.cr | 103 ++++++++++--- src/compiler/ir_gen.cr | 13 +- src/compiler/main.cr | 9 ++ src/compiler/monomorph.cr | 197 ++++++++++++++++++++++++- tests/selfhost/test_multibranch_v10.py | 171 +++++++++++++++++++++ 5 files changed, 467 insertions(+), 26 deletions(-) diff --git a/src/compiler/checker.cr b/src/compiler/checker.cr index 0ae1c847..89228831 100644 --- a/src/compiler/checker.cr +++ b/src/compiler/checker.cr @@ -1640,6 +1640,46 @@ fn struct_field_index_by_name(si: int, name_ni: int) -> int: j = j + 1 return -1 +// Resolve a struct field type for a pattern scrutinee. Direct generic parameters +// are substituted from a concrete `TYP_GENERIC_APPLY` scrutinee (same rule as +// EXPR_FIELD); nested generic forms keep the parameter row and are rejected by the +// caller unless the subpattern is `_`. +fn structpat_field_ti(si: int, jdi: int, scrut_ti: int) -> int: + if si < 0 || jdi < 0: + return -1 + ftn := si_field_type_node(si, jdi) + if ftn >= 0 && ast_kind(ftn) == EXPR_IDENT: + ft_name := ast_int_val(ftn) + if is_struct_generic(si, ft_name) && get_type_kind(scrut_ti) == TYP_GENERIC_APPLY: + ga_start := get_type_extra(scrut_ti) + ga_count := cell_load(g_gen_apply_data, ga_start * 8) + gpi : ., mut = 0 + loop: + if gpi >= si_generic_count(si): + break + if si_generic_name(si, gpi) == ft_name: + if gpi < ga_count: + return cell_load(g_gen_apply_data, (ga_start + 1 + gpi) * 8) + break + gpi = gpi + 1 + return res_type_node(ftn) + +// A struct row whose field declarations still mention a generic parameter cannot +// be used by a struct pattern unless the scrutinee supplies concrete arguments. +// Used by monomorph as a defensive check. +fn struct_has_generic_field(si: int) -> int: + if si < 0: + return 1 + j : ., mut = 0 + loop: + if j >= si_field_count(si): + return 0 + ft := res_type_node(si_field_type_node(si, j)) + if ti_has_generic_param(ft) != 0: + return 1 + j = j + 1 + return 0 + // ─── Struct match pattern(Task 5b1)─── // EXPR_STRUCTPAT layout: a = struct name ni, b = first wrapper, c = field count. // wrapper.a = subpattern, wrapper.b = field name ni. Validate the pattern against @@ -1657,6 +1697,19 @@ fn check_struct_pattern(pat: int, scrut_ti: int) -> int: "Unknown struct type '" + istr_get(name_ni) + "' in struct pattern", ast_line(pat), ast_col(pat)) return 1 + // A struct pattern needs a concrete scrutinee shape. An unresolved generic + // parameter could later instantiate to a different struct (or a non-struct), + // and monomorphization would then emit field loads against the wrong object. + // Reject it here; callers must use a concrete struct type or `_`/binding. + if ti_has_generic_param(scrut_ti) != 0: + check_error(EC_TS_FIELD_TYPE, + "Struct pattern '" + istr_get(name_ni) + "' requires a concrete struct type; use a wildcard or binding arm", + ast_line(pat), ast_col(pat)) + return 1 + // Generic field types cannot be validated/bound soundly unless the scrutinee + // supplies concrete generic arguments. The per-field loop below substitutes + // direct generic parameters from the scrutinee and rejects remaining generic + // forms (except `_`, which performs no comparison or binding). // The scrutinee must be this struct whenever its shape is statically known. sk := get_type_kind(scrut_ti) if sk == TYP_NAMED || sk == TYP_GENERIC_APPLY: @@ -1710,29 +1763,41 @@ fn check_struct_pattern(pat: int, scrut_ti: int) -> int: cell_store(used, jdi * 8, 1) if sub >= 0: k := ast_kind(sub) - if k != EXPR_IDENT && k != EXPR_WILDCARD: - if k == EXPR_INT || k == EXPR_BOOL || k == EXPR_CHAR || k == EXPR_STRING: - ft := res_type_node(si_field_type_node(si, jdi)) + if k == EXPR_IDENT || k == EXPR_WILDCARD || k == EXPR_INT || k == EXPR_BOOL || k == EXPR_CHAR || k == EXPR_STRING: + ft := structpat_field_ti(si, jdi, scrut_ti) + if ti_has_generic_param(ft) != 0: + if k == EXPR_IDENT: + check_error(EC_TS_FIELD_TYPE, + "Field '" + istr_get(fni) + "' in struct pattern " + istr_get(name_ni) + + " has an unresolved generic type; use a wildcard", + ast_line(sub), ast_col(sub)) + bad = 1 + else if k != EXPR_WILDCARD: + check_error(EC_TS_FIELD_TYPE, + "Field '" + istr_get(fni) + "' in struct pattern " + istr_get(name_ni) + + " has an unresolved generic type; literal patterns are not supported", + ast_line(sub), ast_col(sub)) + bad = 1 + else if k != EXPR_IDENT && k != EXPR_WILDCARD: st := infer_expr(sub) - if ft >= 0 && ti_has_generic_param(ft) == 0: - verdict := type_compat_strict(st, ft) - if verdict != 1: - diag_type_incompatible(verdict, EC_TS_FIELD_TYPE, - "Field '" + istr_get(fni) + "' in struct pattern " + istr_get(name_ni) + - ": expected " + type_display(ft) + ", got " + type_display(st), - ast_line(sub), ast_col(sub)) - bad = 1 - else: - check_error(EC_TM_NESTED, - "Nested match patterns are not supported yet", - ast_line(sub), ast_col(sub)) - bad = 1 + verdict := type_compat_strict(st, ft) + if verdict != 1: + diag_type_incompatible(verdict, EC_TS_FIELD_TYPE, + "Field '" + istr_get(fni) + "' in struct pattern " + istr_get(name_ni) + + ": expected " + type_display(ft) + ", got " + type_display(st), + ast_line(sub), ast_col(sub)) + bad = 1 + else: + check_error(EC_TM_NESTED, + "Nested match patterns are not supported yet", + ast_line(sub), ast_col(sub)) + bad = 1 i = i + 1 return bad // Bind the ident subpatterns of a validated struct pattern. Each binding gets // the declared type of its field. `_` and literal subpatterns bind nothing. -fn bind_struct_pattern_idents(pat: int): +fn bind_struct_pattern_idents(pat: int, scrut_ti: int): if pat < 0: return name_ni := ast_a(pat) @@ -1754,7 +1819,7 @@ fn bind_struct_pattern_idents(pat: int): jdi := struct_field_index_by_name(si, ast_b(w)) ft : ., mut = TI_INT if jdi >= 0: - ft = res_type_node(si_field_type_node(si, jdi)) + ft = structpat_field_ti(si, jdi, scrut_ti) def_sym(nn, SYM_LOCAL, ft, -1) i = i + 1 @@ -3612,7 +3677,7 @@ fn infer_expr(node: int) -> int: if ast_kind(arm_pat) == EXPR_STRUCTPAT: // Struct pattern field bindings: each ident subpattern gets the // declared field type (Task 5b1). - bind_struct_pattern_idents(arm_pat) + bind_struct_pattern_idents(arm_pat, match_ti) if ast_kind(arm_pat) == EXPR_IDENT: // 绑定臂的值就是 scrutinee:类型取 scrutinee 类型(旧实现 // 一律 TI_INT,字符串/char/bool scrutinee 会错型)。 diff --git a/src/compiler/ir_gen.cr b/src/compiler/ir_gen.cr index 269ae025..80f0abc1 100644 --- a/src/compiler/ir_gen.cr +++ b/src/compiler/ir_gen.cr @@ -892,8 +892,8 @@ fn find_global(name_idx: int) -> int: // bool/char keep their declared form so literal comparisons use the same // representation as the existing bool/char arms, and other fields use the exact // integer slot form (TI_INT). -fn structpat_field_load_ti(si: int, jdi: int) -> int: - ft := struct_row_field_ti(si, jdi) +fn structpat_field_load_ti(si: int, jdi: int, scrut_ti: int) -> int: + ft := structpat_field_ti(si, jdi, scrut_ti) if ft == TI_STR: return TI_STR if ft == TI_DEX: @@ -957,7 +957,7 @@ fn gen_structpat_field_test(sub: int, fv: int, fall: int) -> int: // Bind ident subpatterns for a struct pattern in the arm scope. Each binding // reads its field again and gets the declared field type. Literal and `_` // subpatterns bind nothing. -fn gen_structpat_binds(pat: int, match_val: int): +fn gen_structpat_binds(pat: int, match_val: int, scrut_ti: int): if pat < 0 || match_val < 0: return si := find_struct_by_name(ast_a(pat)) @@ -978,7 +978,7 @@ fn gen_structpat_binds(pat: int, match_val: int): jdi := struct_field_index_by_name(si, ast_b(w)) if jdi >= 0: fv := new_ir_var("fld", TI_INT) - irv_set_type(fv, structpat_field_load_ti(si, jdi)) + irv_set_type(fv, structpat_field_load_ti(si, jdi, scrut_ti)) emit(IR_LOAD_FIELD, fv, match_val, 0, jdi, 0) bind_local(nn, fv) i = i + 1 @@ -3083,6 +3083,7 @@ fn gen_expr(node: int) -> int: si := find_struct_by_name(ast_a(arm_pat)) sc := ast_c(arm_pat) w0 := ast_b(arm_pat) + scrut_ti := irv_type(match_val) can_match : ., mut = 1 if si < 0 || w0 < 0: can_match = 0 @@ -3108,7 +3109,7 @@ fn gen_expr(node: int) -> int: sk := ast_kind(sub) if sk != EXPR_IDENT && sk != EXPR_WILDCARD: fv := new_ir_var("fld", TI_INT) - ft := structpat_field_load_ti(si, jdi) + ft := structpat_field_load_ti(si, jdi, scrut_ti) irv_set_type(fv, ft) emit(IR_LOAD_FIELD, fv, match_val, 0, jdi, 0) if gen_structpat_field_test(sub, fv, fall_lbl) < 0: @@ -3181,7 +3182,7 @@ fn gen_expr(node: int) -> int: ccr_case_set_target(case_base + ci_arm, body_nod) push_ir_scope() if pat_kind == EXPR_STRUCTPAT: - gen_structpat_binds(arm_pat, match_val) + gen_structpat_binds(arm_pat, match_val, irv_type(match_val)) if pat_kind == EXPR_ENUMPAT: sub_count := ast_c(arm_pat) fi : ., mut = 0 diff --git a/src/compiler/main.cr b/src/compiler/main.cr index ebb5bee6..d8583d4c 100644 --- a/src/compiler/main.cr +++ b/src/compiler/main.cr @@ -487,6 +487,7 @@ fn corec_main() -> int: g_cir_skip_journal = 1 // Generate IR for each function, checking cache first + ir_diag_base := g_diag_count fi : ., mut = 0 loop: if fi >= g_func_count: @@ -576,6 +577,14 @@ fn corec_main() -> int: fi = fi + 1 + // IR generation may add diagnostics (for example, monomorph instance + // revalidation). Previously they were only printed by the later + // pointer-analysis gate, which takes its base afterwards and therefore could + // not see them. Fail closed on any new IR-generation diagnostic. + if g_diag_count > ir_diag_base: + print_diagnostics() + return 1 + // Finalize purity and the state chain after IR generation and before any chain // consumer. True purity needs the whole program, and chain construction depends // on it. diff --git a/src/compiler/monomorph.cr b/src/compiler/monomorph.cr index 711c2ccf..39af02bc 100644 --- a/src/compiler/monomorph.cr +++ b/src/compiler/monomorph.cr @@ -31,6 +31,25 @@ g_gen_dedup_new : string, mut g_gen_dedup_count : int, mut g_gen_dedup_cap : int, mut +// Defensive struct-pattern validation for instances. The checker rejects struct +// patterns on unresolved generic scrutinees / generic field types, so a valid +// source never sets these. If a pattern still survives with an unresolved shape, +// the clone marks it fail-closed (no field loads) and the instance records a +// diagnostic. +g_gen_structpat_count : int, mut +g_gen_structpat_bad : int, mut +g_gen_structpat_bad_line : int, mut +g_gen_structpat_bad_col : int, mut + +// Concrete parameter-type environment for the instance currently being cloned. +// Built lazily only when a struct pattern is present, so generic programs without +// struct patterns keep their previous type-table / .ccr bytes unchanged. +g_gen_clone_param_names : string, mut +g_gen_clone_param_tis : string, mut +g_gen_clone_param_count : int, mut +g_gen_clone_param_ready : int, mut +g_gen_clone_orig_fn : int, mut + // ============================================================ // Grow helpers // ============================================================ @@ -524,6 +543,120 @@ fn gen_dedup_add(old_node: int, new_node: int): g_gen_dedup_count = g_gen_dedup_count + 1 // ============================================================ +// Build the current instance's parameter-type environment. Called lazily only +// when a struct pattern needs scrutinee resolution. +fn gen_build_clone_param_env(): + fn_node := g_gen_clone_orig_fn + if fn_node < 0: + return + pc0 := ast_c(fn_node) + g_gen_clone_param_count = 0 + g_gen_clone_param_names = alloc((pc0 + 1) * 8) + g_gen_clone_param_tis = alloc((pc0 + 1) * 8) + opn0 := ast_b(fn_node) + pi0 : ., mut = 0 + loop: + if pi0 >= pc0: + break + if opn0 < 0: + break + if ast_kind(opn0) == EXPR_PARAM: + nd0 := ast_data(opn0) + pti0 : ., mut = TI_INT + if nd0 >= 0: + pti0 = res_type_node(nd0) + cell_store(g_gen_clone_param_names, g_gen_clone_param_count * 8, ast_a(opn0)) + cell_store(g_gen_clone_param_tis, g_gen_clone_param_count * 8, pti0) + g_gen_clone_param_count = g_gen_clone_param_count + 1 + pi0 = pi0 + 1 + opn0 = opn0 + 1 + g_gen_clone_param_ready = 1 + +// Concrete type of a parameter in the instance currently being cloned (-1 if the +// name is not a parameter). +fn gen_clone_param_ti(name_ni: int) -> int: + if g_gen_clone_param_ready == 0: + return -1 + i : ., mut = 0 + loop: + if i >= g_gen_clone_param_count: + return -1 + if cell_load(g_gen_clone_param_names, i * 8) == name_ni: + return cell_load(g_gen_clone_param_tis, i * 8) + i = i + 1 + return -1 + +fn gen_mark_structpat_bad(pat: int): + if g_gen_structpat_bad == 0: + g_gen_structpat_bad = 1 + g_gen_structpat_bad_line = ast_line(pat) + g_gen_structpat_bad_col = ast_col(pat) + +// Validate struct patterns in a match before cloning its arms. The checker +// already rejects unresolved generic scrutinees; this is the post-substitution +// defense required for instances. `scrut_expr` is the original (pre-clone) match +// expression so it can resolve a parameter ident through the concrete instance +// environment. +fn gen_validate_match_structpats(scrut_expr: int, first_arm: int): + // Fast path: generic programs without struct patterns must not allocate type + // rows or otherwise perturb the type table during monomorphization. + has_structpat : ., mut = 0 + scan := first_arm + loop: + if scan < 0: + break + sp := ast_a(scan) + if sp >= 0 && ast_kind(sp) == EXPR_STRUCTPAT: + has_structpat = 1 + break + scan = ast_c(scan) + if has_structpat == 0: + return + if g_gen_clone_param_ready == 0: + gen_build_clone_param_env() + sti : ., mut = -1 + if scrut_expr >= 0 && ast_kind(scrut_expr) == EXPR_IDENT: + sti = gen_clone_param_ti(ast_int_val(scrut_expr)) + if sti >= 0: + sti = gen_subst_ti(sti) + an := first_arm + loop: + if an < 0: + break + pat := ast_a(an) + if pat >= 0 && ast_kind(pat) == EXPR_STRUCTPAT: + si := find_struct_by_name(ast_a(pat)) + if si < 0: + gen_mark_structpat_bad(pat) + else if sti >= 0: + if ti_has_generic_param(sti) != 0: + gen_mark_structpat_bad(pat) + else: + c := ast_c(pat) + w0 := ast_b(pat) + i : ., mut = 0 + loop: + if i >= c: + break + w := w0 + i + if w >= 0: + jdi := struct_field_index_by_name(si, ast_b(w)) + sub := ast_a(w) + ft := structpat_field_ti(si, jdi, sti) + if ti_has_generic_param(ft) != 0: + // `_` performs no comparison or binding, so a + // still-generic field type is safe there. + if sub < 0 || ast_kind(sub) != EXPR_WILDCARD: + gen_mark_structpat_bad(pat) + i = i + 1 + else: + // Scrutinee is not a parameter ident (local, call result, ...). + // Its instantiated field types cannot be recovered here; be + // conservative and reject struct patterns on generic structs. + if struct_has_generic_field(si) != 0: + gen_mark_structpat_bad(pat) + an = ast_c(an) + // Deep AST clone with generic param substitution // ============================================================ @@ -958,6 +1091,7 @@ fn gen_clone_tree(node: int) -> int: // ── EXPR_MATCH: a=expr(YES), b=first_arm(YES), c=arm_count(NOT) ── if k == EXPR_MATCH: + gen_validate_match_structpats(a, b) a2 := gen_clone_tree(a) b2 := gen_clone_tree(b) n := ast_alloc(k, a2, b2, c, iv, tv, d, ln, cl) @@ -990,6 +1124,15 @@ fn gen_clone_tree(node: int) -> int: // wrapper.a = subpattern node, wrapper.b = field name index (same contract as // struct literal wrappers). Preserve the name so a clone still binds by name. if k == EXPR_STRUCTPAT: + g_gen_structpat_count = g_gen_structpat_count + 1 + psi := find_struct_by_name(a) + pbad : ., mut = 0 + if psi < 0 || g_gen_structpat_bad != 0: + pbad = 1 + if g_gen_structpat_bad == 0: + g_gen_structpat_bad = 1 + g_gen_structpat_bad_line = ln + g_gen_structpat_bad_col = cl if b >= 0 && c > 0: nvs : string, mut = alloc(c * 8) nns : string, mut = alloc(c * 8) @@ -1013,9 +1156,21 @@ fn gen_clone_tree(node: int) -> int: break ast_alloc(EXPR_NONE, cell_load(nvs, i * 8), cell_load(nns, i * 8), 0, 0, 0, 0, ln, cl) i = i + 1 + if pbad != 0: + // Fail closed: b = -1 makes ir_gen reject the pattern without + // emitting any field load, even if the diagnostic gate is bypassed. + n := ast_alloc(k, a, -1, c, iv, tv, d, ln, cl) + gen_dedup_add(node, n) + return n n := ast_alloc(k, a, new_first, c, iv, tv, d, ln, cl) gen_dedup_add(node, n) return n + if pbad != 0: + // No real wrapper to fail on; synthesize an impossible wrapper so the + // IR path takes the fall edge instead of matching unconditionally. + n := ast_alloc(k, a, -1, 1, iv, tv, d, ln, cl) + gen_dedup_add(node, n) + return n n := ast_alloc(k, a, b, c, iv, tv, d, ln, cl) gen_dedup_add(node, n) return n @@ -1098,10 +1253,20 @@ fn gen_create_instance(func_ni: int, type_args: string, bstart: int) -> int: gen_build_subst(func_ni, type_args) gen_build_subst_tis(func_ni, bstart) - // 2. Clone the original function AST + // 2. Clone the original function AST. The parameter-type environment for + // struct-pattern validation is built lazily (only when a struct pattern is + // present), so programs without struct patterns keep their previous + // type-table and .ccr bytes. orig_fn_node := fi_ast_node(func_ni) + g_gen_clone_param_count = 0 + g_gen_clone_param_ready = 0 + g_gen_clone_orig_fn = orig_fn_node g_gen_dedup_count = 0 g_gen_dedup_cap = 0 + g_gen_structpat_count = 0 + g_gen_structpat_bad = 0 + g_gen_structpat_bad_line = 0 + g_gen_structpat_bad_col = 0 new_fn_node := gen_clone_tree(orig_fn_node) // Concretize the instance parameter chain. The EXPR_FN clone branch copies b/c @@ -1141,6 +1306,36 @@ fn gen_create_instance(func_ni: int, type_args: string, bstart: int) -> int: if first_np >= 0: ast_set_b(new_fn_node, first_np) + // Defensive revalidation after substitution. The checker already rejects + // struct patterns on unresolved generic scrutinees, so this only fires if a + // path bypassed it. Any remaining generic parameter on an instance parameter + // makes every struct pattern in the body unsafe: record a hard diagnostic and + // let the cloned patterns' fail-closed shape (b = -1) prevent field loads. + if g_gen_structpat_count > 0: + opn2 := ast_b(new_fn_node) + pc2 := ast_c(new_fn_node) + pi2 : ., mut = 0 + loop: + if pi2 >= pc2: + break + if opn2 < 0: + break + if ast_kind(opn2) == EXPR_PARAM: + nd := ast_data(opn2) + if nd >= 0: + pti := res_type_node(nd) + if ti_has_generic_param(pti) != 0: + g_gen_structpat_bad = 1 + if g_gen_structpat_bad_line == 0: + g_gen_structpat_bad_line = ast_line(opn2) + g_gen_structpat_bad_col = ast_col(opn2) + pi2 = pi2 + 1 + opn2 = opn2 + 1 + if g_gen_structpat_bad != 0: + check_error(EC_TS_FIELD_TYPE, + "Struct pattern on an unresolved generic type is not supported; use a wildcard or binding arm", + g_gen_structpat_bad_line, g_gen_structpat_bad_col) + // Compute mangled name: "orig_name[type_args]" for unique identification orig_name := istr_get(fi_name(func_ni)) mangled_name : ., mut = orig_name + "[" + type_args + "]" diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index d0da8a58..ba12d5c3 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -1011,6 +1011,115 @@ def test_duplicate_value_cases_are_allowed_by_the_reader(): Point { x = 1 } => 1 """ +# Runtime-constructed string field: the pattern must compare contents via str_eq, +# not intern indices/pointers. Also exercises partial, reordered fields and a +# non-int ident binding (string). +MATCH_STRUCT_MISC_SOURCE = """struct Point: + x: int + y: int + +struct R: + name: string + +fn pickp(p: Point) -> int: + return match p: + Point { y = 9, x = 5 } => 1, + Point { x = 2 } => 2, + _ => 0 + +fn pickname(r: R) -> string: + return match r: + R { name = n } => n, + _ => "" + +fn pickmatch(r: R) -> int: + return match r: + R { name = "hi" } => 1, + _ => 0 + +fn main() -> int: + if pickp(Point { x = 5, y = 9 }) != 1: + return 1 + if pickp(Point { x = 2, y = 3 }) != 2: + return 2 + if pickp(Point { x = 3, y = 3 }) != 0: + return 3 + r := R { name = concat("h", "i") } + if pickname(r) != "hi": + return 4 + if pickmatch(r) != 1: + return 5 + return 42 +""" + +MATCH_STRUCT_GENERIC_SCRUTINEE_SOURCE = """struct Point: + x: int + y: int + +struct One: + a: int + +fn pick[T](v: T) -> int: + return match v: + Point { x = 1 } => 1, + _ => 0 + +fn main() -> int: + return pick(One { a = 2 }) + pick(7) +""" + +MATCH_STRUCT_GENERIC_FIELD_SOURCE = """struct Pair[T]: + a: T + n: int + +fn pick(p: Pair[string]) -> int: + return match p: + Pair { a = 1, n = 2 } => 1, + _ => 0 + +fn main() -> int: + p := Pair { a = "x", n = 2 } + return pick(p) +""" + +# A concrete generic application inside a generic function still monomorphizes: +# the scrutinee is Pair[Point], so field substitution yields Point and the +# binding pattern is valid. This guards the monomorph revalidation from being +# over-conservative. +MATCH_STRUCT_GENERIC_INSTANCE_SOURCE = """struct Point: + x: int + y: int + +struct Pair[T]: + a: T + n: int + +fn show[U](u: U, p: Pair[Point]) -> int: + return match p: + Pair { a = v, n = 2 } => 1, + _ => 0 + +fn main() -> int: + p := Pair { a = Point { x = 1, y = 2 }, n = 2 } + if show(7, p) != 1: + return 1 + return 42 +""" + +MATCH_STRUCT_CONCRETE_MISMATCH_SOURCE = """struct Point: + x: int + y: int + +struct Other: + a: int + +fn main() -> int: + o := Other { a = 1 } + return match o: + Point { x = 1 } => 1, + _ => 0 +""" + MATCH_ONE_CASE_SOURCE = """fn pick(n: int) -> int: return match n: 7 => 1, @@ -1647,6 +1756,58 @@ def test_struct_pattern_ident_last_is_exhaustive(): ) +def test_struct_pattern_partial_reordered_and_runtime_string(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, MATCH_STRUCT_MISC_SOURCE, "match_struct_misc") + assert _match_nodes(data) == [], "struct patterns must not become MATCH" + assert segment(data, CASE_TAG) == struct.pack(" Date: Sat, 24 Oct 2026 09:26:54 +0800 Subject: [PATCH 14/22] ccr: validate struct pattern field types from actual scrutinee --- src/compiler/checker.cr | 133 +++++++++++---- src/compiler/ir_gen.cr | 50 ++++-- src/compiler/main.cr | 4 + src/compiler/monomorph.cr | 216 ++++--------------------- tests/selfhost/test_multibranch_v10.py | 141 ++++++++++++++++ 5 files changed, 311 insertions(+), 233 deletions(-) diff --git a/src/compiler/checker.cr b/src/compiler/checker.cr index 89228831..c0bcacc4 100644 --- a/src/compiler/checker.cr +++ b/src/compiler/checker.cr @@ -1640,45 +1640,98 @@ fn struct_field_index_by_name(si: int, name_ni: int) -> int: j = j + 1 return -1 -// Resolve a struct field type for a pattern scrutinee. Direct generic parameters -// are substituted from a concrete `TYP_GENERIC_APPLY` scrutinee (same rule as -// EXPR_FIELD); nested generic forms keep the parameter row and are rejected by the -// caller unless the subpattern is `_`. -fn structpat_field_ti(si: int, jdi: int, scrut_ti: int) -> int: - if si < 0 || jdi < 0: - return -1 - ftn := si_field_type_node(si, jdi) - if ftn >= 0 && ast_kind(ftn) == EXPR_IDENT: - ft_name := ast_int_val(ftn) - if is_struct_generic(si, ft_name) && get_type_kind(scrut_ti) == TYP_GENERIC_APPLY: +// Recursively replace struct generic-parameter rows with the concrete arguments +// supplied by a TYP_GENERIC_APPLY scrutinee. Unknown/absent arguments stay as +// TYP_GENERIC_PARAM so the caller can reject/fail closed. +fn structpat_subst_ti(si: int, ti: int, scrut_ti: int) -> int: + k := get_type_kind(ti) + if k < 0: + return ti + if k == TYP_GENERIC_PARAM: + pni := get_type_data(ti) + if get_type_kind(scrut_ti) == TYP_GENERIC_APPLY: ga_start := get_type_extra(scrut_ti) ga_count := cell_load(g_gen_apply_data, ga_start * 8) gpi : ., mut = 0 loop: if gpi >= si_generic_count(si): break - if si_generic_name(si, gpi) == ft_name: + if si_generic_name(si, gpi) == pni: if gpi < ga_count: return cell_load(g_gen_apply_data, (ga_start + 1 + gpi) * 8) break gpi = gpi + 1 - return res_type_node(ftn) + return ti + if k == TYP_ARRAY: + return alloc_type(TYP_ARRAY, structpat_subst_ti(si, get_type_data(ti), scrut_ti), get_type_extra(ti)) + if k == TYP_SLICE: + return alloc_type(TYP_SLICE, structpat_subst_ti(si, get_type_data(ti), scrut_ti), 0) + if k == TYP_PTR: + return alloc_type(TYP_PTR, structpat_subst_ti(si, get_type_data(ti), scrut_ti), get_type_extra(ti)) + if k == TYP_REF: + return alloc_type(TYP_REF, structpat_subst_ti(si, get_type_data(ti), scrut_ti), get_type_extra(ti)) + if k == TYP_OPTIONAL: + return alloc_type(TYP_OPTIONAL, structpat_subst_ti(si, get_type_data(ti), scrut_ti), 0) + if k == TYP_TUPLE: + cnt := get_type_data(ti) + st := get_type_extra(ti) + subs : string, mut + if cnt > 0: + subs = alloc(cnt * 8) + i : ., mut = 0 + loop: + if i >= cnt: + break + cell_store(subs, i * 8, structpat_subst_ti(si, cell_load(g_gen_apply_data, (st + i) * 8), scrut_ti)) + i = i + 1 + ns := g_gen_apply_data_count + grow_gen_apply_data(ns + cnt) + i2 : ., mut = 0 + loop: + if i2 >= cnt: + break + cell_store(g_gen_apply_data, (ns + i2) * 8, cell_load(subs, i2 * 8)) + i2 = i2 + 1 + g_gen_apply_data_count = ns + cnt + return alloc_type(TYP_TUPLE, cnt, ns) + if k == TYP_GENERIC_APPLY: + st2 := get_type_extra(ti) + cnt2 := cell_load(g_gen_apply_data, st2 * 8) + subs2 : string, mut + if cnt2 > 0: + subs2 = alloc(cnt2 * 8) + i3 : ., mut = 0 + loop: + if i3 >= cnt2: + break + cell_store(subs2, i3 * 8, structpat_subst_ti(si, cell_load(g_gen_apply_data, (st2 + 1 + i3) * 8), scrut_ti)) + i3 = i3 + 1 + ns2 := g_gen_apply_data_count + grow_gen_apply_data(ns2 + 1 + cnt2) + cell_store(g_gen_apply_data, ns2 * 8, cnt2) + i4 : ., mut = 0 + loop: + if i4 >= cnt2: + break + cell_store(g_gen_apply_data, (ns2 + 1 + i4) * 8, cell_load(subs2, i4 * 8)) + i4 = i4 + 1 + g_gen_apply_data_count = ns2 + 1 + cnt2 + return alloc_type(TYP_GENERIC_APPLY, get_type_data(ti), ns2) + return ti -// A struct row whose field declarations still mention a generic parameter cannot -// be used by a struct pattern unless the scrutinee supplies concrete arguments. -// Used by monomorph as a defensive check. -fn struct_has_generic_field(si: int) -> int: - if si < 0: - return 1 - j : ., mut = 0 - loop: - if j >= si_field_count(si): - return 0 - ft := res_type_node(si_field_type_node(si, j)) - if ti_has_generic_param(ft) != 0: - return 1 - j = j + 1 - return 0 +// Resolve a struct field type for a pattern scrutinee. Concrete fields are +// returned unchanged; generic fields are substituted structurally (including +// optional, array, tuple, and nested generic applications). The caller rejects +// any remaining generic row for non-`_` subpatterns. +fn structpat_field_ti(si: int, jdi: int, scrut_ti: int) -> int: + if si < 0 || jdi < 0: + return -1 + ft := res_type_node(si_field_type_node(si, jdi)) + if ft < 0: + return -1 + if ti_has_generic_param(ft) == 0: + return ft + return structpat_subst_ti(si, ft, scrut_ti) // ─── Struct match pattern(Task 5b1)─── // EXPR_STRUCTPAT layout: a = struct name ni, b = first wrapper, c = field count. @@ -1765,6 +1818,11 @@ fn check_struct_pattern(pat: int, scrut_ti: int) -> int: k := ast_kind(sub) if k == EXPR_IDENT || k == EXPR_WILDCARD || k == EXPR_INT || k == EXPR_BOOL || k == EXPR_CHAR || k == EXPR_STRING: ft := structpat_field_ti(si, jdi, scrut_ti) + if ft >= 0: + // Cache the checker-resolved (already substituted) field type + // on the wrapper for IR gen and monomorph clones. +1 keeps 0 + // as the "absent" sentinel because TI_INT itself is 0. + ast_set_data(w, ft + 1) if ti_has_generic_param(ft) != 0: if k == EXPR_IDENT: check_error(EC_TS_FIELD_TYPE, @@ -1779,14 +1837,21 @@ fn check_struct_pattern(pat: int, scrut_ti: int) -> int: ast_line(sub), ast_col(sub)) bad = 1 else if k != EXPR_IDENT && k != EXPR_WILDCARD: - st := infer_expr(sub) - verdict := type_compat_strict(st, ft) - if verdict != 1: - diag_type_incompatible(verdict, EC_TS_FIELD_TYPE, + if get_type_kind(ft) == TYP_OPTIONAL: + check_error(EC_TS_FIELD_TYPE, "Field '" + istr_get(fni) + "' in struct pattern " + istr_get(name_ni) + - ": expected " + type_display(ft) + ", got " + type_display(st), + " has an optional type; use a wildcard or binding arm", ast_line(sub), ast_col(sub)) bad = 1 + else: + st := infer_expr(sub) + verdict := type_compat_strict(st, ft) + if verdict != 1: + diag_type_incompatible(verdict, EC_TS_FIELD_TYPE, + "Field '" + istr_get(fni) + "' in struct pattern " + istr_get(name_ni) + + ": expected " + type_display(ft) + ", got " + type_display(st), + ast_line(sub), ast_col(sub)) + bad = 1 else: check_error(EC_TM_NESTED, "Nested match patterns are not supported yet", @@ -1836,6 +1901,8 @@ fn ti_has_generic_param(ti: int) -> int: return 1 if k == TYP_ARRAY || k == TYP_PTR || k == TYP_REF || k == TYP_SLICE: return ti_has_generic_param(get_type_data(ti)) + if k == TYP_OPTIONAL: + return ti_has_generic_param(get_type_data(ti)) if k == TYP_TUPLE: cnt := get_type_data(ti) st := get_type_extra(ti) diff --git a/src/compiler/ir_gen.cr b/src/compiler/ir_gen.cr index 80f0abc1..2dabdb6a 100644 --- a/src/compiler/ir_gen.cr +++ b/src/compiler/ir_gen.cr @@ -892,8 +892,12 @@ fn find_global(name_idx: int) -> int: // bool/char keep their declared form so literal comparisons use the same // representation as the existing bool/char arms, and other fields use the exact // integer slot form (TI_INT). -fn structpat_field_load_ti(si: int, jdi: int, scrut_ti: int) -> int: - ft := structpat_field_ti(si, jdi, scrut_ti) +// Field load form for struct-pattern tests/bindings. `ft` is the checker-resolved +// field type stored on the wrapper (+1). String patterns must keep TI_STR so +// str_eq compares contents, bool/char keep their declared form so literal +// comparisons use the same representation as the existing arms, and other fields +// use the exact integer slot form (TI_INT). Optional fields are raw slots. +fn structpat_field_load_ti_from(ft: int) -> int: if ft == TI_STR: return TI_STR if ft == TI_DEX: @@ -955,9 +959,9 @@ fn gen_structpat_field_test(sub: int, fv: int, fall: int) -> int: return 1 // Bind ident subpatterns for a struct pattern in the arm scope. Each binding -// reads its field again and gets the declared field type. Literal and `_` -// subpatterns bind nothing. -fn gen_structpat_binds(pat: int, match_val: int, scrut_ti: int): +// reads its field again and gets the checker-resolved field type stored on the +// wrapper. Literal and `_` subpatterns bind nothing. +fn gen_structpat_binds(pat: int, match_val: int): if pat < 0 || match_val < 0: return si := find_struct_by_name(ast_a(pat)) @@ -977,10 +981,19 @@ fn gen_structpat_binds(pat: int, match_val: int, scrut_ti: int): if str_eq(istr_get(nn), "_") == 0: jdi := struct_field_index_by_name(si, ast_b(w)) if jdi >= 0: - fv := new_ir_var("fld", TI_INT) - irv_set_type(fv, structpat_field_load_ti(si, jdi, scrut_ti)) - emit(IR_LOAD_FIELD, fv, match_val, 0, jdi, 0) - bind_local(nn, fv) + fd := ast_data(w) + ft : ., mut = struct_row_field_ti(si, jdi) + if fd > 0: + ft = fd - 1 + // A still-generic field cannot be bound soundly; the + // pattern was fail-closed before the body. + if ti_has_generic_param(ft) == 0: + fv := new_ir_var("fld", TI_INT) + irv_set_type(fv, structpat_field_load_ti_from(ft)) + if get_type_kind(ft) == TYP_OPTIONAL && g_optrep_on != 0: + irv_set_decl_ti(fv, ft) + emit(IR_LOAD_FIELD, fv, match_val, 0, jdi, 0) + bind_local(nn, fv) i = i + 1 // Return the literal initializer AST node for an immutable module-level @@ -3083,7 +3096,6 @@ fn gen_expr(node: int) -> int: si := find_struct_by_name(ast_a(arm_pat)) sc := ast_c(arm_pat) w0 := ast_b(arm_pat) - scrut_ti := irv_type(match_val) can_match : ., mut = 1 if si < 0 || w0 < 0: can_match = 0 @@ -3108,9 +3120,21 @@ fn gen_expr(node: int) -> int: if sub >= 0: sk := ast_kind(sub) if sk != EXPR_IDENT && sk != EXPR_WILDCARD: + fd := ast_data(w) + ft : ., mut = struct_row_field_ti(si, jdi) + if fd > 0: + ft = fd - 1 + if ti_has_generic_param(ft) != 0: + // The checker should have rejected this before + // codegen. Never emit a field load with an + // unresolved type: fail closed. + check_error(EC_TS_FIELD_TYPE, + "Struct pattern field has an unresolved generic type", + ast_line(sub), ast_col(sub)) + can_match = 0 + break fv := new_ir_var("fld", TI_INT) - ft := structpat_field_load_ti(si, jdi, scrut_ti) - irv_set_type(fv, ft) + irv_set_type(fv, structpat_field_load_ti_from(ft)) emit(IR_LOAD_FIELD, fv, match_val, 0, jdi, 0) if gen_structpat_field_test(sub, fv, fall_lbl) < 0: can_match = 0 @@ -3182,7 +3206,7 @@ fn gen_expr(node: int) -> int: ccr_case_set_target(case_base + ci_arm, body_nod) push_ir_scope() if pat_kind == EXPR_STRUCTPAT: - gen_structpat_binds(arm_pat, match_val, irv_type(match_val)) + gen_structpat_binds(arm_pat, match_val) if pat_kind == EXPR_ENUMPAT: sub_count := ast_c(arm_pat) fi : ., mut = 0 diff --git a/src/compiler/main.cr b/src/compiler/main.cr index d8583d4c..70fb86e3 100644 --- a/src/compiler/main.cr +++ b/src/compiler/main.cr @@ -388,7 +388,11 @@ fn corec_main() -> int: run_rc := run_frontend() if run_rc != 0: return 1 + ir_diag_base_run := g_diag_count ir_gen_all() + if g_diag_count > ir_diag_base_run: + print_diagnostics() + return 1 return ir_interpret() // === clean-cache: delete incremental compilation cache === diff --git a/src/compiler/monomorph.cr b/src/compiler/monomorph.cr index 39af02bc..5b09db50 100644 --- a/src/compiler/monomorph.cr +++ b/src/compiler/monomorph.cr @@ -31,25 +31,13 @@ g_gen_dedup_new : string, mut g_gen_dedup_count : int, mut g_gen_dedup_cap : int, mut -// Defensive struct-pattern validation for instances. The checker rejects struct -// patterns on unresolved generic scrutinees / generic field types, so a valid -// source never sets these. If a pattern still survives with an unresolved shape, -// the clone marks it fail-closed (no field loads) and the instance records a -// diagnostic. -g_gen_structpat_count : int, mut +// Defensive struct-pattern validation for instances. The checker resolves field +// types on each wrapper (wrapper.data = ti+1); if one still contains an +// unresolved generic parameter after substitution, the clone fails closed. g_gen_structpat_bad : int, mut g_gen_structpat_bad_line : int, mut g_gen_structpat_bad_col : int, mut -// Concrete parameter-type environment for the instance currently being cloned. -// Built lazily only when a struct pattern is present, so generic programs without -// struct patterns keep their previous type-table / .ccr bytes unchanged. -g_gen_clone_param_names : string, mut -g_gen_clone_param_tis : string, mut -g_gen_clone_param_count : int, mut -g_gen_clone_param_ready : int, mut -g_gen_clone_orig_fn : int, mut - // ============================================================ // Grow helpers // ============================================================ @@ -543,120 +531,6 @@ fn gen_dedup_add(old_node: int, new_node: int): g_gen_dedup_count = g_gen_dedup_count + 1 // ============================================================ -// Build the current instance's parameter-type environment. Called lazily only -// when a struct pattern needs scrutinee resolution. -fn gen_build_clone_param_env(): - fn_node := g_gen_clone_orig_fn - if fn_node < 0: - return - pc0 := ast_c(fn_node) - g_gen_clone_param_count = 0 - g_gen_clone_param_names = alloc((pc0 + 1) * 8) - g_gen_clone_param_tis = alloc((pc0 + 1) * 8) - opn0 := ast_b(fn_node) - pi0 : ., mut = 0 - loop: - if pi0 >= pc0: - break - if opn0 < 0: - break - if ast_kind(opn0) == EXPR_PARAM: - nd0 := ast_data(opn0) - pti0 : ., mut = TI_INT - if nd0 >= 0: - pti0 = res_type_node(nd0) - cell_store(g_gen_clone_param_names, g_gen_clone_param_count * 8, ast_a(opn0)) - cell_store(g_gen_clone_param_tis, g_gen_clone_param_count * 8, pti0) - g_gen_clone_param_count = g_gen_clone_param_count + 1 - pi0 = pi0 + 1 - opn0 = opn0 + 1 - g_gen_clone_param_ready = 1 - -// Concrete type of a parameter in the instance currently being cloned (-1 if the -// name is not a parameter). -fn gen_clone_param_ti(name_ni: int) -> int: - if g_gen_clone_param_ready == 0: - return -1 - i : ., mut = 0 - loop: - if i >= g_gen_clone_param_count: - return -1 - if cell_load(g_gen_clone_param_names, i * 8) == name_ni: - return cell_load(g_gen_clone_param_tis, i * 8) - i = i + 1 - return -1 - -fn gen_mark_structpat_bad(pat: int): - if g_gen_structpat_bad == 0: - g_gen_structpat_bad = 1 - g_gen_structpat_bad_line = ast_line(pat) - g_gen_structpat_bad_col = ast_col(pat) - -// Validate struct patterns in a match before cloning its arms. The checker -// already rejects unresolved generic scrutinees; this is the post-substitution -// defense required for instances. `scrut_expr` is the original (pre-clone) match -// expression so it can resolve a parameter ident through the concrete instance -// environment. -fn gen_validate_match_structpats(scrut_expr: int, first_arm: int): - // Fast path: generic programs without struct patterns must not allocate type - // rows or otherwise perturb the type table during monomorphization. - has_structpat : ., mut = 0 - scan := first_arm - loop: - if scan < 0: - break - sp := ast_a(scan) - if sp >= 0 && ast_kind(sp) == EXPR_STRUCTPAT: - has_structpat = 1 - break - scan = ast_c(scan) - if has_structpat == 0: - return - if g_gen_clone_param_ready == 0: - gen_build_clone_param_env() - sti : ., mut = -1 - if scrut_expr >= 0 && ast_kind(scrut_expr) == EXPR_IDENT: - sti = gen_clone_param_ti(ast_int_val(scrut_expr)) - if sti >= 0: - sti = gen_subst_ti(sti) - an := first_arm - loop: - if an < 0: - break - pat := ast_a(an) - if pat >= 0 && ast_kind(pat) == EXPR_STRUCTPAT: - si := find_struct_by_name(ast_a(pat)) - if si < 0: - gen_mark_structpat_bad(pat) - else if sti >= 0: - if ti_has_generic_param(sti) != 0: - gen_mark_structpat_bad(pat) - else: - c := ast_c(pat) - w0 := ast_b(pat) - i : ., mut = 0 - loop: - if i >= c: - break - w := w0 + i - if w >= 0: - jdi := struct_field_index_by_name(si, ast_b(w)) - sub := ast_a(w) - ft := structpat_field_ti(si, jdi, sti) - if ti_has_generic_param(ft) != 0: - // `_` performs no comparison or binding, so a - // still-generic field type is safe there. - if sub < 0 || ast_kind(sub) != EXPR_WILDCARD: - gen_mark_structpat_bad(pat) - i = i + 1 - else: - // Scrutinee is not a parameter ident (local, call result, ...). - // Its instantiated field types cannot be recovered here; be - // conservative and reject struct patterns on generic structs. - if struct_has_generic_field(si) != 0: - gen_mark_structpat_bad(pat) - an = ast_c(an) - // Deep AST clone with generic param substitution // ============================================================ @@ -1091,7 +965,6 @@ fn gen_clone_tree(node: int) -> int: // ── EXPR_MATCH: a=expr(YES), b=first_arm(YES), c=arm_count(NOT) ── if k == EXPR_MATCH: - gen_validate_match_structpats(a, b) a2 := gen_clone_tree(a) b2 := gen_clone_tree(b) n := ast_alloc(k, a2, b2, c, iv, tv, d, ln, cl) @@ -1124,53 +997,55 @@ fn gen_clone_tree(node: int) -> int: // wrapper.a = subpattern node, wrapper.b = field name index (same contract as // struct literal wrappers). Preserve the name so a clone still binds by name. if k == EXPR_STRUCTPAT: - g_gen_structpat_count = g_gen_structpat_count + 1 - psi := find_struct_by_name(a) pbad : ., mut = 0 - if psi < 0 || g_gen_structpat_bad != 0: - pbad = 1 - if g_gen_structpat_bad == 0: - g_gen_structpat_bad = 1 - g_gen_structpat_bad_line = ln - g_gen_structpat_bad_col = cl if b >= 0 && c > 0: nvs : string, mut = alloc(c * 8) nns : string, mut = alloc(c * 8) + nds : string, mut = alloc(c * 8) i : ., mut = 0 loop: if i >= c: break vn : ., mut = -1 - if b + i >= 0: - vn = ast_a(b + i) - cell_store(nvs, i * 8, gen_clone_tree(vn)) nn : ., mut = -1 + nd : ., mut = 0 if b + i >= 0: + vn = ast_a(b + i) nn = ast_b(b + i) + nd = ast_data(b + i) // checker-resolved field type + 1 + if nd > 0: + ft := nd - 1 + sk : ., mut = -1 + if vn >= 0: + sk = ast_kind(vn) + // `_` neither compares nor binds, so a still-generic field is + // safe there. ident/literal subpatterns would load/bind it. + if ti_has_generic_param(ft) != 0 && sk != EXPR_WILDCARD: + pbad = 1 + if g_gen_structpat_bad == 0: + g_gen_structpat_bad = 1 + g_gen_structpat_bad_line = ln + g_gen_structpat_bad_col = cl + cell_store(nvs, i * 8, gen_clone_tree(vn)) cell_store(nns, i * 8, nn) + cell_store(nds, i * 8, nd) i = i + 1 new_first := g_ast_count i = 0 loop: if i >= c: break - ast_alloc(EXPR_NONE, cell_load(nvs, i * 8), cell_load(nns, i * 8), 0, 0, 0, 0, ln, cl) + ast_alloc(EXPR_NONE, cell_load(nvs, i * 8), cell_load(nns, i * 8), 0, 0, 0, cell_load(nds, i * 8), ln, cl) i = i + 1 if pbad != 0: // Fail closed: b = -1 makes ir_gen reject the pattern without - // emitting any field load, even if the diagnostic gate is bypassed. + // emitting any field load. n := ast_alloc(k, a, -1, c, iv, tv, d, ln, cl) gen_dedup_add(node, n) return n n := ast_alloc(k, a, new_first, c, iv, tv, d, ln, cl) gen_dedup_add(node, n) return n - if pbad != 0: - // No real wrapper to fail on; synthesize an impossible wrapper so the - // IR path takes the fall edge instead of matching unconditionally. - n := ast_alloc(k, a, -1, 1, iv, tv, d, ln, cl) - gen_dedup_add(node, n) - return n n := ast_alloc(k, a, b, c, iv, tv, d, ln, cl) gen_dedup_add(node, n) return n @@ -1253,21 +1128,18 @@ fn gen_create_instance(func_ni: int, type_args: string, bstart: int) -> int: gen_build_subst(func_ni, type_args) gen_build_subst_tis(func_ni, bstart) - // 2. Clone the original function AST. The parameter-type environment for - // struct-pattern validation is built lazily (only when a struct pattern is - // present), so programs without struct patterns keep their previous - // type-table and .ccr bytes. + // 2. Clone the original function AST orig_fn_node := fi_ast_node(func_ni) - g_gen_clone_param_count = 0 - g_gen_clone_param_ready = 0 - g_gen_clone_orig_fn = orig_fn_node g_gen_dedup_count = 0 g_gen_dedup_cap = 0 - g_gen_structpat_count = 0 g_gen_structpat_bad = 0 g_gen_structpat_bad_line = 0 g_gen_structpat_bad_col = 0 new_fn_node := gen_clone_tree(orig_fn_node) + if g_gen_structpat_bad != 0: + check_error(EC_TS_FIELD_TYPE, + "Struct pattern field has an unresolved generic type", + g_gen_structpat_bad_line, g_gen_structpat_bad_col) // Concretize the instance parameter chain. The EXPR_FN clone branch copies b/c // (first parameter and count), so the instance would share the declaration's @@ -1306,36 +1178,6 @@ fn gen_create_instance(func_ni: int, type_args: string, bstart: int) -> int: if first_np >= 0: ast_set_b(new_fn_node, first_np) - // Defensive revalidation after substitution. The checker already rejects - // struct patterns on unresolved generic scrutinees, so this only fires if a - // path bypassed it. Any remaining generic parameter on an instance parameter - // makes every struct pattern in the body unsafe: record a hard diagnostic and - // let the cloned patterns' fail-closed shape (b = -1) prevent field loads. - if g_gen_structpat_count > 0: - opn2 := ast_b(new_fn_node) - pc2 := ast_c(new_fn_node) - pi2 : ., mut = 0 - loop: - if pi2 >= pc2: - break - if opn2 < 0: - break - if ast_kind(opn2) == EXPR_PARAM: - nd := ast_data(opn2) - if nd >= 0: - pti := res_type_node(nd) - if ti_has_generic_param(pti) != 0: - g_gen_structpat_bad = 1 - if g_gen_structpat_bad_line == 0: - g_gen_structpat_bad_line = ast_line(opn2) - g_gen_structpat_bad_col = ast_col(opn2) - pi2 = pi2 + 1 - opn2 = opn2 + 1 - if g_gen_structpat_bad != 0: - check_error(EC_TS_FIELD_TYPE, - "Struct pattern on an unresolved generic type is not supported; use a wildcard or binding arm", - g_gen_structpat_bad_line, g_gen_structpat_bad_col) - // Compute mangled name: "orig_name[type_args]" for unique identification orig_name := istr_get(fi_name(func_ni)) mangled_name : ., mut = orig_name + "[" + type_args + "]" diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index ba12d5c3..51fe02ba 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -1106,6 +1106,105 @@ def test_duplicate_value_cases_are_allowed_by_the_reader(): return 42 """ +# Concrete generic instances reached through locals, field access, call results, +# and a shadowed parameter. The checker resolves the actual scrutinee type; the +# monomorph defensiveness must not fall back to the generic struct declaration. +MATCH_STRUCT_GENERIC_ACTUALS_SOURCE = """struct Point: + x: int + +struct Pair[T]: + a: T + +struct Holder: + p: Pair[Point] + +fn make() -> Pair[Point]: + return Pair { a = Point { x = 7 } } + +fn via_local[U](u: U) -> int: + p := Pair { a = Point { x = 7 } } + return match p: + Pair { a = v } => v.x, + _ => 0 + +fn via_field[U](u: U, h: Holder) -> int: + return match h.p: + Pair { a = v } => v.x, + _ => 0 + +fn via_call[U](u: U) -> int: + return match make(): + Pair { a = v } => v.x, + _ => 0 + +fn shadowed[U](p: U) -> int: + p := Pair { a = Point { x = 7 } } + return match p: + Pair { a = v } => v.x, + _ => 0 + +fn main() -> int: + h := Holder { p = Pair { a = Point { x = 7 } } } + if via_local(1) != 7: + return 1 + if via_field(1, h) != 7: + return 2 + if via_call(1) != 7: + return 3 + if shadowed(99) != 7: + return 4 + return 42 +""" + +# Optional generic field: R[string] makes s: T? bind as string?. A literal cannot +# match an optional slot. The nested optional match is a valid operation on the +# bound value and runs through the interpreter (the pre-existing ELF layout for +# optional string struct fields is outside this pattern task). +MATCH_STRUCT_OPTIONAL_BIND_SOURCE = """struct R[T]: + s: T? + +fn show(r: R[string]) -> int: + return match r: + R { s = v } =>: + return match v: + Some(x) =>: + if x == "hi": + return 42 + return 1 + none =>: + return 2 + _ => 3 + +fn main() -> int: + r := R { s = Some("hi") } + return show(r) +""" + +MATCH_STRUCT_OPTIONAL_LITERAL_SOURCE = """struct R[T]: + s: T? + +fn pick(r: R[string]) -> int: + return match r: + R { s = "hi" } => 1, + _ => 0 + +fn main() -> int: + r := R { s = Some("hi") } + return pick(r) +""" + +MATCH_STRUCT_OPTIONAL_UNRESOLVED_SOURCE = """struct R[T]: + s: T? + +fn pick[U](r: R[U]) -> int: + return match r: + R { s = v } => 1, + _ => 0 + +fn main() -> int: + return pick(1) +""" + MATCH_STRUCT_CONCRETE_MISMATCH_SOURCE = """struct Point: x: int y: int @@ -1808,6 +1907,38 @@ def test_struct_pattern_concrete_generic_instance(): ) +def test_struct_pattern_generic_actuals_through_locals_fields_calls(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr( + tmp, MATCH_STRUCT_GENERIC_ACTUALS_SOURCE, "match_struct_generic_actuals" + ) + assert _match_nodes(data) == [], "struct patterns must not become MATCH" + _assert_program( + tmp, + MATCH_STRUCT_GENERIC_ACTUALS_SOURCE, + 42, + opts=(0, 2), + name="match_struct_generic_actuals", + ) + + +def test_struct_pattern_optional_generic_field_binds_option(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr( + tmp, MATCH_STRUCT_OPTIONAL_BIND_SOURCE, "match_struct_optional_bind" + ) + assert _match_nodes(data) == [], "struct patterns must not become MATCH" + # Interpreter path: the bound ident must be string?, so the nested optional + # match type-checks and returns the contained string. + run = _run([COREC, "run", MATCH_STRUCT_OPTIONAL_BIND_SOURCE], cwd=BASE) + assert run.returncode == 42, ( + f"optional ident binding failed: rc={run.returncode}: " + f"{run.stdout}\n{run.stderr}" + ) + + def test_struct_pattern_errors_are_hard(): cases = [ ( @@ -1840,6 +1971,16 @@ def test_struct_pattern_errors_are_hard(): MATCH_STRUCT_CONCRETE_MISMATCH_SOURCE, ["error[TS03]", "does not match scrutinee type"], ), + ( + "struct_optional_literal", + MATCH_STRUCT_OPTIONAL_LITERAL_SOURCE, + ["error[TS03]", "has an optional type"], + ), + ( + "struct_optional_unresolved", + MATCH_STRUCT_OPTIONAL_UNRESOLVED_SOURCE, + ["error[TS03]", "requires a concrete struct type"], + ), ] with tempfile.TemporaryDirectory() as td: tmp = Path(td) From 26207613410ab39e5e9b3959f5f4608afe5c3ad7 Mon Sep 17 00:00:00 2001 From: DslsDZC Date: Sat, 24 Oct 2026 09:40:15 +0800 Subject: [PATCH 15/22] ccr: implement tuple match patterns --- src/compiler/ast.cr | 1 + src/compiler/checker.cr | 145 +++++++++++++++++++- src/compiler/ir_gen.cr | 87 ++++++++++++ src/compiler/monomorph.cr | 38 ++++++ src/compiler/parser.cr | 57 ++++++++ src/compiler/ty_shadow.cr | 13 +- tests/selfhost/test_multibranch_v10.py | 182 +++++++++++++++++++++++++ 7 files changed, 521 insertions(+), 2 deletions(-) diff --git a/src/compiler/ast.cr b/src/compiler/ast.cr index 63251a5e..a87a4ec8 100644 --- a/src/compiler/ast.cr +++ b/src/compiler/ast.cr @@ -257,6 +257,7 @@ EXPR_OPTIONAL : int = 49 // a=inner type node (T? 类型位置) // hole for diagnostics. EXPR_INTERP_HOLE : int = 50 // data=hole text ni(解析期);checker 改写后本 kind 不再存在 EXPR_BIGINT : int = 51 // a=canonical decimal string ni; type TY_INT +EXPR_TUPLEPAT : int = 52 // tuple pattern: a=first wrapper(连续;wrapper kind=EXPR_NONE,a=子模式节点,b=元素下标), b=element count // Field representation in struct literal: two consecutive AST nodes // (name_idx, value_idx, line=line, col=col) diff --git a/src/compiler/checker.cr b/src/compiler/checker.cr index c0bcacc4..8c86cf4b 100644 --- a/src/compiler/checker.cr +++ b/src/compiler/checker.cr @@ -601,7 +601,7 @@ fn loop_body_has_break(node: int) -> int: if k == EXPR_STRUCT || k == EXPR_STRUCTPAT || k == EXPR_ENUMPAT || k == EXPR_GENERIC_APPLY: // 连续子节点段:首 b、个数 c(wrapper.a = 值/子模式,经 EXPR_NONE 分支承接) return seg_has_break(ast_b(node), ast_c(node)) - if k == EXPR_ARRAY || k == EXPR_TUPLE: + if k == EXPR_ARRAY || k == EXPR_TUPLE || k == EXPR_TUPLEPAT: return seg_has_break(ast_a(node), ast_b(node)) if k == EXPR_NONE: // wrapper(a = 值节点)或基类型节点(a = 0):照 monomorph 的判据 @@ -1888,6 +1888,133 @@ fn bind_struct_pattern_idents(pat: int, scrut_ti: int): def_sym(nn, SYM_LOCAL, ft, -1) i = i + 1 +// ─── Tuple match pattern(Task 5b2)─── +// EXPR_TUPLEPAT layout: a = first wrapper, b = element count. wrapper.a = +// subpattern, wrapper.b = element index (0-based). The scrutinee must be a +// concrete TYP_TUPLE with the same arity. Each element is validated against the +// actual tuple element type (g_gen_apply_data at extra+i): scalar literals are +// compared strictly, ident/wildcard bind, and nested composite patterns are +// rejected with TM09 rather than silently treated as irrefutable. Returns 0 when +// the pattern is usable, 1 when a diagnostic was reported. +fn check_tuple_pattern(pat: int, scrut_ti: int) -> int: + if pat < 0: + return 1 + c := ast_b(pat) + w0 := ast_a(pat) + if c <= 0 || w0 < 0: + check_error(EC_P_PATTERN, "Malformed tuple pattern", + ast_line(pat), ast_col(pat)) + return 1 + if get_type_kind(scrut_ti) != TYP_TUPLE: + check_error(EC_TS_FIELD_TYPE, + "Tuple pattern requires a tuple scrutinee", + ast_line(pat), ast_col(pat)) + return 1 + arity := get_type_data(scrut_ti) + if c != arity: + check_error(EC_TA_TUPLE_ARITY, + "Tuple pattern has " + int_str(c) + " elements, but the scrutinee has " + int_str(arity), + ast_line(pat), ast_col(pat)) + return 1 + // A generic element has no concrete load form at codegen time. Keep the same + // conservative rule as struct patterns: require a fully concrete tuple. + if ti_has_generic_param(scrut_ti) != 0: + check_error(EC_TS_FIELD_TYPE, + "Tuple pattern requires a concrete tuple type; use a wildcard or binding arm", + ast_line(pat), ast_col(pat)) + return 1 + start := get_type_extra(scrut_ti) + bad : ., mut = 0 + i : ., mut = 0 + loop: + if i >= c: + return bad + w := w0 + i + sub : ., mut = -1 + idx : ., mut = -1 + if w >= 0: + sub = ast_a(w) + idx = ast_b(w) + if w < 0 || idx != i: + check_error(EC_P_PATTERN, "Malformed tuple pattern element index", + ast_line(pat), ast_col(pat)) + bad = 1 + i = i + 1 + continue + ft : ., mut = -1 + if start >= 0: + ft = cell_load(g_gen_apply_data, (start + i) * 8) + if ft >= 0: + // Cache the element type on the wrapper for IR lowering and monomorph + // clones. +1 keeps 0 as the "absent" sentinel because TI_INT is 0. + ast_set_data(w, ft + 1) + if sub < 0: + bad = 1 + i = i + 1 + continue + k := ast_kind(sub) + if k == EXPR_IDENT || k == EXPR_WILDCARD: + i = i + 1 + continue + if k == EXPR_INT || k == EXPR_BOOL || k == EXPR_CHAR || k == EXPR_STRING: + if ft < 0: + check_error(EC_TS_FIELD_TYPE, + "Tuple pattern element " + int_str(i) + " has no resolvable type", + ast_line(sub), ast_col(sub)) + bad = 1 + else if ti_has_generic_param(ft) != 0: + check_error(EC_TS_FIELD_TYPE, + "Tuple pattern element " + int_str(i) + " has an unresolved generic type; literal patterns are not supported", + ast_line(sub), ast_col(sub)) + bad = 1 + else if get_type_kind(ft) == TYP_OPTIONAL: + check_error(EC_TS_FIELD_TYPE, + "Tuple pattern element " + int_str(i) + " has an optional type; use a wildcard or binding arm", + ast_line(sub), ast_col(sub)) + bad = 1 + else: + st := infer_expr(sub) + verdict := type_compat_strict(st, ft) + if verdict != 1: + diag_type_incompatible(verdict, EC_TS_FIELD_TYPE, + "Tuple pattern element " + int_str(i) + ": expected " + type_display(ft) + ", got " + type_display(st), + ast_line(sub), ast_col(sub)) + bad = 1 + else: + check_error(EC_TM_NESTED, + "Nested match patterns are not supported yet", + ast_line(sub), ast_col(sub)) + bad = 1 + i = i + 1 + return bad + +// Bind the ident subpatterns of a validated tuple pattern. Each binding gets the +// actual tuple element type. `_` and literal subpatterns bind nothing. +fn bind_tuple_pattern_idents(pat: int, scrut_ti: int): + if pat < 0 || get_type_kind(scrut_ti) != TYP_TUPLE: + return + c := ast_b(pat) + w0 := ast_a(pat) + arity := get_type_data(scrut_ti) + start := get_type_extra(scrut_ti) + if w0 < 0: + return + i : ., mut = 0 + loop: + if i >= c: + break + w := w0 + i + if w >= 0: + sub := ast_a(w) + if sub >= 0 && ast_kind(sub) == EXPR_IDENT: + nn := ast_int_val(sub) + if str_eq(istr_get(nn), "_") == 0: + et : ., mut = TI_INT + if start >= 0 && i < arity: + et = cell_load(g_gen_apply_data, (start + i) * 8) + def_sym(nn, SYM_LOCAL, et, -1) + i = i + 1 + // 类型里是否仍含未实例化的泛型参数(TYP_GENERIC_PARAM,可嵌套在数组 / 指针 / 引用 / // 切片 / 元组 / 泛型应用内)。字面量的字段类型 vs 声明元素同质性判定在任一侧 // 含未实例化参数时跳过,参数未实例化时比较不成立,强行比较会假拒(泛型函数体 @@ -3708,6 +3835,13 @@ fn infer_expr(node: int) -> int: m_literal = 1 if check_struct_pattern(arm_pat, match_ti) != 0: m_unmappable = 1 + else if ast_kind(arm_pat) == EXPR_TUPLEPAT: + // Tuple patterns are refutable as well: arity/scrutinee/type + // errors are diagnosed by check_tuple_pattern, nested composites + // as TM09. An irrefutable arm is still required. + m_literal = 1 + if check_tuple_pattern(arm_pat, match_ti) != 0: + m_unmappable = 1 else: lit_k := sh_match_lit_kind(arm_pat) if lit_k >= 0: @@ -3745,6 +3879,10 @@ fn infer_expr(node: int) -> int: // Struct pattern field bindings: each ident subpattern gets the // declared field type (Task 5b1). bind_struct_pattern_idents(arm_pat, match_ti) + if ast_kind(arm_pat) == EXPR_TUPLEPAT: + // Tuple pattern element bindings: each ident subpattern gets the + // actual tuple element type (Task 5b2). + bind_tuple_pattern_idents(arm_pat, match_ti) if ast_kind(arm_pat) == EXPR_IDENT: // 绑定臂的值就是 scrutinee:类型取 scrutinee 类型(旧实现 // 一律 TI_INT,字符串/char/bool scrutinee 会错型)。 @@ -4258,6 +4396,11 @@ fn infer_expr(node: int) -> int: // Patterns are not value expressions. Match-arm validation/binding lives // in the EXPR_MATCH branch (check_struct_pattern / bind_struct_pattern_idents). return TI_UNIT + if ast_kind(node) == EXPR_TUPLEPAT: + // Tuple patterns are not value expressions either. Match-arm + // validation/binding lives in EXPR_MATCH (check_tuple_pattern / + // bind_tuple_pattern_idents). + return TI_UNIT if ast_kind(node) == EXPR_AS: // expr as Type — type cast inner_ti := infer_expr(ast_a(node)) diff --git a/src/compiler/ir_gen.cr b/src/compiler/ir_gen.cr index 2dabdb6a..bc9358fd 100644 --- a/src/compiler/ir_gen.cr +++ b/src/compiler/ir_gen.cr @@ -996,6 +996,38 @@ fn gen_structpat_binds(pat: int, match_val: int): bind_local(nn, fv) i = i + 1 +// Bind ident subpatterns for a tuple pattern in the arm scope. Each binding reads +// its element again and gets the checker-resolved element type stored on the +// wrapper. Literal and `_` subpatterns bind nothing. +fn gen_tuplepat_binds(pat: int, match_val: int): + if pat < 0 || match_val < 0: + return + c := ast_b(pat) + w0 := ast_a(pat) + if w0 < 0: + return + i : ., mut = 0 + loop: + if i >= c: + break + w := w0 + i + if w >= 0: + sub := ast_a(w) + if sub >= 0 && ast_kind(sub) == EXPR_IDENT: + nn := ast_int_val(sub) + if str_eq(istr_get(nn), "_") == 0: + fd := ast_data(w) + ft : ., mut = TI_INT + if fd > 0: + ft = fd - 1 + fv := new_ir_var("fld", TI_INT) + irv_set_type(fv, structpat_field_load_ti_from(ft)) + if get_type_kind(ft) == TYP_OPTIONAL && g_optrep_on != 0: + irv_set_decl_ti(fv, ft) + emit(IR_LOAD_FIELD, fv, match_val, 0, ast_b(w), 0) + bind_local(nn, fv) + i = i + 1 + // Return the literal initializer AST node for an immutable module-level // constant. The ELF backend has no data initializers, so leaving these as // BSS globals turns values such as T_FN and ESZ_TOKEN into zero at runtime. @@ -3144,6 +3176,57 @@ fn gen_expr(node: int) -> int: emit(IR_JUMP, -1, body_lbl, 0, 0, 0) else: emit(IR_JUMP, -1, fall_lbl, 0, 0, 0) + else if pat_kind == EXPR_TUPLEPAT: + // Tuple pattern: sequential element tests. The checker has already + // validated the scrutinee arity and cached each element type on the + // wrapper. A missing wrapper/type or an unsupported subpattern fails + // closed: jump to the fall edge, never to the body. + tc := ast_b(arm_pat) + tw0 := ast_a(arm_pat) + can_match : ., mut = 1 + if tw0 < 0 || tc <= 0: + can_match = 0 + if has_next == 1: + fall_lbl = new_label() + ti_i : ., mut = 0 + loop: + if ti_i >= tc: + break + w := tw0 + ti_i + sub : ., mut = -1 + idx : ., mut = -1 + fd : ., mut = 0 + if w >= 0: + sub = ast_a(w) + idx = ast_b(w) + fd = ast_data(w) + if w < 0 || idx != ti_i || fd <= 0: + can_match = 0 + break + sk : ., mut = -1 + if sub >= 0: + sk = ast_kind(sub) + if sk == EXPR_IDENT || sk == EXPR_WILDCARD: + ti_i = ti_i + 1 + continue + if sk != EXPR_INT && sk != EXPR_BOOL && sk != EXPR_CHAR && sk != EXPR_STRING: + can_match = 0 + break + ft := fd - 1 + if ti_has_generic_param(ft) != 0: + can_match = 0 + break + fv := new_ir_var("fld", TI_INT) + irv_set_type(fv, structpat_field_load_ti_from(ft)) + emit(IR_LOAD_FIELD, fv, match_val, 0, idx, 0) + if gen_structpat_field_test(sub, fv, fall_lbl) < 0: + can_match = 0 + break + ti_i = ti_i + 1 + if can_match != 0: + emit(IR_JUMP, -1, body_lbl, 0, 0, 0) + else: + emit(IR_JUMP, -1, fall_lbl, 0, 0, 0) else if pat_kind == EXPR_INT: pat_val := new_ir_var("pval", TI_INT) emit(IR_CONST, pat_val, ast_int_val(arm_pat), 0, 0, TI_INT) @@ -3207,6 +3290,8 @@ fn gen_expr(node: int) -> int: push_ir_scope() if pat_kind == EXPR_STRUCTPAT: gen_structpat_binds(arm_pat, match_val) + if pat_kind == EXPR_TUPLEPAT: + gen_tuplepat_binds(arm_pat, match_val) if pat_kind == EXPR_ENUMPAT: sub_count := ast_c(arm_pat) fi : ., mut = 0 @@ -3741,6 +3826,8 @@ fn gen_expr(node: int) -> int: return inner if ast_kind(node) == EXPR_STRUCTPAT: return -1 + if ast_kind(node) == EXPR_TUPLEPAT: + return -1 if ast_kind(node) == EXPR_STMT: gen_expr(ast_a(node)) return -1 diff --git a/src/compiler/monomorph.cr b/src/compiler/monomorph.cr index 5b09db50..89577b38 100644 --- a/src/compiler/monomorph.cr +++ b/src/compiler/monomorph.cr @@ -1050,6 +1050,44 @@ fn gen_clone_tree(node: int) -> int: gen_dedup_add(node, n) return n + // EXPR_TUPLEPAT: a = first wrapper (consecutive), b = element count. + // wrapper.a = subpattern node, wrapper.b = element index, wrapper.d = + // checker-resolved element type + 1. Preserve both halves of the wrapper so a + // clone still compares/binds the same positions with the same type. + if k == EXPR_TUPLEPAT: + if a >= 0 && b > 0: + nvs : string, mut = alloc(b * 8) + nns : string, mut = alloc(b * 8) + nds : string, mut = alloc(b * 8) + i : ., mut = 0 + loop: + if i >= b: + break + vn : ., mut = -1 + nn : ., mut = -1 + nd : ., mut = 0 + if a + i >= 0: + vn = ast_a(a + i) + nn = ast_b(a + i) + nd = ast_data(a + i) + cell_store(nvs, i * 8, gen_clone_tree(vn)) + cell_store(nns, i * 8, nn) + cell_store(nds, i * 8, nd) + i = i + 1 + new_first := g_ast_count + i = 0 + loop: + if i >= b: + break + ast_alloc(EXPR_NONE, cell_load(nvs, i * 8), cell_load(nns, i * 8), 0, 0, 0, cell_load(nds, i * 8), ln, cl) + i = i + 1 + n := ast_alloc(k, new_first, b, c, iv, tv, d, ln, cl) + gen_dedup_add(node, n) + return n + n := ast_alloc(k, a, b, c, iv, tv, d, ln, cl) + gen_dedup_add(node, n) + return n + // ── EXPR_AT: a=name_ni(NOT), b=args_node(YES or -1) ── if k == EXPR_AT: b2 := gen_clone_tree(b) diff --git a/src/compiler/parser.cr b/src/compiler/parser.cr index d7f88b4e..2468f50a 100644 --- a/src/compiler/parser.cr +++ b/src/compiler/parser.cr @@ -1424,6 +1424,63 @@ fn parse_pattern() -> int: advance_tok() tl := cell_load(g_tokens, t * ESZ_TOKEN + OFF_TK_LEXEME) return alloc_node(EXPR_BIGINT, tl, 0, 0, 0, TY_INT, 0, tok_ln(t), tok_cl(t)) + if tok_k(t) == T_LPAREN: + // Tuple pattern: (pat, pat, ...). EXPR_TUPLEPAT layout: a = first wrapper + // (consecutive), b = element count. wrapper.a = subpattern, wrapper.b = + // element index (0-based). The contract is the same as EXPR_STRUCTPAT, + // except the second wrapper slot is positional instead of a field name. + // Composite subpatterns occupy several nodes, so parse all subpatterns + // first, then build contiguous wrappers. `(pat)` is grouping, exactly like + // a parenthesized expression. Empty `()` and trailing commas are rejected + // with a parser diagnostic instead of being silently accepted. + advance_tok() + if check(T_RPAREN): + check_error(EC_P_PATTERN, + "Empty tuple pattern is not supported; use '_'", + tok_ln(cur_tok()), tok_cl(cur_tok())) + advance_tok() + return alloc_node(EXPR_TUPLEPAT, -1, 0, 0, 0, 0, 0, tok_ln(t), tok_cl(t)) + cap : ., mut = 8 + vals : string, mut = alloc(cap * 8) + cell_store(vals, 0, parse_pattern()) + pc : ., mut = 1 + loop: + if !check(T_COMMA): + break + advance_tok() // consume comma + if check(T_RPAREN): + check_error(EC_P_PATTERN, + "Trailing comma is not supported in a tuple pattern", + tok_ln(cur_tok()), tok_cl(cur_tok())) + break + if pc >= cap: + ncap := cap * 2 + nv := alloc(ncap * 8) + cell_copy(vals, cap * 8, nv) + vals = nv + cap = ncap + cell_store(vals, pc * 8, parse_pattern()) + pc = pc + 1 + advance_tok() // consume ) + if pc == 1: + // Parenthesized pattern: transparent grouping, same as `(expr)`. + return cell_load(vals, 0) + pf : ., mut = -1 + pi : ., mut = 0 + loop: + if pi >= pc: + break + vn := cell_load(vals, pi * 8) + ln : ., mut = tok_ln(t) + cl : ., mut = tok_cl(t) + if vn >= 0: + ln = ast_line(vn) + cl = ast_col(vn) + wl := ast_alloc(EXPR_NONE, vn, pi, 0, 0, 0, 0, ln, cl) + if pi == 0: + pf = wl + pi = pi + 1 + return alloc_node(EXPR_TUPLEPAT, pf, pc, 0, 0, 0, 0, tok_ln(t), tok_cl(t)) if tok_k(t) == T_STRING || tok_k(t) == T_INTERP: return parse_interp_run() if tok_k(t) == T_CHAR: diff --git a/src/compiler/ty_shadow.cr b/src/compiler/ty_shadow.cr index 9a8f1406..3f70f8f9 100644 --- a/src/compiler/ty_shadow.cr +++ b/src/compiler/ty_shadow.cr @@ -1214,7 +1214,7 @@ fn sh_match_pat_kind(pat_node: int) -> int: // 绑定 = 通配(值域不绑;与 ir_gen 的按值比较臂不同层) if k == EXPR_ENUMPAT: return 2 - return 0 // 字面量 / struct 模式 / EXPR_NONE -> 不可映射(登记) + return 0 // 字面量 / struct 模式 / tuple 模式 / EXPR_NONE -> 不可映射(登记) // 字面量模式种类(int/bool/string/char),-1 = 非字面量模式。 // 这些模式在 checker 侧可逐值比较,但域是无限的(bool 除外),因此没有 @@ -1263,6 +1263,17 @@ fn sh_match_pat_subtree_start(p: int) -> int: s := sh_match_pat_subtree_start(c) if s >= 0 && s < p: return s + else if k == EXPR_TUPLEPAT: + // Tuple patterns use the array/tuple wrapper contract: a=first wrapper, + // b=element count, wrapper.a=subpattern. + if ast_b(p) > 0: + w := ast_a(p) + if w >= 0 && w < p: + c := ast_a(w) + if c >= 0 && c < p: + s := sh_match_pat_subtree_start(c) + if s >= 0 && s < p: + return s return p // Enum payload 内的首个 refutable 节点(-1 = 全部为 ident/wildcard)。 diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index 51fe02ba..8d54b28b 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -1219,6 +1219,115 @@ def test_duplicate_value_cases_are_allowed_by_the_reader(): _ => 0 """ +# Task 5b2: tuple patterns. Positional wrappers bind elements and test scalar +# literals; `_` skips an element. The literal string is built at runtime inside +# main so the comparison must use str_eq (content), not intern indices. +MATCH_TUPLE_SOURCE = """fn bind(n: int, s: string) -> int: + t := (n, s) + return match t: + (x, y) =>: + if x == 1: + if y == "hi": + return 1 + return 2 + _ => 3 + +fn lit(n: int, s: string) -> int: + t := (n, s) + return match t: + (1, "hi") => 1, + (2, "yo") => 2, + _ => 0 + +fn part(n: int, s: string) -> int: + t := (n, s) + return match t: + (a, _) => a, + _ => 9 + +fn mixed(flag: bool, ch: char, n: int) -> int: + t := (flag, ch, n) + return match t: + (true, 'A', 7) => 1, + (false, 'B', 8) => 2, + _ => 0 + +fn ident_last(n: int) -> int: + t := (n, n + 1) + return match t: + (1, 2) => 1, + rest =>: + if (rest . 0) == n: + return 2 + return 3 + +fn main() -> int: + if bind(1, "hi") != 1: + return 1 + if bind(2, "hi") != 2: + return 2 + if lit(1, concat("h", "i")) != 1: + return 3 + if lit(2, "yo") != 2: + return 4 + if lit(1, "yo") != 0: + return 5 + if part(7, "x") != 7: + return 6 + if mixed(true, 'A', 7) != 1: + return 7 + if mixed(false, 'B', 8) != 2: + return 8 + if mixed(false, 'A', 7) != 0: + return 9 + if ident_last(5) != 2: + return 10 + if ident_last(1) != 1: + return 11 + return 42 +""" + +MATCH_TUPLE_ARITY_SOURCE = """fn main() -> int: + t := (1, 2) + return match t: + (a, b, c) => 1, + _ => 0 +""" + +MATCH_TUPLE_NONTUPLE_SOURCE = """fn main() -> int: + n := 1 + return match n: + (a, b) => 1, + _ => 0 +""" + +MATCH_TUPLE_WRONG_TYPE_SOURCE = """fn main() -> int: + t := (1, 2) + return match t: + (a, "s") => 1, + _ => 0 +""" + +MATCH_TUPLE_NESTED_SOURCE = """fn main() -> int: + t := ((1, 2), 3) + return match t: + ((a, b), c) => 1, + _ => 0 +""" + +MATCH_TUPLE_NO_WILDCARD_SOURCE = """fn main() -> int: + t := (1, 2) + return match t: + (1, 2) => 1 +""" + +MATCH_TUPLE_WILDCARD_ORDER_SOURCE = """fn main() -> int: + t := (1, 2) + return match t: + _ => 0, + (1, 2) => 1 +""" + MATCH_ONE_CASE_SOURCE = """fn pick(n: int) -> int: return match n: 7 => 1, @@ -2021,6 +2130,79 @@ def test_struct_pattern_exhaustiveness_and_order(): ) +def test_tuple_pattern_binds_literals_and_partial(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, MATCH_TUPLE_SOURCE, "match_tuple") + assert _match_nodes(data) == [], "tuple patterns must not become MATCH" + assert segment(data, CASE_TAG) == struct.pack(" Date: Sat, 24 Oct 2026 09:46:42 +0800 Subject: [PATCH 16/22] ccr: reject malformed tuple patterns in parser --- src/compiler/parser.cr | 103 +++++++++++++---- tests/selfhost/test_multibranch_v10.py | 149 +++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 19 deletions(-) diff --git a/src/compiler/parser.cr b/src/compiler/parser.cr index 2468f50a..68666cce 100644 --- a/src/compiler/parser.cr +++ b/src/compiler/parser.cr @@ -1412,6 +1412,27 @@ fn parse_match_expr() -> int: g_want_value = want return alloc_node(EXPR_MATCH, expr, af, ac, 0, 0, 0, tok_ln(t), tok_cl(t)) +// Skip the remainder of a malformed tuple pattern through its matching ')'. +// The caller has already emitted P005; recovery only prevents cascading +// diagnostics from the tokens the malformed element left behind. +fn tuplepat_recover(): + depth : ., mut = 0 + loop: + k := tok_k(cur_tok()) + if k == T_EOF: + break + if k == T_LPAREN: + depth = depth + 1 + advance_tok() + continue + if k == T_RPAREN: + advance_tok() + if depth == 0: + break + depth = depth - 1 + continue + advance_tok() + fn parse_pattern() -> int: t := cur_tok() if tok_k(t) == T_UNDERSCORE: @@ -1431,37 +1452,81 @@ fn parse_pattern() -> int: // except the second wrapper slot is positional instead of a field name. // Composite subpatterns occupy several nodes, so parse all subpatterns // first, then build contiguous wrappers. `(pat)` is grouping, exactly like - // a parenthesized expression. Empty `()` and trailing commas are rejected - // with a parser diagnostic instead of being silently accepted. + // a parenthesized expression. Empty `()`, leading commas, and trailing + // commas are hard parser diagnostics. An element that parse_pattern could + // not parse (its fallback returns 0) is diagnosed here and never stored: + // a node 0 must not reach the checker or IR as a pattern. advance_tok() if check(T_RPAREN): check_error(EC_P_PATTERN, "Empty tuple pattern is not supported; use '_'", tok_ln(cur_tok()), tok_cl(cur_tok())) advance_tok() - return alloc_node(EXPR_TUPLEPAT, -1, 0, 0, 0, 0, 0, tok_ln(t), tok_cl(t)) + return alloc_node(EXPR_WILDCARD, 0, 0, 0, 0, 0, 0, tok_ln(t), tok_cl(t)) + if check(T_COMMA): + check_error(EC_P_PATTERN, "Unexpected token in pattern", + tok_ln(cur_tok()), tok_cl(cur_tok())) + tuplepat_recover() + return alloc_node(EXPR_WILDCARD, 0, 0, 0, 0, 0, 0, tok_ln(t), tok_cl(t)) cap : ., mut = 8 vals : string, mut = alloc(cap * 8) - cell_store(vals, 0, parse_pattern()) + et := cur_tok() + e0 := parse_pattern() + if e0 <= 0: + check_error(EC_P_PATTERN, "Unexpected token in pattern", + tok_ln(et), tok_cl(et)) + tuplepat_recover() + return alloc_node(EXPR_WILDCARD, 0, 0, 0, 0, 0, 0, tok_ln(t), tok_cl(t)) + cell_store(vals, 0, e0) pc : ., mut = 1 loop: - if !check(T_COMMA): - break - advance_tok() // consume comma + if check(T_COMMA): + advance_tok() // consume comma + if check(T_RPAREN): + check_error(EC_P_PATTERN, + "Trailing comma is not supported in a tuple pattern", + tok_ln(cur_tok()), tok_cl(cur_tok())) + advance_tok() + return alloc_node(EXPR_WILDCARD, 0, 0, 0, 0, 0, 0, tok_ln(t), tok_cl(t)) + if check(T_COMMA): + check_error(EC_P_PATTERN, "Unexpected token in pattern", + tok_ln(cur_tok()), tok_cl(cur_tok())) + tuplepat_recover() + return alloc_node(EXPR_WILDCARD, 0, 0, 0, 0, 0, 0, tok_ln(t), tok_cl(t)) + if check(T_EOF): + check_error(EC_P_BRACKET, + "Missing closing ')' in tuple pattern", + tok_ln(cur_tok()), tok_cl(cur_tok())) + return alloc_node(EXPR_WILDCARD, 0, 0, 0, 0, 0, 0, tok_ln(t), tok_cl(t)) + et2 := cur_tok() + en := parse_pattern() + if en <= 0: + check_error(EC_P_PATTERN, "Unexpected token in pattern", + tok_ln(et2), tok_cl(et2)) + tuplepat_recover() + return alloc_node(EXPR_WILDCARD, 0, 0, 0, 0, 0, 0, tok_ln(t), tok_cl(t)) + if pc >= cap: + ncap := cap * 2 + nv := alloc(ncap * 8) + cell_copy(vals, cap * 8, nv) + vals = nv + cap = ncap + cell_store(vals, pc * 8, en) + pc = pc + 1 + continue if check(T_RPAREN): - check_error(EC_P_PATTERN, - "Trailing comma is not supported in a tuple pattern", - tok_ln(cur_tok()), tok_cl(cur_tok())) + advance_tok() break - if pc >= cap: - ncap := cap * 2 - nv := alloc(ncap * 8) - cell_copy(vals, cap * 8, nv) - vals = nv - cap = ncap - cell_store(vals, pc * 8, parse_pattern()) - pc = pc + 1 - advance_tok() // consume ) + if check(T_EOF): + check_error(EC_P_BRACKET, + "Missing closing ')' in tuple pattern", + tok_ln(cur_tok()), tok_cl(cur_tok())) + return alloc_node(EXPR_WILDCARD, 0, 0, 0, 0, 0, 0, tok_ln(t), tok_cl(t)) + // Missing separator between elements. + check_error(EC_P_PATTERN, "Unexpected token in pattern", + tok_ln(cur_tok()), tok_cl(cur_tok())) + tuplepat_recover() + return alloc_node(EXPR_WILDCARD, 0, 0, 0, 0, 0, 0, tok_ln(t), tok_cl(t)) if pc == 1: // Parenthesized pattern: transparent grouping, same as `(expr)`. return cell_load(vals, 0) diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index 8d54b28b..b7a9478f 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -1328,6 +1328,83 @@ def test_duplicate_value_cases_are_allowed_by_the_reader(): (1, 2) => 1 """ +MATCH_TUPLE_GROUPING_SOURCE = """fn main() -> int: + n := 7 + r := match n: + (a) => a + if r != 7: + return 1 + return 42 +""" + +MATCH_TUPLE_BAD_LEADING_COMMA_SOURCE = """fn main() -> int: + n := 1 + return match n: + (,) => 1, + _ => 0 +""" + +MATCH_TUPLE_BAD_PLUS_SOURCE = """fn main() -> int: + n := 1 + return match n: + (+) => 1, + _ => 0 +""" + +MATCH_TUPLE_BAD_DOUBLE_COMMA_SOURCE = """fn main() -> int: + t := (1, 2) + return match t: + (a, ,) => 1, + _ => 0 +""" + +MATCH_TUPLE_BAD_EMPTY_SOURCE = """fn main() -> int: + n := 1 + return match n: + () => 1, + _ => 0 +""" + +MATCH_TUPLE_BAD_TRAILING_SOURCE = """fn main() -> int: + n := 1 + return match n: + (a,) => 1, + _ => 0 +""" + +MATCH_TUPLE_GENERIC_SOURCE = """fn pick[T](x: T) -> int: + t := (x, 1) + return match t: + (a, b) => 1, + _ => 0 + +fn main() -> int: + return pick(9) +""" + +MATCH_TUPLE_ENUM_PAYLOAD_SOURCE = """fn main() -> int: + v := Some((1, 2)) + return match v: + Some((a, b)) => 1, + _ => 0 +""" + +MATCH_TUPLE_GENERIC_MONOMORPH_SOURCE = """fn pick[T](x: T) -> int: + t := (1, "hi") + return match t: + (n, s) =>: + if n == 1: + if s == "hi": + return 1 + return 0 + _ => 2 + +fn main() -> int: + if pick(9) != 1: + return 1 + return 42 +""" + MATCH_ONE_CASE_SOURCE = """fn pick(n: int) -> int: return match n: 7 => 1, @@ -2187,6 +2264,78 @@ def test_tuple_pattern_exhaustiveness_and_order(): ) +def test_tuple_pattern_parenthesized_binding_is_transparent(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr(tmp, MATCH_TUPLE_GROUPING_SOURCE, "match_tuple_group") + assert _match_nodes(data) == [], "grouped binding must not become MATCH" + _assert_program( + tmp, + MATCH_TUPLE_GROUPING_SOURCE, + 42, + opts=(0, 2), + name="match_tuple_group", + ) + + +def test_tuple_pattern_malformed_is_hard_p005(): + cases = [ + ("tuple_leading_comma", MATCH_TUPLE_BAD_LEADING_COMMA_SOURCE), + ("tuple_plus", MATCH_TUPLE_BAD_PLUS_SOURCE), + ("tuple_double_comma", MATCH_TUPLE_BAD_DOUBLE_COMMA_SOURCE), + ("tuple_empty", MATCH_TUPLE_BAD_EMPTY_SOURCE), + ("tuple_trailing", MATCH_TUPLE_BAD_TRAILING_SOURCE), + ] + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + for name, source in cases: + needles = ["error[P05]"] + if name in ("tuple_leading_comma", "tuple_plus", "tuple_double_comma"): + needles.append("Unexpected token in pattern") + _assert_compile_rejected(tmp, source, name, needles) + + +def test_tuple_pattern_generic_tuple_rejected(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + needles = ["error[TS03]", "requires a concrete tuple type"] + _assert_compile_rejected( + tmp, MATCH_TUPLE_GENERIC_SOURCE, "tuple_generic", needles + ) + run = _run([COREC, "run", MATCH_TUPLE_GENERIC_SOURCE], cwd=BASE) + text = run.stdout + run.stderr + assert run.returncode == 1, ( + f"interpreter accepted a generic tuple pattern: rc={run.returncode}: {text}" + ) + for needle in needles: + assert needle in text, f"interpreter missing {needle!r}: {text}" + + +def test_tuple_pattern_enum_payload_rejected_tm09(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + needles = ["error[TM09]", "Nested match patterns are not supported"] + _assert_compile_rejected( + tmp, MATCH_TUPLE_ENUM_PAYLOAD_SOURCE, "tuple_enum_payload", needles + ) + + +def test_tuple_pattern_generic_monomorph_clone(): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + data = _build_ccr( + tmp, MATCH_TUPLE_GENERIC_MONOMORPH_SOURCE, "tuple_generic_monomorph" + ) + assert _match_nodes(data) == [], "tuple patterns must not become MATCH" + _assert_program( + tmp, + MATCH_TUPLE_GENERIC_MONOMORPH_SOURCE, + 42, + opts=(0, 2), + name="tuple_generic_monomorph", + ) + + def test_nested_tuple_pattern_rejected_by_interpreter(): with tempfile.TemporaryDirectory() as td: tmp = Path(td) From b92c68fc0a3345920b5f276bc7dd30bd457c1f16 Mon Sep 17 00:00:00 2001 From: DslsDZC Date: Sat, 24 Oct 2026 10:02:41 +0800 Subject: [PATCH 17/22] cache: persist v10 match case ranges --- src/compiler/cir_cache.cr | 216 +++++++++++- src/compiler/globals.cr | 4 +- src/compiler/main.cr | 6 +- tests/selfhost/test_ccr_types.py | 8 +- tests/selfhost/test_multibranch_v10.py | 446 ++++++++++++++++++++++++- 5 files changed, 656 insertions(+), 24 deletions(-) diff --git a/src/compiler/cir_cache.cr b/src/compiler/cir_cache.cr index 517421da..e9b382ca 100644 --- a/src/compiler/cir_cache.cr +++ b/src/compiler/cir_cache.cr @@ -16,6 +16,10 @@ // {kind, enter_rel, exit_rel, parent_rel, nstart_rel, ncount} // edges: count, then 24-byte records {rel_from, rel_to, kind} // instructions: count, then 48-byte records {op, dest, s1, s2, s3, tk} +// match cases: match count, then per IR_MATCH in instruction order: +// {case_count, then case_count x 24-byte records +// value i64, kind u32, str i32, target_rel i32, flags u32} +// target_rel = target_nod - node_start, or -1 for a negative target // string constants: count, then {length, bytes} // intern journal: base count, record count, then {length, bytes} // trailer: var_start, node_start @@ -53,6 +57,9 @@ // NOD type ids) does not change this snapshot layout or its derived-code // contract. The stored records, journal, and trailer are unchanged, so no // version bump is required for that migration; only `.ccr` bytes changed. +// v24 adds the per-function MATCH CASE section after the instruction range. +// The layout changed, so entries written before v24 are invalid even though +// the rest of the snapshot is unchanged. // // Magic value: // The magic is a negative 64-bit constant written as signed decimal. The @@ -60,7 +67,7 @@ // writer and reader use the same constant, so the cache remains self-consistent. // The limitation is recorded in the project backlog. CIR_CACHE_MAGIC : int = -4485090715960753727 -CIR_CACHE_VER : int = 23 +CIR_CACHE_VER : int = 24 g_cir_write_buf : string, mut g_cir_write_pos : int, mut @@ -195,10 +202,6 @@ fn save_cir_cache(path: string, source_fi: int, ir_fi: int) -> int: if cid == 0: return -1 - fd := syscall3(2, path, 577, 420) // O_WRONLY|O_CREAT|O_TRUNC, 0644 - if fd < 0: - return -1 - // Compute fingerprint fn_node := fi_ast_node(source_fi) fp := func_fingerprint(fn_node) @@ -214,6 +217,32 @@ fn save_cir_cache(path: string, source_fi: int, ir_fi: int) -> int: instr_start := cell_load(g_ir_func_instr_start, ir_fi * 8) instr_count := cell_load(g_ir_func_instr_count, ir_fi * 8) + // Validate and size the MATCH CASE section before touching the file. Every + // IR_MATCH owns a contiguous range of g_ccr_case rows; nested matches append + // after their enclosing range, which is also instruction order. A mismatch + // would produce a snapshot the loader must reject, so refuse to write it. + match_count : ., mut = 0 + match_size : ., mut = 8 + expected_case : ., mut = -1 + ms_i : ., mut = 0 + loop: + if ms_i >= instr_count: + break + ms_inst := instr_start + ms_i + if iri_op(ms_inst) == IR_MATCH: + ms_start := iri_s2(ms_inst) + ms_n := iri_s3(ms_inst) + if ms_start < 0 || ms_n < 0: + return -1 + if ms_start + ms_n > g_ccr_case_count: + return -1 + if expected_case >= 0 && ms_start != expected_case: + return -1 + expected_case = ms_start + ms_n + match_count = match_count + 1 + match_size = match_size + 8 + ms_n * 24 + ms_i = ms_i + 1 + // Serialize in memory and issue one write. The previous per-field writes // made a full self-host build perform millions of syscalls on its cache. total_size : ., mut = 48 + name_len // v17 头部 = magic/ver/identity/fp/sig/name_len @@ -232,6 +261,9 @@ fn save_cir_cache(path: string, source_fi: int, ir_fi: int) -> int: own_edge_count = 0 total_size = total_size + 8 + own_edge_count * 24 total_size = total_size + 8 + instr_count * 48 + // Per-function MATCH CASE section. Always present, even when empty, so the + // reader can parse every v24 snapshot with one layout. + total_size = total_size + match_size total_size = total_size + 8 size_si : ., mut = 0 loop: @@ -263,6 +295,10 @@ fn save_cir_cache(path: string, source_fi: int, ir_fi: int) -> int: g_cir_write_cap = new_cap g_cir_write_pos = 0 + fd := syscall3(2, path, 577, 420) // O_WRONLY|O_CREAT|O_TRUNC, 0644 + if fd < 0: + return -1 + // Write header cir_store_cell(fd, CIR_CACHE_MAGIC) cir_store_cell(fd, CIR_CACHE_VER) @@ -354,6 +390,35 @@ fn save_cir_cache(path: string, source_fi: int, ir_fi: int) -> int: cir_store_cell(fd, iri_tk(inst)) ii = ii + 1 + // Write the MATCH CASE section. Matches are stored in instruction order so + // the loader can pair each group with its IR_MATCH without storing an index. + cir_store_cell(fd, match_count) + cs_i : ., mut = 0 + loop: + if cs_i >= instr_count: + break + cs_inst := instr_start + cs_i + if iri_op(cs_inst) == IR_MATCH: + cs_start := iri_s2(cs_inst) + cs_n := iri_s3(cs_inst) + cir_store_cell(fd, cs_n) + cj : ., mut = 0 + loop: + if cj >= cs_n: + break + cbase := (cs_start + cj) * 24 + cir_store_cell(fd, cell_load(g_ccr_case, cbase + CCR_CASE_VALUE)) + cir_store_cell32(fd, cell_load32(g_ccr_case, cbase + CCR_CASE_KIND)) + cir_store_cell32(fd, cell_load_i32(g_ccr_case, cbase + CCR_CASE_STR)) + ctgt := cell_load_i32(g_ccr_case, cbase + CCR_CASE_TARGET) + crel : ., mut = -1 + if ctgt >= 0: + crel = ctgt - node_start + cir_store_cell32(fd, crel) + cir_store_cell32(fd, cell_load32(g_ccr_case, cbase + CCR_CASE_FLAGS)) + cj = cj + 1 + cs_i = cs_i + 1 + // Write string constants used by this function cir_store_cell(fd, g_ir_str_const_count) si : ., mut = 0 @@ -411,6 +476,12 @@ fn cir_store_cell(fd: int, val: int): cell_store(g_cir_write_buf, g_cir_write_pos, val) g_cir_write_pos = g_cir_write_pos + 8 +// CASE records pack 32-bit fields after the 64-bit value. Keep them at their +// wire widths; widening them to 8 bytes would break the shared 24B row. +fn cir_store_cell32(fd: int, val: int): + cell_store32(g_cir_write_buf, g_cir_write_pos, val) + g_cir_write_pos = g_cir_write_pos + 4 + fn cir_store_byte(fd: int, val: int): cell_store_byte(g_cir_write_buf, g_cir_write_pos, val) g_cir_write_pos = g_cir_write_pos + 1 @@ -567,16 +638,78 @@ fn load_cir_cache(path: string, func_idx: int) -> int: p = p + 8 + p_instr_count * 48 if p > dlen: return -1 - // A snapshot stores instructions but not the CASE table. A function with - // IR_MATCH cannot be restored without its CASE range, so treat it as a miss - // and rebuild. (Task 5 may persist the range instead.) + // Count IR_MATCH instructions. The CASE section stores one group per MATCH + // in instruction order, so this count must match the section header exactly. + p_match_count : ., mut = 0 p_mi : ., mut = 0 loop: if p_mi >= p_instr_count: break if cell_load(data, instr_p + 8 + p_mi * 48) == IR_MATCH: + p_match_count = p_match_count + 1 + p_mi = p_mi + 1 + + // MATCH CASE section: match count, then per MATCH {case_count, rows}. A row + // stores target_rel = target_nod - node_start. Validate every row against + // this function's NOD range before any global is written. + case_p := p + p_case_matches := cell_load(data, p) + p = p + 8 + if p_case_matches != p_match_count: + return -1 + p_ci : ., mut = 0 + p_mi = 0 + p_expected_case : ., mut = -1 + loop: + if p_ci >= p_case_matches: + break + // Pair this group with the next IR_MATCH in instruction order. + loop: + if p_mi >= p_instr_count: + return -1 + if cell_load(data, instr_p + 8 + p_mi * 48) == IR_MATCH: + break + p_mi = p_mi + 1 + p_inst := instr_p + 8 + p_mi * 48 + p_ms := cell_load(data, p_inst + 24) + p_mn := cell_load(data, p_inst + 32) + p_cn := cell_load(data, p) + p = p + 8 + if p_cn != p_mn || p_cn < 0 || p_ms < 0: + return -1 + // Matches in one function own contiguous ranges in increasing order. + if p_expected_case >= 0 && p_ms != p_expected_case: return -1 + p_expected_case = p_ms + p_cn + if p_cn > (dlen - p) / 24: + return -1 + p_rj : ., mut = 0 + loop: + if p_rj >= p_cn: + break + p_kind := cell_load32(data, p + 8) + p_flags := cell_load32(data, p + 20) + if p_kind < CCR_CASE_KIND_VALUE || p_kind > CCR_CASE_KIND_DEFAULT: + return -1 + if p_flags != 0: + return -1 + p_tr := cell_load_i32(data, p + 16) + if p_tr < 0 || p_tr >= p_node_count: + return -1 + if cell_load(data, node_p + 8 + p_tr * 64) != IR_LABEL: + return -1 + p = p + 24 + p_rj = p_rj + 1 p_mi = p_mi + 1 + p_ci = p_ci + 1 + // No unmatched IR_MATCH may remain after the stored groups. + loop: + if p_mi >= p_instr_count: + break + if cell_load(data, instr_p + 8 + p_mi * 48) == IR_MATCH: + return -1 + p_mi = p_mi + 1 + str_p := p p_str_count := cell_load(data, p) p = p + 8 @@ -702,6 +835,31 @@ fn load_cir_cache(path: string, func_idx: int) -> int: str_intern(js) ji = ji + 1 + // CASE str indexes are intern ids, so they are meaningful only after the + // journal replay above. Range-check them before the destructive array restore; + // do not call istr_get here because a malformed id must stay a cache miss. + if p_case_matches > 0: + csp : ., mut = case_p + 8 + csi : ., mut = 0 + loop: + if csi >= p_case_matches: + break + csn := cell_load(data, csp) + csp = csp + 8 + csr : ., mut = 0 + loop: + if csr >= csn: + break + csidx := cell_load_i32(data, csp + 12) + cskind := cell_load32(data, csp + 8) + if csidx < -1 || csidx >= g_str_count: + return -1 + if cskind == CCR_CASE_KIND_STRING && csidx < 0: + return -1 + csp = csp + 24 + csr = csr + 1 + csi = csi + 1 + // Restore vars var_count := cell_load(data, pos) pos = pos + 8 @@ -875,6 +1033,48 @@ fn load_cir_cache(path: string, func_idx: int) -> int: ii = ii + 1 g_ir_instr_count = base_instr + instr_count + // Restore this function's MATCH CASE ranges. The section is in the same order + // as the restored instruction stream, so pair each group with the next + // IR_MATCH and append at the current global CASE count. Stored targets are + // relative to the writer's node_start; rebase them to the restored nodes and + // rewrite s2 to the new range start. s3 is the stored count and is unchanged. + // The pre-scan already validated counts, ordering, target labels, and row + // domains, so this phase cannot fail halfway. + restored_matches := cell_load(data, pos) + pos = pos + 8 + rm_i : ., mut = 0 + rm_scan : ., mut = base_instr + loop: + if rm_i >= restored_matches: + break + loop: + if rm_scan >= g_ir_instr_count: + return -1 + if iri_op(rm_scan) == IR_MATCH: + break + rm_scan = rm_scan + 1 + rm_count := cell_load(data, pos) + pos = pos + 8 + rm_case_start := g_ccr_case_count + iri_set_s2(rm_scan, rm_case_start) + rm_j : ., mut = 0 + loop: + if rm_j >= rm_count: + break + rm_value := cell_load(data, pos) + rm_kind := cell_load32(data, pos + 8) + rm_str := cell_load_i32(data, pos + 12) + rm_rel := cell_load_i32(data, pos + 16) + rm_flags := cell_load32(data, pos + 20) + pos = pos + 24 + rm_target : ., mut = -1 + if rm_rel >= 0: + rm_target = base_node + rm_rel + ccr_case_append(rm_value, rm_kind, rm_str, rm_target, rm_flags) + rm_j = rm_j + 1 + rm_scan = rm_scan + 1 + rm_i = rm_i + 1 + // Restore string constants (used by nodes/instrs) str_count := cell_load(data, pos) pos = pos + 8 diff --git a/src/compiler/globals.cr b/src/compiler/globals.cr index eed8d0dd..2f095b06 100644 --- a/src/compiler/globals.cr +++ b/src/compiler/globals.cr @@ -71,8 +71,8 @@ g_ccr_case_count : int, mut // match; the capacity is compiler-private and never serialized. g_ccr_case_cap : int, mut // Set by ir_gen when the current function lowered a match to IR_MATCH. The -// per-function .cir snapshot does not carry the CASE range, so a function with -// this flag is rebuilt and not saved in this task. +// v24 .cir snapshot carries the CASE range, so this is a diagnostic flag, not a +// cache-save gate. g_cir_has_match : int, mut g_ast : string, mut g_ast_count : int, mut diff --git a/src/compiler/main.cr b/src/compiler/main.cr index 70fb86e3..ae11769e 100644 --- a/src/compiler/main.cr +++ b/src/compiler/main.cr @@ -573,9 +573,9 @@ fn corec_main() -> int: df_end_func(ir_func_idx) // Save only when generation did not change snapshot-uncarried shared state. - // A MATCH function's CASE range is not in the snapshot, so saving it - // would let a warm build restore a MATCH with no dispatch table. - if cache_enabled != 0 && g_cir_has_match == 0: + // MATCH CASE ranges are part of the snapshot (v24), so a MATCH function + // is saved like any other. g_cir_has_match remains diagnostic only. + if cache_enabled != 0: if g_type_count == tc0: save_cir_cache(cache_path, fi, ir_func_idx) diff --git a/tests/selfhost/test_ccr_types.py b/tests/selfhost/test_ccr_types.py index de6fc9e0..ee0d8670 100644 --- a/tests/selfhost/test_ccr_types.py +++ b/tests/selfhost/test_ccr_types.py @@ -1894,8 +1894,8 @@ def test_p4t4_dump_flag_zero_artifact_effect(): def test_p4t4_cir_snapshot_layout_unbumped(): - """㉛ `.cir` 快照**布局**未变(**P4 当时**未 bump 的证据面;**17 → 18 由 TODO #2026-09-16-16 批 2 换代**—— - 旧值 17 / 新值 18 / 归因 = 聚合读结果槽型改声明面形式(旧快照与新语义不等价)/ 出处 = 计划 §12.4·§13): + """㉛ `.cir` 快照**布局**未变(**P4 当时**未 bump 的证据面;**17 → 18 由 TODO #2026-09-16-16 批 2 换代**, + 后续 v23/v24 因语义/布局换代——v24 在指令段后新增 MATCH CASE 小节——旧值 17 / 新值 24 / 归因 = 聚合读结果槽型改声明面形式(旧快照与新语义不等价)/ 出处 = 计划 §12.4·§13): 条目版本位 == 当前版本; 节点小节 = 8B 计数 + n×64B(8 字段)——即「项槽不落盘、装载侧重派生」的格式 事实;>0 节点保证非空转。""" @@ -1910,7 +1910,7 @@ def test_p4t4_cir_snapshot_layout_unbumped(): total_nodes = 0 for f in entries: ver, nodes, ok = cir_entry_nodes(os.path.join(cache_dir, f)) - assert ver == 23, f"{f}: CIR_CACHE_VER {ver} != 23(2026-10-23 **BigInt IR 批** v23:新增 IR_CONST_BIG/IR_PTR_OF/IR_STR_OF;旧条目会复活旧整数语义 ⇒ 旧缓存必须失效;前代 22 = 2026-09-18 插值展开批、21 = 串域批、20 = 缓存膨胀批、19 = 批 5(opt-dex)、18 = TODO #2026-09-16-16 批 2)" + assert ver == 24, f"{f}: CIR_CACHE_VER {ver} != 24(2026-10-24 v10 MATCH CASE 持久化:指令段后新增 CASE 小节 ⇒ 布局改变;前代 23 = 2026-10-23 **BigInt IR 批**;22 = 插值展开批、21 = 串域批、20 = 缓存膨胀批、19 = 批 5(opt-dex)、18 = TODO #2026-09-16-16 批 2)" assert ok, f"{f}: node section (64B stride) runs past EOF" total_nodes += len(nodes) assert total_nodes > 0, "vacuous: no nodes parsed from snapshots" @@ -2073,7 +2073,7 @@ def test_p5t2_snapshot_disk_code_preserved(): disk = [] for f in sorted(os.listdir(cache_dir)): ver, nodes, ok = cir_entry_nodes(os.path.join(cache_dir, f)) - assert ver == 23 and ok, f"{f}: version/stride drifted(期望 23 = 2026-10-23 **BigInt IR 批**(IR_CONST_BIG/IR_PTR_OF/IR_STR_OF;前代 22 = 插值展开批;节点 stride 仍 64B / 头偏移不变(裁-4 + 尾部 trailer);前代 21 = 串域批、20 = 缓存膨胀批、19 = 批 5(opt-dex)、18 = TODO #2026-09-16-16 批 2)" + assert ver == 24 and ok, f"{f}: version/stride drifted(期望 24 = 2026-10-24 v10 MATCH CASE 持久化(指令段后新增 CASE 小节,节点 stride 仍 64B / 头偏移不变);前代 23 = BigInt IR 批;22 = 插值展开批;21 = 串域批;20 = 缓存膨胀批;19 = 批 5(opt-dex);18 = TODO #2026-09-16-16 批 2)" disk += [(nd[0], nd[5]) for nd in nodes] assert disk, "vacuous: no nodes parsed from snapshots" mint = set((r[1], r[2]) for r in rows if r[1] in MINT_OPS and r[2] > 0) diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index b7a9478f..d029190e 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -25,6 +25,7 @@ import subprocess import sys import tempfile +import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) @@ -217,6 +218,175 @@ def _assert_soft_diagnostic(tmp, source, name, needles): return text +# --------------------------------------------------------------------------- +# .cir snapshot fixtures (v24 layout) +# --------------------------------------------------------------------------- +# +# The cache lives under /.core/cache/cir and is keyed by source path and +# function name. These helpers parse the v24 per-function snapshot independently +# so the tests can prove the MATCH CASE section round-trips, and can tamper with +# exactly that section. + +CIR_VERSION = 24 +CIR_CASE_ROW = 24 + + +def _cache_dir(tmp): + return tmp / ".core" / "cache" / "cir" + + +def _clean_cache(tmp): + import shutil + + shutil.rmtree(_cache_dir(tmp), ignore_errors=True) + + +def _cache_entry(tmp, fn): + entries = [ + p + for p in sorted(_cache_dir(tmp).glob("*.cir")) + if p.name.endswith(f"::{fn}.cir") + ] + assert len(entries) == 1, f"{fn}: expected one cache entry, got {entries}" + return entries[0] + + +def _entry_stamp(path): + st = path.stat() + return (st.st_size, st.st_mtime_ns) + + +def _cir_snapshot(data): + """Parse a v24 snapshot. Returns section offsets and CASE rows.""" + if len(data) < 56: + raise AssertionError(f"snapshot too short: {len(data)}B") + magic, ver, cid, fp, sig, name_len = struct.unpack_from(" int: + return match n: + 1 => match m: + 10 => 100, + _ => 101, + 2 => 2, + _ => 3 + +fn main() -> int: + if pick(1, 10) != 100: + return 1 + if pick(1, 7) != 101: + return 2 + if pick(2, 0) != 2: + return 3 + if pick(9, 0) != 3: + return 4 + return 42 +""" + +# Same function body on both sides; only the earlier pad function changes. The +# extra `x = x` adds one node but no variable, so pick_b's var baseline is +# unchanged while its node/instruction baseline shifts by one. +SHIFT_PAD_V1 = """fn pad(b: bool) -> int: + x := 1 + x = x + if b: + return 1 + return 0 +""" + +SHIFT_PAD_V2 = """fn pad(b: bool) -> int: + x := 1 + x = x + x = x + if b: + return 1 + return 0 +""" + +SHIFT_PICK = """fn pick_b(n: int) -> int: + return match n: + 7 => 70, + 8 => 80, + 9 => 90, + _ => 99 +""" + +SHIFT_MAIN = """fn main() -> int: + if pick_b(9) != 90: + return 2 + return 42 +""" + + +def _assert_snapshot_case_section(snap, data, counts): + """Validate one snapshot's CASE section and its target labels.""" + assert snap["match_count"] == len(counts), snap["match_count"] + for match, count in zip(snap["matches"], counts): + assert match["count"] == count, (match["count"], count) + for row in match["rows"]: + rel = row["target_rel"] + assert 0 <= rel < snap["node_count"], (rel, snap["node_count"]) + op = struct.unpack_from(" expected per-MATCH case counts. + """ + _clean_cache(tmp) + src = _write_source(tmp, source, base) + cold_elf, cold_ccr = _build_source(tmp, src, "cold", tmp) + entries = {fn: _cache_entry(tmp, fn) for fn in fns} + before = {fn: entry.read_bytes() for fn, entry in entries.items()} + stamps = {fn: _entry_stamp(entry) for fn, entry in entries.items()} + snaps = {} + for fn, counts in fns.items(): + snaps[fn] = _cir_snapshot(before[fn]) + assert snaps[fn]["name"] == fn, (snaps[fn]["name"], fn) + _assert_snapshot_case_section(snaps[fn], before[fn], counts) + case_rows = _parse_case(segment(cold_ccr.read_bytes(), CASE_TAG)) + assert len(case_rows) == sum(sum(c) for c in fns.values()), len(case_rows) + _assert_targets_are_labels(cold_ccr.read_bytes(), case_rows) + + time.sleep(0.02) + warm_elf, warm_ccr = _build_source(tmp, src, "warm", tmp) + for fn in fns: + assert _entry_stamp(entries[fn]) == stamps[fn], ( + f"{fn}: warm build did not hit the cache" + ) + assert entries[fn].read_bytes() == before[fn], f"{fn}: hit rewrote the entry" + assert cold_elf.read_bytes() == warm_elf.read_bytes(), "cold/warm ELF differ" + assert cold_ccr.read_bytes() == warm_ccr.read_bytes(), "cold/warm .ccr differ" + assert _run_elf(warm_elf) == expected, "warm ELF returned the wrong status" + return snaps + + +def test_cir_cache_restores_match_cases_cold_warm(): + """MATCH functions are saved, hit on the warm path, and rebuild byte-identically.""" + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + snaps = _assert_match_cold_warm( + tmp, MATCH_INT_SOURCE, "match_cache", 42, {"pick": [5]} + ) + # The old rejection path would never save this entry at all. + assert snaps["pick"]["instr_count"] > 0 + + +def test_cir_cache_two_match_functions_disjoint_ranges(): + """Two cached MATCH functions keep disjoint CASE ranges after restore.""" + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + snaps = _assert_match_cold_warm( + tmp, + MATCH_TWO_FUNCS_SOURCE, + "match_two_cache", + 42, + {"pick_a": [3], "pick_b": [4]}, + ) + rows = _parse_case(segment(Path(str(tmp / "warm.elf") + ".ccr").read_bytes(), CASE_TAG)) + assert len(rows) == 7, rows + nodes = _nod_records(Path(str(tmp / "warm.elf") + ".ccr").read_bytes()) + match_nodes = [i for i, rec in enumerate(nodes) if rec[0] == IR_MATCH] + assert len(match_nodes) == 2, match_nodes + ranges = sorted( + (nodes[m][3], nodes[m][3] + nodes[m][4]) for m in match_nodes + ) + assert ranges[0] == (0, 3), ranges + assert ranges[1] == (3, 7), ranges + assert snaps["pick_a"]["match_count"] == 1 + assert snaps["pick_b"]["match_count"] == 1 + + +def test_cir_cache_nested_match_case_order(): + """Nested matches append their range after the enclosing one on both paths.""" with tempfile.TemporaryDirectory() as td: tmp = Path(td) - first = _build_ccr(tmp, MATCH_INT_SOURCE, "warm", cwd=tmp) - second = _build_ccr(tmp, MATCH_INT_SOURCE, "warm", cwd=tmp) - assert segment(first, CASE_TAG) == segment(second, CASE_TAG) - rows = _parse_case(segment(second, CASE_TAG)) + snaps = _assert_match_cold_warm( + tmp, + MATCH_NESTED_CASE_SOURCE, + "match_nested_cache", + 42, + {"pick": [3, 2]}, + ) + nested = snaps["pick"]["matches"] + assert [m["count"] for m in nested] == [3, 2] + rows = _parse_case(segment(Path(str(tmp / "warm.elf") + ".ccr").read_bytes(), CASE_TAG)) assert len(rows) == 5, rows - assert _single_match(second)[4] == 5 + + +def test_cir_cache_warm_restore_remaps_shifted_case_targets(): + """A hit with a shifted node/instruction baseline rebases CASE targets. + + The fingerprints hash the whole source, so a normal warm build cannot shift + a cached function's baseline. This fixture constructs the shift explicitly: + the pad function gains one node (no variables), and the cached pick_b/main + v1 snapshots are re-labelled with their v2 fingerprints. The loader must + therefore rebase every CASE target from the writer's node_start to the + current node base (the baseline shifts by one). + """ + with tempfile.TemporaryDirectory() as td: + root = Path(td) + + # Baseline: normal cold v2 build in its own directory. + base_dir = root / "base" + base_dir.mkdir() + v2_source = SHIFT_PAD_V2 + "\n" + SHIFT_PICK + "\n" + SHIFT_MAIN + base_src = _write_source(base_dir, v2_source, "shift") + base_elf, base_ccr = _build_source(base_dir, base_src, "baseline", base_dir) + base_pick = _cache_entry(base_dir, "pick_b") + base_snap = _cir_snapshot(base_pick.read_bytes()) + base_main = _cache_entry(base_dir, "main") + base_main_snap = _cir_snapshot(base_main.read_bytes()) + + # v1: smaller pad, snapshot pick_b at node_start 15. + work = root / "work" + work.mkdir() + v1_source = SHIFT_PAD_V1 + "\n" + SHIFT_PICK + "\n" + SHIFT_MAIN + work_src = _write_source(work, v1_source, "shift") + _build_source(work, work_src, "v1", work) + v1_pick = _cache_entry(work, "pick_b") + v1_bytes = bytearray(v1_pick.read_bytes()) + v1_snap = _cir_snapshot(bytes(v1_bytes)) + assert v1_snap["node_start"] + 1 == base_snap["node_start"], ( + v1_snap["node_start"], + base_snap["node_start"], + ) + assert v1_snap["var_start"] == base_snap["var_start"], ( + v1_snap["var_start"], + base_snap["var_start"], + ) + v1_main = _cache_entry(work, "main") + v1_main_bytes = bytearray(v1_main.read_bytes()) + v1_main_snap = _cir_snapshot(bytes(v1_main_bytes)) + assert v1_main_snap["node_start"] + 1 == base_main_snap["node_start"] + assert v1_main_snap["var_start"] == base_main_snap["var_start"] + + # Rewrite v2's source and seed pick_b/main with the shifted v1 snapshots + # plus the v2 fingerprints. pad misses and is regenerated. Keeping main a + # hit isolates the CASE remap from the separate cache-hit label-counter + # issue (a regenerated function after a label-using hit starts at 1). + work_src.write_text(v2_source, encoding="utf-8") + for entry, old_snap, new_snap in ( + (v1_pick, v1_snap, base_snap), + (v1_main, v1_main_snap, base_main_snap), + ): + data = bytearray(entry.read_bytes()) + struct.pack_into(" Date: Sat, 24 Oct 2026 10:14:59 +0800 Subject: [PATCH 18/22] cache: validate and rebase match nod mirrors --- src/compiler/cir_cache.cr | 25 +++++ tests/selfhost/test_multibranch_v10.py | 126 +++++++++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/src/compiler/cir_cache.cr b/src/compiler/cir_cache.cr index e9b382ca..82e3790a 100644 --- a/src/compiler/cir_cache.cr +++ b/src/compiler/cir_cache.cr @@ -638,6 +638,11 @@ fn load_cir_cache(path: string, func_idx: int) -> int: p = p + 8 + p_instr_count * 48 if p > dlen: return -1 + // emit() creates one NOD and one instruction per operation, so the local + // index is the same in both sections. The MATCH NOD mirror is validated below + // through this mapping; require the counts to agree before relying on it. + if p_node_count != p_instr_count: + return -1 // Count IR_MATCH instructions. The CASE section stores one group per MATCH // in instruction order, so this count must match the section header exactly. p_match_count : ., mut = 0 @@ -653,6 +658,8 @@ fn load_cir_cache(path: string, func_idx: int) -> int: // stores target_rel = target_nod - node_start. Validate every row against // this function's NOD range before any global is written. case_p := p + if p + 8 > dlen: + return -1 p_case_matches := cell_load(data, p) p = p + 8 if p_case_matches != p_match_count: @@ -673,10 +680,22 @@ fn load_cir_cache(path: string, func_idx: int) -> int: p_inst := instr_p + 8 + p_mi * 48 p_ms := cell_load(data, p_inst + 24) p_mn := cell_load(data, p_inst + 32) + if p + 8 > dlen: + return -1 p_cn := cell_load(data, p) p = p + 8 if p_cn != p_mn || p_cn < 0 || p_ms < 0: return -1 + // The cached MATCH NOD is lowered into the .ccr, so its s2/s3 must + // mirror the instruction exactly. A tampered NOD would otherwise hit and + // change the warm CASE range after lower_to_ccr rebuilds from NOD. + p_nod := node_p + 8 + p_mi * 64 + if cell_load(data, p_nod) != IR_MATCH: + return -1 + if cell_load(data, p_nod + 24) != p_ms: + return -1 + if cell_load(data, p_nod + 32) != p_mn: + return -1 // Matches in one function own contiguous ranges in increasing order. if p_expected_case >= 0 && p_ms != p_expected_case: return -1 @@ -711,6 +730,8 @@ fn load_cir_cache(path: string, func_idx: int) -> int: p_mi = p_mi + 1 str_p := p + if p + 8 > dlen: + return -1 p_str_count := cell_load(data, p) p = p + 8 p_si : ., mut = 0 @@ -1057,6 +1078,10 @@ fn load_cir_cache(path: string, func_idx: int) -> int: pos = pos + 8 rm_case_start := g_ccr_case_count iri_set_s2(rm_scan, rm_case_start) + // lower_to_ccr rebuilds instructions from NOD, so the mirror must get the + // same rebased start. Node/instr local indexes are 1:1 (checked above). + rm_nod := base_node + (rm_scan - base_instr) + cell_store(g_df_nodes, rm_nod * ESZ_DFNODE + OFF_DF_S2, rm_case_start) rm_j : ., mut = 0 loop: if rm_j >= rm_count: diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index d029190e..6cbe2d43 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -2630,6 +2630,47 @@ def _assert_snapshot_case_section(snap, data, counts): assert op == IR_LABEL, f"CASE target rel {rel} is op {op}, expected IR_LABEL" +def _snapshot_match_nod(data, snap, ordinal=0): + """Return (local index, record offset, s2, s3) for a MATCH NOD record.""" + seen = 0 + for i in range(snap["node_count"]): + off = snap["node_p"] + i * 64 + if struct.unpack_from(" Date: Sat, 24 Oct 2026 10:42:31 +0800 Subject: [PATCH 19/22] docs: mark pattern design as historical v9.1 and v10 current --- .../specs/2026-10-23-pattern-matching-ir-design.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/superpowers/specs/2026-10-23-pattern-matching-ir-design.md b/docs/superpowers/specs/2026-10-23-pattern-matching-ir-design.md index c84aa06d..f4dad782 100644 --- a/docs/superpowers/specs/2026-10-23-pattern-matching-ir-design.md +++ b/docs/superpowers/specs/2026-10-23-pattern-matching-ir-design.md @@ -1,5 +1,13 @@ # match 的决策树编译与统一分支节点 +> **v10 实现说明(2026-10-24)**:现行 `.ccr` 是 v10,不是本文写作时的 v9.1。 +> 多路分支在 v10 中使用 `IR_MATCH`(opcode 54)+ CASE 24B 记录 +> `{value, kind, str_index, target_nod, flags}`;`IR_BRANCH` 继续承载二元 +> `if`/`while`。本文的 v9.1 版本/段表/16B CASE 段落是历史设计,已被 +> `docs/superpowers/specs/2026-10-23-lattice-ccr-v10-design.md` §11 取代。 +> 已实现范围:int/bool/enum/string/char/struct/tuple/ident 模式、二分与密集 +> 跳转表、CIR CASE 区间持久化。下文保留历史决策与推导。 + ## 概述 `match` 目前在 `src/compiler/ir_gen.cr:2683` 起按 arm 顺序展开为 `IR_CONST`、`IR_BINARY`、`IR_BRANCH` 组成的比较链,corec 的 IR 中只有二元条件分支。字符串、字符、结构体、标识符模式在 `ir_gen` 中没有测试或绑定生成,会静默执行第一个 arm。 From 508622266f345bb4212b31a1c47f9c65e022c8c0 Mon Sep 17 00:00:00 2001 From: DslsDZC Date: Sat, 24 Oct 2026 11:16:27 +0800 Subject: [PATCH 20/22] ccr: separate semantic match cases from wire ccr --- src/compiler/ccr_lower.cr | 117 ++++++++++++++++++++++++- src/compiler/cir_cache.cr | 96 +++++++++++--------- src/compiler/globals.cr | 25 ++++-- src/compiler/ir_gen.cr | 67 +++++++------- src/compiler/main.cr | 21 +++++ tests/selfhost/test_ccr_types.py | 4 +- tests/selfhost/test_multibranch_v10.py | 91 +++++++++++++++---- 7 files changed, 327 insertions(+), 94 deletions(-) diff --git a/src/compiler/ccr_lower.cr b/src/compiler/ccr_lower.cr index cbb0bd2b..175058c9 100644 --- a/src/compiler/ccr_lower.cr +++ b/src/compiler/ccr_lower.cr @@ -1,6 +1,35 @@ // === ccr_lower.cr === // HDFG linear schedule lowering. This is the only frontend step that turns -// graph nodes into the linear instruction stream stored in .ccr. +// graph nodes into the linear instruction stream stored in .ccr, and the only +// producer of the wire CASE table. + +// ── wire CASE helpers ──────────────────────────────────────────────────── +// The wire table is a flat 24B-row buffer. ccr_lower derives it from the +// semantic model (g_lat_case); rows keep the wire layout named by CCR_CASE_*. +fn grow_ccr_case(needed: int): + if needed < g_ccr_case_cap: + return + nc : ., mut = g_ccr_case_cap * 2 + if nc < 16: + nc = 16 + if nc < needed: + nc = needed + 16 + nb := alloc(nc * CCR_V10_CASE_DISK) + cell_copy(g_ccr_case, g_ccr_case_cap * CCR_V10_CASE_DISK, nb) + g_ccr_case = nb + g_ccr_case_cap = nc + +fn ccr_case_append(value: int, kind: int, str_index: int, target: int, flags: int) -> int: + idx := g_ccr_case_count + grow_ccr_case(idx + 1) + base := idx * CCR_V10_CASE_DISK + cell_store(g_ccr_case, base + CCR_CASE_VALUE, value) + cell_store32(g_ccr_case, base + CCR_CASE_KIND, kind) + cell_store32(g_ccr_case, base + CCR_CASE_STR, str_index) + cell_store32(g_ccr_case, base + CCR_CASE_TARGET, target) + cell_store32(g_ccr_case, base + CCR_CASE_FLAGS, flags) + g_ccr_case_count = idx + 1 + return idx fn lower_to_ccr(): // The graph was built in parallel with linear IR during emit(). @@ -41,6 +70,92 @@ fn lower_to_ccr(): cell_load(g_df_func_node_count, fi * 8)) fi = fi + 1 + // Derive the wire CASE table from the semantic model. The model stores body + // label numbers; resolve each target against the IR_LABEL nodes of the same + // function. Matches are visited in instruction order, so the wire rows keep + // the model's reservation order. An unresolved label stays -1 and the + // .ccr loader/saver rejects the malformed wire row. Only this function + // writes g_ccr_case. + g_ccr_case_count = 0 + + fi = 0 + loop: + if fi >= g_ir_func_count: + break + f_nstart := cell_load(g_df_func_node_start, fi * 8) + f_ncount := cell_load(g_df_func_node_count, fi * 8) + f_nend := f_nstart + f_ncount + + lbl_n : ., mut = 0 + ni = f_nstart + loop: + if ni >= f_nend: + break + if cell_load(g_df_nodes, ni * ESZ_DFNODE + OFF_DF_OPCODE) == IR_LABEL: + lbl_n = lbl_n + 1 + ni = ni + 1 + + lbl_id : string, mut = "" + lbl_nod : string, mut = "" + if lbl_n > 0: + lbl_id = alloc(lbl_n * 8) + lbl_nod = alloc(lbl_n * 8) + li : ., mut = 0 + ni = f_nstart + loop: + if ni >= f_nend: + break + if cell_load(g_df_nodes, ni * ESZ_DFNODE + OFF_DF_OPCODE) == IR_LABEL: + cell_store(lbl_id, li * 8, + cell_load(g_df_nodes, ni * ESZ_DFNODE + OFF_DF_S1)) + cell_store(lbl_nod, li * 8, ni) + li = li + 1 + ni = ni + 1 + + istart := cell_load(g_ir_func_instr_start, fi * 8) + icount := cell_load(g_ir_func_instr_count, fi * 8) + ii : ., mut = 0 + loop: + if ii >= icount: + break + inst := istart + ii + if iri_op(inst) == IR_MATCH: + ms := iri_s2(inst) + mn := iri_s3(inst) + if ms >= 0 && mn >= 0 && ms + mn <= g_lat_case_count: + wire_start := g_ccr_case_count + rj : ., mut = 0 + loop: + if rj >= mn: + break + cbase := (ms + rj) * CCR_V10_CASE_DISK + cval := cell_load(g_lat_case, cbase + CCR_CASE_VALUE) + ckind := cell_load32(g_lat_case, cbase + CCR_CASE_KIND) + csidx := cell_load_i32(g_lat_case, cbase + CCR_CASE_STR) + cflags := cell_load32(g_lat_case, cbase + CCR_CASE_FLAGS) + clabel := cell_load_i32(g_lat_case, cbase + CCR_CASE_TARGET) + cnod : ., mut = -1 + if clabel >= 0: + lj : ., mut = 0 + loop: + if lj >= lbl_n: + break + if cell_load(lbl_id, lj * 8) == clabel: + cnod = cell_load(lbl_nod, lj * 8) + break + lj = lj + 1 + ccr_case_append(cval, ckind, csidx, cnod, cflags) + rj = rj + 1 + // IR_MATCH s2/s3 become the derived wire range. The NOD + // mirror is rebuilt into g_ir_instrs above, but downstream + // readers (interp) also consume it, so keep both in sync. + iri_set_s2(inst, wire_start) + iri_set_s3(inst, mn) + cell_store(g_df_nodes, inst * ESZ_DFNODE + OFF_DF_S2, wire_start) + cell_store(g_df_nodes, inst * ESZ_DFNODE + OFF_DF_S3, mn) + ii = ii + 1 + fi = fi + 1 + // After lowering, compute per-variable usage counts for optimization passes compute_usage_counts() diff --git a/src/compiler/cir_cache.cr b/src/compiler/cir_cache.cr index 82e3790a..6566def2 100644 --- a/src/compiler/cir_cache.cr +++ b/src/compiler/cir_cache.cr @@ -18,8 +18,12 @@ // instructions: count, then 48-byte records {op, dest, s1, s2, s3, tk} // match cases: match count, then per IR_MATCH in instruction order: // {case_count, then case_count x 24-byte records -// value i64, kind u32, str i32, target_rel i32, flags u32} -// target_rel = target_nod - node_start, or -1 for a negative target +// value i64, kind u32, str i32, target_label i32, flags u32} +// This is the semantic model, not the derived wire: target_label is +// the IR body label number, or -1 when the target is unresolved. +// The MATCH s2/s3 stored in nodes/instructions are model start/count; +// the loader rewrites s2 to the reader's model start and ccr_lower +// derives the wire CASE table afterwards. // string constants: count, then {length, bytes} // intern journal: base count, record count, then {length, bytes} // trailer: var_start, node_start @@ -58,8 +62,8 @@ // contract. The stored records, journal, and trailer are unchanged, so no // version bump is required for that migration; only `.ccr` bytes changed. // v24 adds the per-function MATCH CASE section after the instruction range. -// The layout changed, so entries written before v24 are invalid even though -// the rest of the snapshot is unchanged. +// v25 changes that section from derived wire rows (target NOD id) to the +// semantic model (target body label), so v24 entries are invalid. // // Magic value: // The magic is a negative 64-bit constant written as signed decimal. The @@ -67,7 +71,7 @@ // writer and reader use the same constant, so the cache remains self-consistent. // The limitation is recorded in the project backlog. CIR_CACHE_MAGIC : int = -4485090715960753727 -CIR_CACHE_VER : int = 24 +CIR_CACHE_VER : int = 25 g_cir_write_buf : string, mut g_cir_write_pos : int, mut @@ -218,9 +222,10 @@ fn save_cir_cache(path: string, source_fi: int, ir_fi: int) -> int: instr_count := cell_load(g_ir_func_instr_count, ir_fi * 8) // Validate and size the MATCH CASE section before touching the file. Every - // IR_MATCH owns a contiguous range of g_ccr_case rows; nested matches append - // after their enclosing range, which is also instruction order. A mismatch - // would produce a snapshot the loader must reject, so refuse to write it. + // IR_MATCH owns a contiguous range of semantic model rows (g_lat_case); + // nested matches append after their enclosing range, which is also + // instruction order. A mismatch would produce a snapshot the loader must + // reject, so refuse to write it. match_count : ., mut = 0 match_size : ., mut = 8 expected_case : ., mut = -1 @@ -234,7 +239,7 @@ fn save_cir_cache(path: string, source_fi: int, ir_fi: int) -> int: ms_n := iri_s3(ms_inst) if ms_start < 0 || ms_n < 0: return -1 - if ms_start + ms_n > g_ccr_case_count: + if ms_start + ms_n > g_lat_case_count: return -1 if expected_case >= 0 && ms_start != expected_case: return -1 @@ -407,15 +412,14 @@ fn save_cir_cache(path: string, source_fi: int, ir_fi: int) -> int: if cj >= cs_n: break cbase := (cs_start + cj) * 24 - cir_store_cell(fd, cell_load(g_ccr_case, cbase + CCR_CASE_VALUE)) - cir_store_cell32(fd, cell_load32(g_ccr_case, cbase + CCR_CASE_KIND)) - cir_store_cell32(fd, cell_load_i32(g_ccr_case, cbase + CCR_CASE_STR)) - ctgt := cell_load_i32(g_ccr_case, cbase + CCR_CASE_TARGET) - crel : ., mut = -1 - if ctgt >= 0: - crel = ctgt - node_start - cir_store_cell32(fd, crel) - cir_store_cell32(fd, cell_load32(g_ccr_case, cbase + CCR_CASE_FLAGS)) + cir_store_cell(fd, cell_load(g_lat_case, cbase + CCR_CASE_VALUE)) + cir_store_cell32(fd, cell_load32(g_lat_case, cbase + CCR_CASE_KIND)) + cir_store_cell32(fd, cell_load_i32(g_lat_case, cbase + CCR_CASE_STR)) + // Persist the semantic target label, not a node-relative wire + // target. Labels are restored verbatim, so no baseline rebase + // is needed on load. + cir_store_cell32(fd, cell_load_i32(g_lat_case, cbase + CCR_CASE_TARGET)) + cir_store_cell32(fd, cell_load32(g_lat_case, cbase + CCR_CASE_FLAGS)) cj = cj + 1 cs_i = cs_i + 1 @@ -654,9 +658,10 @@ fn load_cir_cache(path: string, func_idx: int) -> int: p_match_count = p_match_count + 1 p_mi = p_mi + 1 - // MATCH CASE section: match count, then per MATCH {case_count, rows}. A row - // stores target_rel = target_nod - node_start. Validate every row against - // this function's NOD range before any global is written. + // MATCH CASE section: match count, then per MATCH {case_count, rows}. This + // is the semantic model: a row stores target_label = the IR body label + // number. Validate every target against this function's IR_LABEL nodes + // before any global is written. case_p := p if p + 8 > dlen: return -1 @@ -712,11 +717,26 @@ fn load_cir_cache(path: string, func_idx: int) -> int: return -1 if p_flags != 0: return -1 - p_tr := cell_load_i32(data, p + 16) - if p_tr < 0 || p_tr >= p_node_count: - return -1 - if cell_load(data, node_p + 8 + p_tr * 64) != IR_LABEL: + p_tl := cell_load_i32(data, p + 16) + if p_tl < -1: return -1 + // A non-negative model target must name an IR_LABEL node in this + // function. -1 is preserved for an unresolved target and will be + // rejected by ccr_lower's wire validation instead of restoring a + // bogus label. + if p_tl >= 0: + p_lf : ., mut = 0 + p_ln : ., mut = 0 + loop: + if p_ln >= p_node_count: + break + p_loff := node_p + 8 + p_ln * 64 + if cell_load(data, p_loff) == IR_LABEL && cell_load(data, p_loff + 16) == p_tl: + p_lf = 1 + break + p_ln = p_ln + 1 + if p_lf == 0: + return -1 p = p + 24 p_rj = p_rj + 1 p_mi = p_mi + 1 @@ -1054,13 +1074,14 @@ fn load_cir_cache(path: string, func_idx: int) -> int: ii = ii + 1 g_ir_instr_count = base_instr + instr_count - // Restore this function's MATCH CASE ranges. The section is in the same order - // as the restored instruction stream, so pair each group with the next - // IR_MATCH and append at the current global CASE count. Stored targets are - // relative to the writer's node_start; rebase them to the restored nodes and - // rewrite s2 to the new range start. s3 is the stored count and is unchanged. - // The pre-scan already validated counts, ordering, target labels, and row - // domains, so this phase cannot fail halfway. + // Restore this function's semantic MATCH CASE ranges. The section is in the + // same order as the restored instruction stream, so pair each group with the + // next IR_MATCH and append at the current global model count. Targets are + // already body label numbers and labels are restored verbatim, so only the + // model start is rebased. s3 (the count) is unchanged. This restores the + // model, not the wire; ccr_lower derives g_ccr_case afterwards. The pre-scan + // already validated counts, ordering, target labels, and row domains, so + // this phase cannot fail halfway. restored_matches := cell_load(data, pos) pos = pos + 8 rm_i : ., mut = 0 @@ -1076,10 +1097,10 @@ fn load_cir_cache(path: string, func_idx: int) -> int: rm_scan = rm_scan + 1 rm_count := cell_load(data, pos) pos = pos + 8 - rm_case_start := g_ccr_case_count + rm_case_start := g_lat_case_count iri_set_s2(rm_scan, rm_case_start) // lower_to_ccr rebuilds instructions from NOD, so the mirror must get the - // same rebased start. Node/instr local indexes are 1:1 (checked above). + // same model start. Node/instr local indexes are 1:1 (checked above). rm_nod := base_node + (rm_scan - base_instr) cell_store(g_df_nodes, rm_nod * ESZ_DFNODE + OFF_DF_S2, rm_case_start) rm_j : ., mut = 0 @@ -1089,13 +1110,10 @@ fn load_cir_cache(path: string, func_idx: int) -> int: rm_value := cell_load(data, pos) rm_kind := cell_load32(data, pos + 8) rm_str := cell_load_i32(data, pos + 12) - rm_rel := cell_load_i32(data, pos + 16) + rm_label := cell_load_i32(data, pos + 16) rm_flags := cell_load32(data, pos + 20) pos = pos + 24 - rm_target : ., mut = -1 - if rm_rel >= 0: - rm_target = base_node + rm_rel - ccr_case_append(rm_value, rm_kind, rm_str, rm_target, rm_flags) + lat_case_append(rm_value, rm_kind, rm_str, rm_label, rm_flags) rm_j = rm_j + 1 rm_scan = rm_scan + 1 rm_i = rm_i + 1 diff --git a/src/compiler/globals.cr b/src/compiler/globals.cr index 2f095b06..0dae347a 100644 --- a/src/compiler/globals.cr +++ b/src/compiler/globals.cr @@ -60,19 +60,30 @@ g_ccr_nod_type : string, mut g_ccr_nod_type_count : int, mut g_ccr_nod_flags : string, mut g_ccr_nod_flags_count : int, mut +// Semantic v10 CASE model. Rows are 24B records with the same field layout as +// the wire (value, kind, str index, target, flags), but `target` is the IR body +// *label number* rather than a NOD id. ir_gen fills this model; ccr_lower is +// the only step that turns it into g_ccr_case (the wire table), and the CIR +// cache persists this model rather than the derived wire. This file is first in +// both builds, so these globals must stay here. +g_lat_case : string, mut +g_lat_case_count : int, mut +// Capacity of g_lat_case in 24B rows. Compiler-private and never serialized. +g_lat_case_cap : int, mut // In-memory CASE table for v10 segment tag 9. Rows are 24B records with the // layout named by CCR_CASE_* (value, kind, str index, target NOD, flags). -// corec fills this table when it lowers a match; the empty program keeps count -// zero and the wire writes a bare 4B zero count. The loader rebuilds the same -// rows from disk. This file is first in both builds, so it must stay here. +// This is a derived buffer: ccr_lower builds it from g_lat_case, and ccr_io / +// interp read it as wire. The empty program keeps count zero and the wire +// writes a bare 4B zero count. The loader rebuilds the same rows from disk. +// This file is first in both builds, so it must stay here. g_ccr_case : string, mut g_ccr_case_count : int, mut -// Capacity of g_ccr_case in 24B rows. corec appends rows while lowering a -// match; the capacity is compiler-private and never serialized. +// Capacity of g_ccr_case in 24B rows. ccr_lower appends rows while deriving the +// wire; the capacity is compiler-private and never serialized. g_ccr_case_cap : int, mut // Set by ir_gen when the current function lowered a match to IR_MATCH. The -// v24 .cir snapshot carries the CASE range, so this is a diagnostic flag, not a -// cache-save gate. +// .cir snapshot carries the semantic CASE range, so this is a diagnostic flag, +// not a cache-save gate. g_cir_has_match : int, mut g_ast : string, mut g_ast_count : int, mut diff --git a/src/compiler/ir_gen.cr b/src/compiler/ir_gen.cr index bc9358fd..e8de3190 100644 --- a/src/compiler/ir_gen.cr +++ b/src/compiler/ir_gen.cr @@ -827,39 +827,42 @@ fn new_label() -> int: g_next_label = g_next_label + 1 return lbl -// ── v10 CASE table helpers ─────────────────────────────────────────────── -// The CASE table is a flat 24B-row buffer. A match reserves its rows before -// emitting arm bodies so nested matches append after the outer range; the -// targets (NOD ids of IR_LABEL nodes) are filled after each body label exists. -fn grow_ccr_case(needed: int): - if needed < g_ccr_case_cap: +// ── v10 semantic CASE model helpers ────────────────────────────────────── +// The model is a flat 24B-row buffer with the same layout as the wire CASE +// table, except `target` is the IR body label number (not a NOD id). A match +// reserves its rows before emitting arm bodies so nested matches append after +// the outer range; each target label is recorded once its body label exists. +// ccr_lower resolves labels to NOD ids and builds the wire table; ir_gen never +// writes g_ccr_case. +fn grow_lat_case(needed: int): + if needed < g_lat_case_cap: return - nc : ., mut = g_ccr_case_cap * 2 + nc : ., mut = g_lat_case_cap * 2 if nc < 16: nc = 16 if nc < needed: nc = needed + 16 nb := alloc(nc * CCR_V10_CASE_DISK) - cell_copy(g_ccr_case, g_ccr_case_cap * CCR_V10_CASE_DISK, nb) - g_ccr_case = nb - g_ccr_case_cap = nc + cell_copy(g_lat_case, g_lat_case_cap * CCR_V10_CASE_DISK, nb) + g_lat_case = nb + g_lat_case_cap = nc -fn ccr_case_append(value: int, kind: int, str_index: int, target: int, flags: int) -> int: - idx := g_ccr_case_count - grow_ccr_case(idx + 1) +fn lat_case_append(value: int, kind: int, str_index: int, target_label: int, flags: int) -> int: + idx := g_lat_case_count + grow_lat_case(idx + 1) base := idx * CCR_V10_CASE_DISK - cell_store(g_ccr_case, base + CCR_CASE_VALUE, value) - cell_store32(g_ccr_case, base + CCR_CASE_KIND, kind) - cell_store32(g_ccr_case, base + CCR_CASE_STR, str_index) - cell_store32(g_ccr_case, base + CCR_CASE_TARGET, target) - cell_store32(g_ccr_case, base + CCR_CASE_FLAGS, flags) - g_ccr_case_count = idx + 1 + cell_store(g_lat_case, base + CCR_CASE_VALUE, value) + cell_store32(g_lat_case, base + CCR_CASE_KIND, kind) + cell_store32(g_lat_case, base + CCR_CASE_STR, str_index) + cell_store32(g_lat_case, base + CCR_CASE_TARGET, target_label) + cell_store32(g_lat_case, base + CCR_CASE_FLAGS, flags) + g_lat_case_count = idx + 1 return idx -fn ccr_case_set_target(idx: int, target: int): - if idx < 0 || idx >= g_ccr_case_count: +fn lat_case_set_target(idx: int, label: int): + if idx < 0 || idx >= g_lat_case_count: return - cell_store32(g_ccr_case, idx * CCR_V10_CASE_DISK + CCR_CASE_TARGET, target) + cell_store32(g_lat_case, idx * CCR_V10_CASE_DISK + CCR_CASE_TARGET, label) fn bind_local(name_idx: int, var_idx: int): grow_ir_locals(g_ir_local_count + 1) @@ -3003,20 +3006,20 @@ fn gen_expr(node: int) -> int: an0 = ast_c(an0) case_base : ., mut = -1 if match_mode != 0: - case_base = g_ccr_case_count + case_base = g_lat_case_count // type_kind 0: MATCH is not a type-face op. The NOD semantic // type_id is filled by ccr_nod_type_domain from the scrutinee var, // so the wire records the scrutinee domain instead of an invented // result domain. emit(IR_MATCH, -1, match_val, case_base, unique_n, 0) g_cir_has_match = 1 - // Reserve the whole range before emitting bodies: nested matches in - // an arm must append after this range, not splice into it. + // Reserve the whole model range before emitting bodies: nested + // matches in an arm must append after this range, not splice into it. cj0 : ., mut = 0 loop: if cj0 >= unique_n: break - ccr_case_append(cell_load(vals, cj0 * 16 + 8), cell_load(vals, cj0 * 16), -1, -1, 0) + lat_case_append(cell_load(vals, cj0 * 16 + 8), cell_load(vals, cj0 * 16), -1, -1, 0) cj0 = cj0 + 1 an : ., mut = first_arm arm_idx : ., mut = 0 @@ -3279,14 +3282,13 @@ fn gen_expr(node: int) -> int: if has_next == 1: fall_lbl = new_label() emit(IR_JUMP, -1, fall_lbl, 0, 0, 0) - body_nod : ., mut = -1 - if match_mode != 0: - body_nod = g_ir_instr_count emit(IR_LABEL, -1, body_lbl, 0, 0, 0) if match_mode != 0: ci_arm := cell_load(cis, arm_idx * 8) if ci_arm >= 0: - ccr_case_set_target(case_base + ci_arm, body_nod) + // The semantic model stores the body label number. ccr_lower + // resolves it to the NOD id when it derives the wire table. + lat_case_set_target(case_base + ci_arm, body_lbl) push_ir_scope() if pat_kind == EXPR_STRUCTPAT: gen_structpat_binds(arm_pat, match_val) @@ -4706,6 +4708,11 @@ fn ir_gen_all(): g_next_label = 1 g_ir_loop_depth = 0 g_ir_str_const_count = 0 + g_lat_case = "" + g_lat_case_count = 0 + g_lat_case_cap = 0 + // The wire table is derived by ccr_lower; reset it too so a second IR run + // in one process cannot inherit stale rows. g_ccr_case = "" g_ccr_case_count = 0 g_ccr_case_cap = 0 diff --git a/src/compiler/main.cr b/src/compiler/main.cr index ae11769e..19d39ee6 100644 --- a/src/compiler/main.cr +++ b/src/compiler/main.cr @@ -238,6 +238,7 @@ fn corec_main() -> int: cli_flag_bool_debug("dump-types", "", "Debug: dump TYPE segment") cli_flag_bool_debug("dump-ifaces", "", "Debug: dump IFACE segment") cli_flag_bool_debug("dump-tk-terms", "", "Debug: dump per-DFNode type terms") + cli_flag_bool_debug("dump-case-model", "", "Debug: report semantic CASE model vs derived wire counts") // The old type-shadow CLI channels were removed; the judgment path has no // switch and always goes through the engine. cli_flag_bool_debug("verify-named-dedup", "", "Debug: verify type side-table consistency") @@ -393,6 +394,9 @@ fn corec_main() -> int: if g_diag_count > ir_diag_base_run: print_diagnostics() return 1 + // The interpreter consumes the wire CASE table. Derive it from the + // semantic model exactly as the .ccr path does before executing. + lower_to_ccr() return ir_interpret() // === clean-cache: delete incremental compilation cache === @@ -455,6 +459,10 @@ fn corec_main() -> int: g_ir_str_const_count = 0 g_ir_source_hash = 0 g_ir_source_hash_ready = 0 + g_lat_case = "" + g_lat_case_count = 0 + g_lat_case_cap = 0 + // Wire CASE is derived by ccr_lower; clear both to avoid stale rows. g_ccr_case = "" g_ccr_case_count = 0 g_ccr_case_cap = 0 @@ -594,6 +602,14 @@ fn corec_main() -> int: // on it. df_state_finalize() + // Hidden contract channel: report the semantic CASE model and the derived + // wire count before lowering. Normal builds print nothing. + if cli_has("dump-case-model") != 0: + print("case_model_before ") + print(int_str(g_lat_case_count)) + print(" ") + println(int_str(g_ccr_case_count)) + // === cir: output dataflow graph === if cli_eq(cmd, "cir"): // Register allocation checks and entry dumps live in corearch now. @@ -634,6 +650,11 @@ fn corec_main() -> int: pass_cse() println("lower to ccr...") lower_to_ccr() + if cli_has("dump-case-model") != 0: + print("case_model_after ") + print(int_str(g_lat_case_count)) + print(" ") + println(int_str(g_ccr_case_count)) print("lower done: ") print(int_str(g_ir_func_count)) print(" funcs, ") diff --git a/tests/selfhost/test_ccr_types.py b/tests/selfhost/test_ccr_types.py index ee0d8670..24019794 100644 --- a/tests/selfhost/test_ccr_types.py +++ b/tests/selfhost/test_ccr_types.py @@ -1910,7 +1910,7 @@ def test_p4t4_cir_snapshot_layout_unbumped(): total_nodes = 0 for f in entries: ver, nodes, ok = cir_entry_nodes(os.path.join(cache_dir, f)) - assert ver == 24, f"{f}: CIR_CACHE_VER {ver} != 24(2026-10-24 v10 MATCH CASE 持久化:指令段后新增 CASE 小节 ⇒ 布局改变;前代 23 = 2026-10-23 **BigInt IR 批**;22 = 插值展开批、21 = 串域批、20 = 缓存膨胀批、19 = 批 5(opt-dex)、18 = TODO #2026-09-16-16 批 2)" + assert ver == 25, f"{f}: CIR_CACHE_VER {ver} != 25(2026-10-24 v10 MATCH CASE 语义模型持久化:CASE 小节存 target label 而非派生 wire target;前代 24 = MATCH CASE 持久化、23 = 2026-10-23 **BigInt IR 批**;22 = 插值展开批、21 = 串域批、20 = 缓存膨胀批、19 = 批 5(opt-dex)、18 = TODO #2026-09-16-16 批 2)" assert ok, f"{f}: node section (64B stride) runs past EOF" total_nodes += len(nodes) assert total_nodes > 0, "vacuous: no nodes parsed from snapshots" @@ -2073,7 +2073,7 @@ def test_p5t2_snapshot_disk_code_preserved(): disk = [] for f in sorted(os.listdir(cache_dir)): ver, nodes, ok = cir_entry_nodes(os.path.join(cache_dir, f)) - assert ver == 24 and ok, f"{f}: version/stride drifted(期望 24 = 2026-10-24 v10 MATCH CASE 持久化(指令段后新增 CASE 小节,节点 stride 仍 64B / 头偏移不变);前代 23 = BigInt IR 批;22 = 插值展开批;21 = 串域批;20 = 缓存膨胀批;19 = 批 5(opt-dex);18 = TODO #2026-09-16-16 批 2)" + assert ver == 25 and ok, f"{f}: version/stride drifted(期望 25 = 2026-10-24 v10 MATCH CASE 语义模型持久化(CASE 小节存 target label,节点 stride 仍 64B / 头偏移不变);前代 24 = MATCH CASE 持久化;23 = BigInt IR 批;22 = 插值展开批;21 = 串域批;20 = 缓存膨胀批;19 = 批 5(opt-dex);18 = TODO #2026-09-16-16 批 2)" disk += [(nd[0], nd[5]) for nd in nodes] assert disk, "vacuous: no nodes parsed from snapshots" mint = set((r[1], r[2]) for r in rows if r[1] in MINT_OPS and r[2] > 0) diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index 6cbe2d43..4f69b2be 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -227,7 +227,7 @@ def _assert_soft_diagnostic(tmp, source, name, needles): # so the tests can prove the MATCH CASE section round-trips, and can tamper with # exactly that section. -CIR_VERSION = 24 +CIR_VERSION = 25 CIR_CASE_ROW = 24 @@ -257,7 +257,7 @@ def _entry_stamp(path): def _cir_snapshot(data): - """Parse a v24 snapshot. Returns section offsets and CASE rows.""" + """Parse a v25 snapshot. Returns section offsets and CASE model rows.""" if len(data) < 56: raise AssertionError(f"snapshot too short: {len(data)}B") magic, ver, cid, fp, sig, name_len = struct.unpack_from("= 0, f"CASE model target label {label} is unresolved" + assert label in labels, ( + f"CASE model target label {label} is not an IR_LABEL in this function" + ) def _snapshot_match_nod(data, snap, ordinal=0): @@ -2704,6 +2716,55 @@ def _assert_match_cold_warm(tmp, source, base, expected, fns): return snaps +def test_case_model_is_semantic_before_lower(): + """ir_gen fills only the semantic model; ccr_lower derives the wire table. + + The hidden channel reports both counts around lower_to_ccr. Before lowering + the wire table must be empty while the model has rows; afterwards the wire + count must be non-zero and every MATCH range must index that wire table. + """ + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + _clean_cache(tmp) + src = _write_source(tmp, MATCH_INT_SOURCE, "case_model_contract") + ccr = tmp / "case_model_contract.ccr" + r = _run( + [COREC, "ccr", src, "-o", ccr, "--dump-case-model"], + cwd=tmp, + ) + assert r.returncode == 0, f"corec ccr failed: {r.stdout}\n{r.stderr}" + before = {} + after = {} + for line in r.stdout.splitlines(): + parts = line.split() + if len(parts) != 3: + continue + if parts[0] == "case_model_before": + before = {"model": int(parts[1]), "wire": int(parts[2])} + elif parts[0] == "case_model_after": + after = {"model": int(parts[1]), "wire": int(parts[2])} + assert before, f"missing case_model_before: {r.stdout!r}" + assert after, f"missing case_model_after: {r.stdout!r}" + assert before["model"] > 0, before + assert before["wire"] == 0, before + assert after["model"] == before["model"], (before, after) + assert after["wire"] > 0, after + + data = ccr.read_bytes() + validate(data) + nodes = _nod_records(data) + matches = [rec for rec in nodes if rec[0] == IR_MATCH] + assert matches, "expected at least one IR_MATCH" + for rec in matches: + start, count = rec[3], rec[4] + assert count > 0, rec + assert 0 <= start < after["wire"], (start, after) + assert start + count <= after["wire"], (start, count, after) + rows = _parse_case(segment(data, CASE_TAG)) + assert len(rows) == after["wire"], (len(rows), after) + _assert_targets_are_labels(data, rows) + + def test_cir_cache_restores_match_cases_cold_warm(): """MATCH functions are saved, hit on the warm path, and rebuild byte-identically.""" with tempfile.TemporaryDirectory() as td: @@ -2758,14 +2819,14 @@ def test_cir_cache_nested_match_case_order(): def test_cir_cache_warm_restore_remaps_shifted_case_targets(): - """A hit with a shifted node/instruction baseline rebases CASE targets. + """A hit with a shifted node/instruction baseline rebuilds CASE labels. The fingerprints hash the whole source, so a normal warm build cannot shift a cached function's baseline. This fixture constructs the shift explicitly: the pad function gains one node (no variables), and the cached pick_b/main - v1 snapshots are re-labelled with their v2 fingerprints. The loader must - therefore rebase every CASE target from the writer's node_start to the - current node base (the baseline shifts by one). + v1 snapshots are re-labelled with their v2 fingerprints. The model stores + body label numbers, which are unchanged by the shift; ccr_lower must resolve + them to the new NOD ids so the warm wire matches the cold wire. """ with tempfile.TemporaryDirectory() as td: root = Path(td) @@ -2854,9 +2915,9 @@ def test_cir_cache_tampered_case_section_is_a_miss(): tampered = [] # 1. Truncated inside the CASE rows (the trailer is gone). tampered.append(("truncated", clean[: first_row + 8])) - # 2. Invalid target: one past the function's NOD range. + # 2. Invalid target label: no IR_LABEL node in this function carries it. bad_target = bytearray(clean) - struct.pack_into(" Date: Sat, 24 Oct 2026 11:28:32 +0800 Subject: [PATCH 21/22] test: update v25 CASE cache contract expectations --- src/compiler/cir_cache.cr | 4 +- src/compiler/main.cr | 5 +- tests/selfhost/test_multibranch_v10.py | 63 +++++++++++++++++++------- 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/src/compiler/cir_cache.cr b/src/compiler/cir_cache.cr index 6566def2..2f298bd7 100644 --- a/src/compiler/cir_cache.cr +++ b/src/compiler/cir_cache.cr @@ -266,8 +266,8 @@ fn save_cir_cache(path: string, source_fi: int, ir_fi: int) -> int: own_edge_count = 0 total_size = total_size + 8 + own_edge_count * 24 total_size = total_size + 8 + instr_count * 48 - // Per-function MATCH CASE section. Always present, even when empty, so the - // reader can parse every v24 snapshot with one layout. + // Per-function MATCH CASE model section. Always present, even when empty, + // so the reader can parse every v25 snapshot with one layout. total_size = total_size + match_size total_size = total_size + 8 size_si : ., mut = 0 diff --git a/src/compiler/main.cr b/src/compiler/main.cr index 19d39ee6..dde6d2e5 100644 --- a/src/compiler/main.cr +++ b/src/compiler/main.cr @@ -581,8 +581,9 @@ fn corec_main() -> int: df_end_func(ir_func_idx) // Save only when generation did not change snapshot-uncarried shared state. - // MATCH CASE ranges are part of the snapshot (v24), so a MATCH function - // is saved like any other. g_cir_has_match remains diagnostic only. + // MATCH CASE model rows are part of the snapshot (v25), so a MATCH + // function is saved like any other. g_cir_has_match remains + // diagnostic only. if cache_enabled != 0: if g_type_count == tc0: save_cir_cache(cache_path, fi, ir_func_idx) diff --git a/tests/selfhost/test_multibranch_v10.py b/tests/selfhost/test_multibranch_v10.py index 4f69b2be..c7693eb7 100644 --- a/tests/selfhost/test_multibranch_v10.py +++ b/tests/selfhost/test_multibranch_v10.py @@ -219,13 +219,13 @@ def _assert_soft_diagnostic(tmp, source, name, needles): # --------------------------------------------------------------------------- -# .cir snapshot fixtures (v24 layout) +# .cir snapshot fixtures (v25 layout) # --------------------------------------------------------------------------- # # The cache lives under /.core/cache/cir and is keyed by source path and -# function name. These helpers parse the v24 per-function snapshot independently -# so the tests can prove the MATCH CASE section round-trips, and can tamper with -# exactly that section. +# function name. These helpers parse the v25 per-function snapshot independently +# so the tests can prove the semantic MATCH CASE model round-trips, and can +# tamper with exactly that section. CIR_VERSION = 25 CIR_CASE_ROW = 24 @@ -2716,12 +2716,28 @@ def _assert_match_cold_warm(tmp, source, base, expected, fns): return snaps +def _case_model_counts(stdout): + """Parse the hidden --dump-case-model lines into (before, after) dicts.""" + before = {} + after = {} + for line in stdout.splitlines(): + parts = line.split() + if len(parts) != 3: + continue + if parts[0] == "case_model_before": + before = {"model": int(parts[1]), "wire": int(parts[2])} + elif parts[0] == "case_model_after": + after = {"model": int(parts[1]), "wire": int(parts[2])} + return before, after + + def test_case_model_is_semantic_before_lower(): """ir_gen fills only the semantic model; ccr_lower derives the wire table. The hidden channel reports both counts around lower_to_ccr. Before lowering the wire table must be empty while the model has rows; afterwards the wire - count must be non-zero and every MATCH range must index that wire table. + count must equal the model count and every MATCH range must index that wire + table. The warm run proves a restored model is also not pre-expanded to wire. """ with tempfile.TemporaryDirectory() as td: tmp = Path(td) @@ -2733,22 +2749,13 @@ def test_case_model_is_semantic_before_lower(): cwd=tmp, ) assert r.returncode == 0, f"corec ccr failed: {r.stdout}\n{r.stderr}" - before = {} - after = {} - for line in r.stdout.splitlines(): - parts = line.split() - if len(parts) != 3: - continue - if parts[0] == "case_model_before": - before = {"model": int(parts[1]), "wire": int(parts[2])} - elif parts[0] == "case_model_after": - after = {"model": int(parts[1]), "wire": int(parts[2])} + before, after = _case_model_counts(r.stdout) assert before, f"missing case_model_before: {r.stdout!r}" assert after, f"missing case_model_after: {r.stdout!r}" assert before["model"] > 0, before assert before["wire"] == 0, before assert after["model"] == before["model"], (before, after) - assert after["wire"] > 0, after + assert after["wire"] == before["model"], (before, after) data = ccr.read_bytes() validate(data) @@ -2764,6 +2771,30 @@ def test_case_model_is_semantic_before_lower(): assert len(rows) == after["wire"], (len(rows), after) _assert_targets_are_labels(data, rows) + # Warm run: the cache restores the semantic model, not the wire. The + # entries must hit, the pre-lower wire count must stay zero, and the + # derived .ccr must be byte-identical. + entries = [_cache_entry(tmp, fn) for fn in ("pick", "main")] + stamps = [_entry_stamp(entry) for entry in entries] + warm_ccr = tmp / "case_model_contract_warm.ccr" + time.sleep(0.02) + rw = _run( + [COREC, "ccr", src, "-o", warm_ccr, "--dump-case-model"], + cwd=tmp, + ) + assert rw.returncode == 0, f"warm corec ccr failed: {rw.stdout}\n{rw.stderr}" + warm_before, warm_after = _case_model_counts(rw.stdout) + assert warm_before, f"missing warm case_model_before: {rw.stdout!r}" + assert warm_after, f"missing warm case_model_after: {rw.stdout!r}" + for entry, stamp in zip(entries, stamps): + assert _entry_stamp(entry) == stamp, ( + f"{entry.name}: warm run did not hit the cache" + ) + assert warm_before["model"] == before["model"], (before, warm_before) + assert warm_before["wire"] == 0, warm_before + assert warm_after["wire"] == warm_before["model"], (warm_before, warm_after) + assert warm_ccr.read_bytes() == ccr.read_bytes(), "cold/warm .ccr differ" + def test_cir_cache_restores_match_cases_cold_warm(): """MATCH functions are saved, hit on the warm path, and rebuild byte-identically.""" From f2b7d8aa1f006cff6a1f80834281a3e1e480201c Mon Sep 17 00:00:00 2001 From: DslsDZC Date: Sat, 24 Oct 2026 11:28:32 +0800 Subject: [PATCH 22/22] os: centralize linux output file modes --- build_selfhost_native.py | 1 + src/arch/x86_64/corearch.cr | 6 +-- src/format/elf/ld.cr | 6 +-- src/os/linux/file_mode.cr | 8 ++++ src/targets/x86_64/linux/elf/_import.cr | 2 + src/targets/x86_64/linux/elf/main.cr | 6 +-- tests/selfhost/test_directory_build.py | 62 +++++++++++++++++++++++-- 7 files changed, 78 insertions(+), 13 deletions(-) create mode 100644 src/os/linux/file_mode.cr diff --git a/build_selfhost_native.py b/build_selfhost_native.py index 7fdbb8c1..f6291007 100644 --- a/build_selfhost_native.py +++ b/build_selfhost_native.py @@ -95,6 +95,7 @@ # + rax 回存;内置体名索引扫描段留 elf.cr 原位);跨轴消费 x86_64/instr.cr # 编码原语(e2_mov/e2_w8/e2_store_ret)。 os_linux_files = [ + 'src/os/linux/file_mode.cr', 'src/os/linux/entry.cr', 'src/os/linux/callseq.cr', 'src/os/linux/syscall.cr', diff --git a/src/arch/x86_64/corearch.cr b/src/arch/x86_64/corearch.cr index 86f46a67..074318c9 100644 --- a/src/arch/x86_64/corearch.cr +++ b/src/arch/x86_64/corearch.cr @@ -673,7 +673,7 @@ fn corearch_main() -> int: g_elf_buf = alloc(16777216) sz := elf_gen(g_elf_buf) w16(g_elf_buf, 16, 3) - fd := syscall3(2, out_path, 577, 493) + fd := syscall3(2, out_path, 577, linux_mode_rw()) if fd < 0: print("error: cannot write ") println(out_path) @@ -766,7 +766,7 @@ fn corearch_main() -> int: return 1 // Pure static: write directly; the frontend prepends rt.cr. ELF output // is executable (0755); .so output stays 0644. - fd := syscall3(2, out_path, 577, 493) + fd := syscall3(2, out_path, 577, linux_mode_exec()) if fd < 0: print("error: cannot write ") println(out_path) @@ -789,7 +789,7 @@ fn corearch_main() -> int: return 1 else: sz := elf_gen(g_elf_buf) - fd := syscall3(2, out_path, 577, 493) // 0755:可执行输出(同静态路径修复) if fd < 0 { print("error: cannot write "); println(out_path); return 1; } + fd := syscall3(2, out_path, 577, linux_mode_exec()) // 0755:可执行输出(同静态路径修复) if fd < 0 { print("error: cannot write "); println(out_path); return 1; } syscall3(1, fd, g_elf_buf, sz) syscall3(3, fd, 0, 0) print(" -> ") diff --git a/src/format/elf/ld.cr b/src/format/elf/ld.cr index fb8cc27b..5b164feb 100644 --- a/src/format/elf/ld.cr +++ b/src/format/elf/ld.cr @@ -670,7 +670,7 @@ fn ctx_emit_dyn(buf: string, path: string) -> int: total := cell_load(g_ch_vaddr, (g_ch_count - 1) * 8) + cell_load(g_ch_size, (g_ch_count - 1) * 8) - g_text_base println(int_str(total)) emit(buf, total, 0) - fd := syscall3(2, path, 577, 493) // 0755: ELF output must be executable + fd := syscall3(2, path, 577, linux_mode_exec()) // 0755: executable ELF output if fd < 0: return -1 nw := syscall3(1, fd, buf, total) @@ -685,7 +685,7 @@ fn ctx_emit_so(buf: string, path: string) -> int: layout() total := cell_load(g_ch_vaddr, (g_ch_count - 1) * 8) + cell_load(g_ch_size, (g_ch_count - 1) * 8) - g_text_base emit(buf, total, 1) - fd := syscall3(2, path, 577, 493) // 0755: ELF output must be executable + fd := syscall3(2, path, 577, linux_mode_rw()) // 0644: shared object, not executable if fd < 0: return -1 nw := syscall3(1, fd, buf, total) @@ -898,7 +898,7 @@ fn ctx_emit_static(buf: string, path: string) -> int: bss_page := 0x400000 + ((total + 4095) / 4096) * 4096 + 4096 write_phdr(buf, 1, 1, 6, 0, bss_page, bss_page, 0, 1073741824, 4096) - fd := syscall3(2, path, 577, 493) // 0755: ELF output must be executable + fd := syscall3(2, path, 577, linux_mode_exec()) // 0755: executable ELF output if fd < 0: return -1 nw := syscall3(1, fd, buf, total) diff --git a/src/os/linux/file_mode.cr b/src/os/linux/file_mode.cr new file mode 100644 index 00000000..7b6da6f1 --- /dev/null +++ b/src/os/linux/file_mode.cr @@ -0,0 +1,8 @@ +// Linux output file modes. +// +// These are Linux OS facts, not target architecture or ELF header facts: +// 0755 executable ELF, 0644 non-executable data/shared object. +fn linux_mode_exec() -> int: + return 493 +fn linux_mode_rw() -> int: + return 420 diff --git a/src/targets/x86_64/linux/elf/_import.cr b/src/targets/x86_64/linux/elf/_import.cr index c30399b1..237372ef 100644 --- a/src/targets/x86_64/linux/elf/_import.cr +++ b/src/targets/x86_64/linux/elf/_import.cr @@ -42,6 +42,8 @@ import frame import resolve import elf import ld +// Linux output file modes: linux_mode_exec and linux_mode_rw. +import file_mode // Linux entry sequence: emit_start and emit_start_size. import entry // SysV AMD64 call sequences: cs_args_dispatch, cs_stack_args, cs_stack_cleanup, diff --git a/src/targets/x86_64/linux/elf/main.cr b/src/targets/x86_64/linux/elf/main.cr index c9f3a1f4..981e001a 100644 --- a/src/targets/x86_64/linux/elf/main.cr +++ b/src/targets/x86_64/linux/elf/main.cr @@ -153,7 +153,7 @@ fn corearch_main() -> int: g_elf_buf = alloc(16777216) sz := elf_gen(g_elf_buf) w16(g_elf_buf, 16, 3) - fd := syscall3(2, out_path, 577, 493) + fd := syscall3(2, out_path, 577, linux_mode_rw()) if fd < 0: print("error: cannot write ") println(out_path) @@ -224,7 +224,7 @@ fn corearch_main() -> int: ctx_set_user_code(cd, cs) sz = ctx_emit_static(g_elf_buf, out_path) else: - fd := syscall3(2, out_path, 577, 493) // 0755: ELF output must be executable + fd := syscall3(2, out_path, 577, linux_mode_exec()) // 0755: executable ELF output if fd < 0: print("error: cannot write ") println(out_path) @@ -246,7 +246,7 @@ fn corearch_main() -> int: return 1 else: sz := elf_gen(g_elf_buf) - fd := syscall3(2, out_path, 577, 493) // 0755: ELF output must be executable + fd := syscall3(2, out_path, 577, linux_mode_exec()) // 0755: executable ELF output if fd < 0: print("error: cannot write ") println(out_path) diff --git a/tests/selfhost/test_directory_build.py b/tests/selfhost/test_directory_build.py index 2e8d2ee5..f60b81c7 100644 --- a/tests/selfhost/test_directory_build.py +++ b/tests/selfhost/test_directory_build.py @@ -61,8 +61,15 @@ def main() -> int: print(result.stderr) return 1 + exec_bits = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH output_mode = output.stat().st_mode - output.chmod(output_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + if not output_mode & exec_bits: + print( + f"[FAIL] directory output has no execute bit: mode {output_mode:o}" + ) + print(result.stdout) + print(result.stderr) + return 1 run_result = subprocess.run([str(output)], cwd=project, timeout=30) if run_result.returncode != 23: print(f"[FAIL] directory output returned {run_result.returncode}, expected 23") @@ -73,6 +80,48 @@ def main() -> int: "[PASS] `corec build .` resolves _import.cr and emits a working ELF" ) + # Shared outputs (.so) are data, not executables: Linux mode 0644. + shared_ccr = project / "shared.ccr" + shared_compile = run_corec( + ["ccr", ".", "--static", "-o", str(shared_ccr), "--opt-level", "0"], + project, + ) + if shared_compile.returncode != 0 or not shared_ccr.exists(): + print(f"[FAIL] shared ccr build exited {shared_compile.returncode}") + print(shared_compile.stdout) + print(shared_compile.stderr) + return 1 + shared_output = project / "libdirectory_build_smoke.so" + shared_result = subprocess.run( + [ + "nice", + "-n", + "19", + str(COREARCH), + str(shared_ccr), + "--elf", + "--shared", + "-o", + str(shared_output), + ], + cwd=project, + capture_output=True, + text=True, + timeout=180, + ) + if shared_result.returncode != 0 or not shared_output.exists(): + print(f"[FAIL] --shared build exited {shared_result.returncode}") + print(shared_result.stdout) + print(shared_result.stderr) + return 1 + shared_mode = shared_output.stat().st_mode & 0o777 + if shared_mode != 0o644: + print( + f"[FAIL] --shared output mode is {shared_mode:o}, expected 644" + ) + return 1 + print("[PASS] --shared output is mode 0644, not executable") + (project / "helper.cr").write_text( "fn imported_result() -> int:\n return 25\n", encoding="utf-8" ) @@ -103,9 +152,14 @@ def main() -> int: print(unnamed_result.stdout) print(unnamed_result.stderr) return 1 - unnamed_output.chmod( - unnamed_output.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH - ) + if not unnamed_output.stat().st_mode & exec_bits: + print( + "[FAIL] unnamed directory output has no execute bit: mode " + f"{unnamed_output.stat().st_mode:o}" + ) + print(unnamed_result.stdout) + print(unnamed_result.stderr) + return 1 unnamed_run = subprocess.run([str(unnamed_output)], cwd=unnamed, timeout=30) if unnamed_run.returncode != 24: print(