@asmlift/core 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +22 -16
  2. package/package.json +1 -1
  3. package/src/backend/c.ts +1 -0
  4. package/src/backend/cfamily.ts +238 -164
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +341 -22
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +24 -6
  10. package/src/frontend/opaque.ts +31 -18
  11. package/src/frontend/ppc.ts +54 -7
  12. package/src/frontend/ssa.ts +632 -13
  13. package/src/frontend/thumb.ts +2786 -286
  14. package/src/ir/alias.ts +129 -0
  15. package/src/ir/bits.ts +75 -0
  16. package/src/ir/core.ts +337 -2
  17. package/src/ir/opcodes.ts +156 -27
  18. package/src/ir/parse.ts +19 -2
  19. package/src/ir/print.ts +27 -2
  20. package/src/ir/simplify.ts +190 -3
  21. package/src/ir/struct-names.ts +42 -0
  22. package/src/ir/verify.ts +43 -49
  23. package/src/l3/address.ts +62 -0
  24. package/src/l3/argbase.ts +8 -2
  25. package/src/l3/ast.ts +464 -49
  26. package/src/l3/basecse.ts +709 -88
  27. package/src/l3/coalesce.ts +521 -66
  28. package/src/l3/dce.ts +54 -19
  29. package/src/l3/gates.ts +88 -0
  30. package/src/l3/hoist.ts +293 -14
  31. package/src/l3/homesplit.ts +285 -0
  32. package/src/l3/initfirst.ts +301 -0
  33. package/src/l3/inlinebase.ts +193 -0
  34. package/src/l3/mentions.ts +113 -0
  35. package/src/l3/mulfirst.ts +42 -0
  36. package/src/l3/nearbase.ts +152 -0
  37. package/src/l3/offmember.ts +371 -0
  38. package/src/l3/parkfirst.ts +96 -0
  39. package/src/l3/pollguard.ts +154 -0
  40. package/src/l3/ptrfield.ts +227 -0
  41. package/src/l3/regspell.ts +110 -85
  42. package/src/l3/reindex.ts +715 -78
  43. package/src/l3/scopebase.ts +649 -219
  44. package/src/l3/sinkinit.ts +40 -0
  45. package/src/l3/slotorder.ts +123 -0
  46. package/src/l3/storage.ts +48 -0
  47. package/src/l3/symbol-refs.ts +41 -8
  48. package/src/l3/tailmerge.ts +23 -4
  49. package/src/l3/typing.ts +198 -9
  50. package/src/l3/unmerge.ts +263 -0
  51. package/src/l3/unreduce.ts +971 -0
  52. package/src/l3/volatileptr.ts +207 -0
  53. package/src/l3/volatileval.ts +130 -0
  54. package/src/l3/volstore.ts +229 -0
  55. package/src/l3/zerosub.ts +62 -0
  56. package/src/pattern/engine.ts +236 -13
  57. package/src/pipeline.ts +206 -49
  58. package/src/proto.ts +112 -14
  59. package/src/raise/arrays.ts +6 -1
  60. package/src/raise/divpow2.ts +4 -3
  61. package/src/raise/globalshape.ts +1038 -0
  62. package/src/raise/gvn.ts +44 -19
  63. package/src/raise/latch.ts +126 -0
  64. package/src/raise/memberarrays.ts +594 -0
  65. package/src/raise/narrow.ts +124 -0
  66. package/src/raise/narrowlocal.ts +556 -0
  67. package/src/raise/paramwidth.ts +179 -0
  68. package/src/raise/pre-recovery.ts +101 -16
  69. package/src/raise/recover.ts +56 -23
  70. package/src/raise/retsink.ts +215 -14
  71. package/src/raise/shortcircuit.ts +477 -79
  72. package/src/raise/struct-arrays.ts +21 -3
  73. package/src/raise/structs.ts +61 -3
  74. package/src/rank-axes.ts +630 -0
  75. package/src/rank-declare.ts +256 -0
  76. package/src/rank.ts +1726 -251
  77. package/src/structure/analysis.ts +1516 -220
  78. package/src/structure/bitfields.ts +332 -0
  79. package/src/structure/globalaccess.ts +274 -0
  80. package/src/structure/hazards.ts +411 -20
  81. package/src/structure/loops.ts +2 -49
  82. package/src/structure/namecoalesce.ts +435 -0
  83. package/src/structure/structure.ts +2850 -533
  84. package/src/structure/switch-recover.ts +688 -147
  85. package/src/symbols.ts +62 -1
  86. package/src/target.ts +367 -24
  87. package/src/trace.ts +111 -32
@@ -4,7 +4,8 @@
4
4
  // factory takes its dependencies EXPLICITLY (`SwitchRecoverDeps`); `expr`/`structureRegion` are
5
5
  // late-bound callbacks into the emission phase, so case bodies reuse the ordinary structuring
6
6
  // machinery (loops/ifs inside cases, the onStack guard).
7
- import { Block, Fn, Op, Value, successorsOf } from '../ir/core';
7
+ import { Block, Fn, Op, Value, forwardingTarget, isBodyless, successorsOf } from '../ir/core';
8
+ import { ORDER_SENSITIVE_OPS } from '../ir/opcodes';
8
9
  import { Expr, Stmt, SwitchCase } from '../l3/ast';
9
10
 
10
11
  export interface SwitchRecoverDeps {
@@ -18,20 +19,220 @@ export interface SwitchRecoverDeps {
18
19
  /** is this opcode an integer comparison? */
19
20
  isCmpOpcode: (opcode: string) => boolean;
20
21
  switchAllowsNeqCase: boolean;
21
- /** does emitting this block's ops carry a statement beyond the ops themselves? A def-site
22
- * ANCHORED merge copy (structure.ts anchorConstCopies) is attached to a const op and emitted
23
- * with the block's side effects a test block carrying one is not pure however pure its
24
- * opcodes look, because collapsing it into a `switch` discards the write while the edge copy
25
- * it replaced stays suppressed. */
26
- emitsAnchoredWrite: (blk: Block) => boolean;
22
+ /** read a relational test whose BRANCH admits exactly one scrutinee value as that case */
23
+ switchAllowsBoundCase: boolean;
24
+ /** emit the case arms in the ASSEMBLY's block-layout order rather than by ascending case value */
25
+ switchArmsFollowLayout: boolean;
26
+ /** may the emitted SOURCE say "this arm runs on into the next one"? False for a language whose
27
+ * `case` cannot fall through (Pascal), and then Regime A declines a falling arm to if-recovery
28
+ * — the behaviourally identical recovery that backend CAN print. See StructureOptions. */
29
+ spellSwitchFallthrough: boolean;
30
+ /** does emitting this block's ops carry a statement beyond the ops themselves? Collapsing a
31
+ * test block into a `switch` re-renders its ops at their uses and emits no side effects for it,
32
+ * so any op that renders as a STATEMENT of its own loses that statement. Two produce one: a
33
+ * def-site ANCHORED merge copy (structure.ts anchorConstCopies), whose edge copy stays
34
+ * suppressed, and a MATERIALIZED def, whose `v = …` assignment renders only here while its uses
35
+ * read the bare name — leaving a local declared and never assigned. */
36
+ emitsOwnStatement: (blk: Block) => boolean;
37
+ /** Where a value is WRITTEN: its block for a parameter, its def op's block otherwise — the half
38
+ * of "where is this defined" `defs` does not answer. Taken from `structure.ts`, which builds
39
+ * both halves for def-site anchoring, rather than indexing the parameters a second time. */
40
+ blockOf: (v: Value) => Block | undefined;
41
+ /** THE DISPATCH HOIST: the copies of every edge the tree walk collapsed, merged and re-emitted
42
+ * ONCE ahead of the `switch`. `structure.ts hoistedDispatchAssigns` owns the emission
43
+ * (suppression, identity elision, `undef`, the write-order sort, `sequentialize`), so this
44
+ * regime takes it as a dependency rather than implementing it a second time, exactly as Regime
45
+ * B takes `argAssignsFor`. Null ⇒ no single hoisted statement spells them; the caller declines
46
+ * to if-recovery. `liveAt` are the blocks whose live-in names the writes must not clobber. */
47
+ hoistDispatchCopies: (
48
+ edges: readonly { pred: Block; succ: { block: Block; args: Value[] } }[],
49
+ liveAt: readonly Block[],
50
+ ) => Stmt[] | null;
27
51
  expr: (v: Value) => Expr;
28
52
  structureRegion: (b: Block, stop: Block | null) => Stmt[];
29
53
  }
30
54
 
55
+ /** Where ONE switch arm's region leaves it — the fact that decides whether the arm can be spelled
56
+ * as C at all, and with or without a `break`.
57
+ *
58
+ * - `break` every path out of the arm reaches the switch's merge (or returns / loops
59
+ * inside the arm). The ordinary closed arm.
60
+ * - `fallthrough` every path out leaves into exactly ONE sibling arm's entry: C's fall-through.
61
+ * Only spellable when that sibling is the arm emitted NEXT, which `chainArms`
62
+ * below arranges and both regimes then re-read off the emission array (see the
63
+ * l3/ast.ts non-neutrality note).
64
+ * - `unstructurable` anything else: two different siblings, or a mix of "into a sibling" and
65
+ * "out to the merge". C needs a `goto` for those. Regime A declines to
66
+ * if-recovery on this verdict; Regime B, having no fallback, fails LOUD. */
67
+ export type ArmExit = { kind: 'break' } | { kind: 'fallthrough'; to: Block } | { kind: 'unstructurable'; why: string };
68
+
31
69
  export interface SwitchRecovery {
32
70
  recognizeSwitch: (b: Block, stop: Block | null) => Stmt[] | null;
33
- /** shared with the Regime-B (`switch_br`) path in structure.ts, which throws where A declines */
34
- caseRegionReachesSibling: (targets: Set<Block>, b: Block, merge: Block | null) => boolean;
71
+ /** shared with the Regime-B (`switch_br`) path in structure.ts. Both regimes recover the
72
+ * fall-through this returns; on an `unstructurable` verdict Regime A declines to if-recovery and
73
+ * Regime B, which has no fallback, fails loud. */
74
+ analyzeArmExit: (entry: Block, b: Block, merge: Block | null, siblings: Set<Block>) => ArmExit;
75
+ /** a block's position in the ASSEMBLY — the arm-order evidence, shared with Regime B so the two
76
+ * regimes read it from one definition (and one statement of what it rests on). */
77
+ layoutIndex: (blk: Block) => number;
78
+ /** where the `default:` label goes among the EMITTED arms, or `undefined` for C's last position —
79
+ * shared with Regime B so both regimes state those refusals once. */
80
+ defaultLayoutPos: (
81
+ defaultBlk: Block,
82
+ arms: readonly { entry: Block; fallsThrough: boolean }[],
83
+ opts: { placedByDispatch: boolean; orderIntact: boolean },
84
+ ) => number | undefined;
85
+ /** ONE linear emission order for a set of arms, re-threaded so every falling arm sits directly
86
+ * above the one it falls into — or null when no linear order spells them. Shared with Regime B,
87
+ * so the chain, the adjacency it guarantees and the three refusals below it have one definition
88
+ * (Regime A maps null to if-recovery, Regime B to a loud StructureError). */
89
+ chainArms: (order: Block[], dflt: Block | null, exitOf: Map<Block, ArmExit>) => Block[] | null;
90
+ }
91
+
92
+ /** A block with no body of its own: no params, and one op that only LEAVES. `ret` qualifies as
93
+ * well as `br` because raise/retsink.ts rewrites the one into the other — a cross-jumped arm
94
+ * body has two dispatch preds, which is exactly the shape that makes retsink sink the merge's
95
+ * return into every leaf, the fall-out jumps included. */
96
+ function isBareExit(blk: Block): boolean {
97
+ return isBodyless(blk) && (blk.ops[0].opcode === 'br' || blk.ops[0].opcode === 'ret');
98
+ }
99
+
100
+ /** Are these two blocks the SAME bare exit — the same jump with the same args, or the same return
101
+ * of the same values? Neither has a body, so two of them are indistinguishable at emission. */
102
+ function sameBareExit(a: Block, c: Block): boolean {
103
+ if (a === c) {
104
+ return true;
105
+ }
106
+ if (!isBareExit(a) || !isBareExit(c) || a.ops[0].opcode !== c.ops[0].opcode) {
107
+ return false;
108
+ }
109
+ const same = (x: readonly Value[], y: readonly Value[]) => x.length === y.length && x.every((v, i) => v === y[i]);
110
+ if (a.ops[0].opcode === 'ret') {
111
+ return same(a.ops[0].operands, c.ops[0].operands);
112
+ }
113
+ const [x, y] = [a, c].map((blk) => blk.ops[0].successors[0]);
114
+ return x.block === y.block && same(x.args, y.args);
115
+ }
116
+
117
+ export interface TestInfo {
118
+ x: Value;
119
+ k: number;
120
+ cls: 'eq' | 'ne' | 'rel';
121
+ opcode: string;
122
+ xOnLeft: boolean;
123
+ }
124
+
125
+ // Evaluate a test predicate for a CONCRETE scrutinee value — used to SIMULATE the decision tree and
126
+ // verify recovered case values (below). Returns true iff the `taken` (successors[0]) edge is followed.
127
+ // Signed/unsigned per the icmp opcode (PRE3, done concretely rather than via interval lattices).
128
+ function evalCmp(opcode: string, xOnLeft: boolean, xv: number, k: number): boolean {
129
+ const uns = opcode.startsWith('icmp_u');
130
+ const [xn, kn] = uns ? [xv >>> 0, k >>> 0] : [xv | 0, k | 0];
131
+ const [l, r] = xOnLeft ? [xn, kn] : [kn, xn]; // put the scrutinee where it textually appears
132
+ switch (opcode) {
133
+ case 'icmp_eq':
134
+ return l === r;
135
+ case 'icmp_ne':
136
+ return l !== r;
137
+ case 'icmp_slt':
138
+ case 'icmp_ult':
139
+ return l < r;
140
+ case 'icmp_sle':
141
+ case 'icmp_ule':
142
+ return l <= r;
143
+ case 'icmp_sgt':
144
+ case 'icmp_ugt':
145
+ return l > r;
146
+ case 'icmp_sge':
147
+ case 'icmp_uge':
148
+ return l >= r;
149
+ default:
150
+ return false;
151
+ }
152
+ }
153
+
154
+ // Which single scrutinee value does this relational test's BRANCH admit, if exactly one? A
155
+ // relational side is a HALF-LINE in the compare's own ordering, so it can hold one value only at
156
+ // a domain endpoint — which is why testing the two endpoints and their neighbours decides it,
157
+ // with no interval lattice. `x < 1` over an unsigned scrutinee admits `{0}` and is agbcc's
158
+ // spelling of `case 0` in a balanced search: `emit_case_nodes` tests the subtree BOUND, not the
159
+ // value, whenever the remaining range has collapsed to one. Read as navigation instead, that
160
+ // arm's body becomes a second default candidate and the whole tree declines.
161
+ //
162
+ // THE BRANCH, never the fall-through. Every jump in `emit_case_nodes` that lands on a case body
163
+ // is its test's BRANCH — for a single-valued node, LT to `node->left->code_label` and GT to
164
+ // `node->right->code_label`, each guarded by `node_is_bounded` on that side — while the
165
+ // fall-through always continues into more dispatch, so a fall-side reading has no producer in
166
+ // this dispatch — and none turns up in 3176 generated agbcc dispatches.
167
+ //
168
+ // TWO PREMISES ABOUT THE DOMAIN. It is the 32-bit REGISTER's, not the scrutinee's recovered
169
+ // type, so a narrower type has a nearer endpoint this misses — which costs a case and never
170
+ // invents one. And it is the WHOLE of that domain, so an ancestor that already excluded the
171
+ // value makes the reading wrong; PRE3 is what catches that, simulating the original tree for
172
+ // every recovered case value and declining unless it lands on the recorded body, exactly as it
173
+ // does for the `eq` cases. Null when the branch admits none, several, or the whole domain.
174
+ function singletonTaken(ti: TestInfo): number | null {
175
+ const [min, max] = ti.opcode.startsWith('icmp_u') ? [0, -1] : [-0x80000000, 0x7fffffff];
176
+ for (const [v, next] of [
177
+ [min, min + 1],
178
+ [max, max - 1],
179
+ ]) {
180
+ if (evalCmp(ti.opcode, ti.xOnLeft, v, ti.k) && !evalCmp(ti.opcode, ti.xOnLeft, next, ti.k)) {
181
+ return v;
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+
187
+ /** Re-thread `order` so every FALLING arm sits directly above the arm it falls into. Each
188
+ * fall-through chain is emitted contiguously and takes the position of its HEAD in `order`,
189
+ * which is the caller's own arm-order policy — so with no fall-through every chain is a
190
+ * singleton and `order` comes back unchanged. `dflt` is the `default:` arm's block when it has
191
+ * one, and it is pinned LAST because that is where C prints the label.
192
+ *
193
+ * THREE REFUSALS (null ⇒ the caller declines), each a shape no single linear order spells:
194
+ * - two arms falling into the SAME arm — C drops into an arm from above along one edge only;
195
+ * - the `default:` arm falling into a case, since nothing is emitted below it;
196
+ * - a fall-through CYCLE, whose members are all fallen-into and so are never a chain head. */
197
+ export function chainArms(order: Block[], dflt: Block | null, exitOf: Map<Block, ArmExit>): Block[] | null {
198
+ const next = new Map<Block, Block>();
199
+ const fallenInto = new Set<Block>();
200
+ for (const e of [...order, ...(dflt ? [dflt] : [])]) {
201
+ const x = exitOf.get(e);
202
+ if (x?.kind !== 'fallthrough') {
203
+ continue;
204
+ }
205
+ if (e === dflt || fallenInto.has(x.to)) {
206
+ return null;
207
+ }
208
+ fallenInto.add(x.to);
209
+ next.set(e, x.to);
210
+ }
211
+ const chains: Block[][] = [];
212
+ const seen = new Set<Block>();
213
+ let intoDefault = -1;
214
+ for (const head of order) {
215
+ if (fallenInto.has(head)) {
216
+ continue;
217
+ }
218
+ const chain: Block[] = [];
219
+ for (let cur: Block | undefined = head; cur !== undefined && cur !== dflt && !seen.has(cur); cur = next.get(cur)) {
220
+ seen.add(cur);
221
+ chain.push(cur);
222
+ }
223
+ if (dflt !== null && next.get(chain[chain.length - 1]) === dflt) {
224
+ intoDefault = chains.length;
225
+ }
226
+ chains.push(chain);
227
+ }
228
+ // A cycle's every member is fallen-into, so none of them is a head and none is walked.
229
+ if (seen.size !== order.length) {
230
+ return null;
231
+ }
232
+ if (intoDefault >= 0) {
233
+ chains.push(...chains.splice(intoDefault, 1));
234
+ }
235
+ return chains.flat();
35
236
  }
36
237
 
37
238
  export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
@@ -44,14 +245,100 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
44
245
  isNamed,
45
246
  isCmpOpcode,
46
247
  switchAllowsNeqCase,
47
- emitsAnchoredWrite,
248
+ switchAllowsBoundCase,
249
+ switchArmsFollowLayout,
250
+ spellSwitchFallthrough,
251
+ emitsOwnStatement,
252
+ blockOf,
253
+ hoistDispatchCopies,
48
254
  expr,
49
255
  structureRegion,
50
256
  } = deps;
51
257
 
258
+ // A block's index in `fn.blocks` as its position in the ASSEMBLY — the sole warrant for reading a
259
+ // source's arm order off block indices below, and true PER FRONTEND rather than of the IR:
260
+ // - thumb.ts and mips.ts build the list by scanning the instruction stream in address order;
261
+ // - ppc.ts does not. It APPENDS a synthetic return block (`synthReturn`) at the end of the list
262
+ // for every conditional-return branch, wherever in the stream that branch sits, so its list
263
+ // is not address order at all;
264
+ // - raising only ever REMOVES blocks from the list (raise/{divpow2,latch,retsink,shortcircuit}
265
+ // .ts all `filter`), never inserts or reorders, so the frontend's order is what survives.
266
+ // `switchArmsFollowLayout` is therefore a claim about a target's FRONTEND as much as about its
267
+ // compiler, and a target opts in on both — which is why PPC_MWCC, whose frontend fails the first
268
+ // half, does not.
269
+ const blockIndex = new Map(fn.blocks.map((blk, i) => [blk, i] as const));
270
+ const layoutIndex = (blk: Block): number => blockIndex.get(blk) ?? -1;
271
+
272
+ // Where the `default:` label goes among the arms, as a COUNT of the arms laid out before it — the
273
+ // same evidence the case bodies carry, read the same way. Compiled at every position of a 3- to
274
+ // 8-case switch, the default's block lands where the source wrote it. `undefined` ⇒ C's
275
+ // conventional last position, which is what every other producer of this node means.
276
+ //
277
+ // SEVEN WITHHOLDINGS, W1..W7, numbered in the order the code asks them so the prose and the code
278
+ // index ONE list, and stated here so both regimes state them once. W1 is the target's own opt-in;
279
+ // W2..W4 are about a block the DISPATCH placed rather than the arm; W5..W7 are about
280
+ // fall-through — W5 about the LIST the count would index, W6 and W7 about the POSITION it names.
281
+ //
282
+ // W1 the target does not read arm order off the layout at all (`switchArmsFollowLayout`).
283
+ // W2 a block with no body of its own is one the dispatch minted (`b .Ldefault`), and which of
284
+ // several such the collapse below keeps is a walk-order accident.
285
+ // W3 the walk already read that block as a CASE arm — a dense table sends every unwritten
286
+ // value's slot to the default's block, so grouping the slots gives that block an arm of its
287
+ // own and its index is where THAT arm sits.
288
+ // W4 `emit_case_nodes` ends every exhausted subtree with `emit_jump_if_reachable
289
+ // (default_label)` and `expand_end_case` reorders the whole dispatch, those jumps included,
290
+ // ahead of the arm bodies — so a jump survives as a plain FALL-THROUGH exactly when the
291
+ // default's body is the arm the source wrote FIRST. That reading holds only while a second
292
+ // subtree still names the label: a two-case chain names it once, and agbcc then lays that
293
+ // block right after the tests whatever the source wrote, both spellings compiling to
294
+ // identical instructions.
295
+ // W5 the chain RE-THREADED the arm order (`orderIntact` false), so the emitted list is no
296
+ // longer the one the layout count describes and no position in it means what the count
297
+ // says. This one is whole-switch because the re-threading is. A per-position reading —
298
+ // bracket the label between the two arms that straddle it in LAYOUT, then map that into the
299
+ // emitted list — is possible and unbuilt, and hard to need: a compiler that lays bodies out
300
+ // in source order already writes a falling arm directly above its target, so the order it
301
+ // declares is a chain order too.
302
+ // W6 the LAST emitted arm falls through, which can only be into the default (the adjacency
303
+ // check leaves no other target) — the label must then be last, which IS `undefined`.
304
+ // W7 the position lands directly after a falling arm, where printing the label would divert
305
+ // that arm into the default. cfamily.ts fails loud on exactly that, and this is the producer
306
+ // side of the same rule.
307
+ //
308
+ // A switch with a chain elsewhere keeps its evidence: the reason to withhold is the position,
309
+ // never "some arm somewhere falls".
310
+ const defaultLayoutPos = (
311
+ defaultBlk: Block,
312
+ arms: readonly { entry: Block; fallsThrough: boolean }[],
313
+ opts: { placedByDispatch: boolean; orderIntact: boolean },
314
+ ): number | undefined => {
315
+ if (!switchArmsFollowLayout) {
316
+ return undefined; // W1 target does not read layout
317
+ }
318
+ if (isBareExit(defaultBlk)) {
319
+ return undefined; // W2 dispatch-minted bodyless block
320
+ }
321
+ if (arms.some((a) => a.entry === defaultBlk)) {
322
+ return undefined; // W3 already an emitted arm
323
+ }
324
+ if (opts.placedByDispatch) {
325
+ return undefined; // W4 dispatch ran out into it
326
+ }
327
+ if (!opts.orderIntact) {
328
+ return undefined; // W5 chain re-threaded the arms
329
+ }
330
+ if (arms[arms.length - 1]?.fallsThrough) {
331
+ return undefined; // W6 last arm falls through
332
+ }
333
+ const at = arms.filter((a) => layoutIndex(a.entry) < layoutIndex(defaultBlk)).length;
334
+ // W7 the label would land directly after a falling arm
335
+ return at > 0 && arms[at - 1].fallsThrough ? undefined : at;
336
+ };
337
+
52
338
  // --- Regime A: comparison-tree switch recovery ----------------------------------------------------
53
339
  // Every ambiguity declines. Four preconditions are enforced below, annotated PRE1..PRE4:
54
- // scrutinee identity/dominance, no fall-through, concrete interval consistency, test purity.
340
+ // scrutinee identity/dominance, ARM EXITS (per site: `break`, `fallthrough` — which `chainArms`
341
+ // then places — or a decline), concrete interval consistency, test purity.
55
342
 
56
343
  // Fold a value that is a compile-time constant (a `const`, or a synthesized immediate like agbcc's
57
344
  // `250 << 2` for a large sparse case) to a number — else null.
@@ -124,20 +411,23 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
124
411
  }
125
412
  };
126
413
 
127
- // A "pure test block": its only computation is constants + one integer comparison feeding its
128
- // cond_br terminator (no store/call/load/opaque its body is DISCARDED when the tree collapses to a
129
- // switch, so a side effect there would be lost). PRE4 (purity). The root block is exempt from the
130
- // "only const/icmp" rule because its non-terminator ops are already emitted as sideEffects(b) before
131
- // the switch; a non-root test block must be strictly pure.
132
- const SIDE_EFFECTFUL = new Set(['store', 'astore', 'call', 'load', 'aload', 'opaque']);
133
- interface TestInfo {
134
- x: Value;
135
- k: number;
136
- cls: 'eq' | 'ne' | 'rel';
137
- opcode: string;
138
- xOnLeft: boolean;
139
- }
140
- const testInfo = (blk: Block, isRoot: boolean): TestInfo | null => {
414
+ // TWO QUESTIONS about a block, asked separately because the answers diverge and the walk needs
415
+ // both: WHAT does it test (`testInfo`), and may this recovery DISCARD it (`collapsible`)?
416
+ //
417
+ // PRE4 (purity) is the second. A collapsed test block's ops re-render at whichever use inlines
418
+ // them, at a point the switch decides so the question is motion, not deletion, and
419
+ // `ORDER_SENSITIVE_OPS` is the set that asks it. NOT the trapping divides: a use is dominated by
420
+ // its def, so the re-rendered op runs on a subset of the paths it already ran on — nothing is
421
+ // speculated. `emitsOwnStatement` covers what motion cannot save: a statement belonging to the
422
+ // block rather than to a use. The root is exempt from all of it — its ops are already emitted as
423
+ // sideEffects(b) before the switch.
424
+ //
425
+ // A block that tests the scrutinee and is NOT collapsible is still dispatch, so the walk must
426
+ // read it as dispatch and decline, never re-read it as a case body — that would spell an arm
427
+ // whose guard the dispatch has already decided.
428
+ const collapsible = (blk: Block): boolean =>
429
+ !blk.ops.some((op) => ORDER_SENSITIVE_OPS.has(op.opcode)) && !emitsOwnStatement(blk);
430
+ const testInfo = (blk: Block): TestInfo | null => {
141
431
  const term = blk.ops[blk.ops.length - 1];
142
432
  if (term.opcode !== 'cond_br') {
143
433
  return null;
@@ -146,9 +436,6 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
146
436
  if (!cmp || !isCmpOpcode(cmp.opcode)) {
147
437
  return null;
148
438
  }
149
- if (!isRoot && (blk.ops.some((op) => SIDE_EFFECTFUL.has(op.opcode)) || emitsAnchoredWrite(blk))) {
150
- return null;
151
- } // PRE4 — anchored writes included: discarded with the block, while their edge copies stay suppressed
152
439
  // Which operand is the scrutinee, which is the constant?
153
440
  const [lo, ro] = cmp.operands;
154
441
  const lc = evalConst(lo),
@@ -169,64 +456,80 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
169
456
  return { x, k, cls, opcode: cmp.opcode, xOnLeft };
170
457
  };
171
458
 
172
- // Evaluate a test predicate for a CONCRETE scrutinee value used to SIMULATE the decision tree and
173
- // verify recovered case values (below). Returns true iff the `taken` (successors[0]) edge is followed.
174
- // Signed/unsigned per the icmp opcode (PRE3, done concretely rather than via interval lattices).
175
- const evalCmp = (opcode: string, xOnLeft: boolean, xv: number, k: number): boolean => {
176
- const uns = opcode.startsWith('icmp_u');
177
- const [xn, kn] = uns ? [xv >>> 0, k >>> 0] : [xv | 0, k | 0];
178
- const [l, r] = xOnLeft ? [xn, kn] : [kn, xn]; // put the scrutinee where it textually appears
179
- switch (opcode) {
180
- case 'icmp_eq':
181
- return l === r;
182
- case 'icmp_ne':
183
- return l !== r;
184
- case 'icmp_slt':
185
- case 'icmp_ult':
186
- return l < r;
187
- case 'icmp_sle':
188
- case 'icmp_ule':
189
- return l <= r;
190
- case 'icmp_sgt':
191
- case 'icmp_ugt':
192
- return l > r;
193
- case 'icmp_sge':
194
- case 'icmp_uge':
195
- return l >= r;
196
- default:
197
- return false;
459
+ // Where does one arm's region LEAVE? Walk it from `entry`, never stepping THROUGH the merge or a
460
+ // sibling arm's entry, and classify what it steps INTO. `siblings` is every OTHER arm entry the
461
+ // caller can emit a `case`/`default` label for the merge is deliberately not among them, so a
462
+ // switch whose default block IS the merge (agbcc's usual "the default just leaves") reads as an
463
+ // ordinary `break`, not as falling into the default.
464
+ //
465
+ // Region membership is `dom(blk) b`: a block NOT dominated by the switch is outside this
466
+ // switch's region and is not walked. It IS recorded as an escape, because an arm that can leave
467
+ // sideways does not fall into the next case — and only the FALL-THROUGH verdict consults that
468
+ // record, so an arm that reaches no sibling still closes with a plain `break`.
469
+ //
470
+ // A CONSEQUENCE, not a hole: a sibling reachable only THROUGH such a block is never seen, so the
471
+ // arm reads as closed and `structureRegion` walks into the sibling's blocks and emits them again
472
+ // under this arm. That is duplication, not a wrong dispatch — the same duplication the structurer
473
+ // already does for any tail two arms share, and how the case bodies agbcc tail-merged are put
474
+ // back. Costly for matching, correct to run.
475
+ const analyzeArmExit = (entry: Block, b: Block, merge: Block | null, siblings: Set<Block>): ArmExit => {
476
+ if (entry === merge) {
477
+ return { kind: 'break' }; // an empty arm (a table slot pointing straight at the switch's end)
198
478
  }
199
- };
200
-
201
- // Can any case/default entry's region reach a SIBLING entry (switch fall-through)? Region =
202
- // blocks strictly dominated by `b`, short of `merge`. Shared by Regime A (declines to
203
- // if-recovery) and Regime B (throws — a jump-table has no fallback).
204
- const caseRegionReachesSibling = (targets: Set<Block>, b: Block, merge: Block | null): boolean => {
205
- const inRegion = (blk: Block) => blk !== merge && dom.get(blk)!.has(b);
206
- for (const entry of targets) {
207
- const rseen = new Set<Block>([entry]);
208
- const q = [entry];
209
- while (q.length) {
210
- const cur = q.pop()!;
211
- for (const s of successorsOf(cur)) {
212
- if (s === entry) {
479
+ const into = new Set<Block>(); // sibling entries this arm flows into
480
+ let toMerge = false,
481
+ escapes = false;
482
+ const seen = new Set<Block>([entry]);
483
+ const q = [entry];
484
+ while (q.length) {
485
+ const cur = q.pop()!;
486
+ for (const s of successorsOf(cur)) {
487
+ if (s === merge) {
488
+ toMerge = true;
489
+ } else if (s !== entry && siblings.has(s)) {
490
+ into.add(s);
491
+ } else if (s !== entry && !seen.has(s)) {
492
+ if (!dom.get(s)!.has(b)) {
493
+ escapes = true;
213
494
  continue;
214
495
  }
215
- if (targets.has(s)) {
216
- return true;
217
- }
218
- if (inRegion(s) && !rseen.has(s)) {
219
- rseen.add(s);
220
- q.push(s);
221
- }
496
+ seen.add(s);
497
+ q.push(s);
222
498
  }
223
499
  }
224
500
  }
225
- return false;
501
+ if (into.size === 0) {
502
+ return { kind: 'break' };
503
+ }
504
+ if (into.size === 1 && !toMerge && !escapes) {
505
+ return { kind: 'fallthrough', to: [...into][0] };
506
+ }
507
+ // Name what is actually missing. These three are different facts, and only the first is a shape
508
+ // C has no spelling for — the other two are asmlift's own limits, so say so rather than blame C.
509
+ const names = () => [...into].map((x) => `#${layoutIndex(x)}`).join(', ');
510
+ if (into.size > 1) {
511
+ return {
512
+ kind: 'unstructurable',
513
+ why: `a case body reaches several sibling cases (${names()}) — C fall-through reaches only one, so this needs a goto`,
514
+ };
515
+ }
516
+ if (escapes) {
517
+ return {
518
+ kind: 'unstructurable',
519
+ why: `a case body reaches sibling case ${names()} on one path and, on another, a block the switch does not dominate`,
520
+ };
521
+ }
522
+ return {
523
+ kind: 'unstructurable',
524
+ // `case 0: if (c) { …; break; } /* fall through */ case 1:` is the C for this, and the reason
525
+ // asmlift cannot write it is its own: `{k:'break'}` is emitted only for the innermost LOOP
526
+ // (l3/ast.ts), never switch-scoped. That is the capability this shape is waiting on.
527
+ why: `a case body reaches sibling case ${names()} on one path and the end of the switch on another — a switch-scoped \`break\` inside a case body is not emitted yet`,
528
+ };
226
529
  };
227
530
 
228
531
  const recognizeSwitch = (b: Block, stop: Block | null): Stmt[] | null => {
229
- const root = testInfo(b, true);
532
+ const root = testInfo(b);
230
533
  if (!root) {
231
534
  return null;
232
535
  }
@@ -249,27 +552,10 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
249
552
  // Walk the test tree. `cases`: value → case-entry block. `defaultCands`: leaves reached without an
250
553
  // equality pin. A test-block DAG cycle, or a `!=` case when the compiler disallows it, declines.
251
554
  const cases = new Map<number, Block>();
555
+ // Reached through `forwardingTarget`: agbcc's binary-search layout branches to the shared default
556
+ // through empty `b .Ldef` blocks, and without resolving them each becomes a DISTINCT default
557
+ // candidate and the whole tree declines.
252
558
  const defaultCands = new Set<Block>();
253
- // Skip pure forwarding blocks — a block whose only op is an unconditional `br` (no side effects, no
254
- // params). agbcc's binary-search layout branches to the shared default through such empty `b .Ldef`
255
- // blocks; without skipping them each becomes a DISTINCT default candidate and the whole tree declines.
256
- const skipForward = (blk: Block): Block => {
257
- let cur = blk;
258
- const guard = new Set<Block>();
259
- // Only skip a truly empty forwarding block: a lone `br` with no params AND no successor ARGS —
260
- // an edge that carries a phi arg is NOT transparent (skipping it would drop that assignment).
261
- while (
262
- cur.ops.length === 1 &&
263
- cur.ops[0].opcode === 'br' &&
264
- cur.params.length === 0 &&
265
- cur.ops[0].successors[0].args.length === 0 &&
266
- !guard.has(cur)
267
- ) {
268
- guard.add(cur);
269
- cur = cur.ops[0].successors[0].block;
270
- }
271
- return cur;
272
- };
273
559
  // Concretely SIMULATE the decision tree for a scrutinee value `xv`, returning the leaf block it
274
560
  // reaches (or null on an unexpected cycle). This is PRE3 done concretely: it lets us verify each
275
561
  // recovered case value actually routes to its recorded body in the ORIGINAL tree.
@@ -277,7 +563,7 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
277
563
  let cur = b;
278
564
  const guard = new Set<Block>();
279
565
  for (;;) {
280
- const ti = testInfo(cur, cur === b);
566
+ const ti = testInfo(cur);
281
567
  if (!ti || ti.x !== scrut) {
282
568
  return cur;
283
569
  } // reached a leaf (case body / default)
@@ -287,7 +573,7 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
287
573
  guard.add(cur);
288
574
  const term = cur.ops[cur.ops.length - 1];
289
575
  const taken = evalCmp(ti.opcode, ti.xOnLeft, xv, ti.k);
290
- cur = skipForward(term.successors[taken ? 0 : 1].block);
576
+ cur = forwardingTarget(term.successors[taken ? 0 : 1].block);
291
577
  }
292
578
  };
293
579
  const seen = new Set<Block>();
@@ -298,59 +584,76 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
298
584
  return null;
299
585
  } // a test-block DAG cycle → decline
300
586
  seen.add(blk);
301
- const ti = testInfo(blk, blk === b);
587
+ const ti = testInfo(blk);
302
588
  if (!ti || ti.x !== scrut) {
303
589
  return null;
304
590
  } // PRE1: every test is on the SAME Value
591
+ if (blk !== b && !collapsible(blk)) {
592
+ return null;
593
+ } // PRE4
305
594
  const term = blk.ops[blk.ops.length - 1];
306
- const taken = skipForward(term.successors[0].block),
307
- fall = skipForward(term.successors[1].block);
308
- const asLeafOrTest = (child: Block, role: 'case' | 'nav', k?: number) => {
309
- const isTest = !!testInfo(child, false) && testInfo(child, false)!.x === scrut;
310
- if (role === 'case') {
311
- if (isTest) {
312
- return false;
313
- } // a case target that's a test → decline
314
- if (child.params.length) {
315
- return false;
316
- } // case entry with a phi decline
317
- if (cases.has(k!)) {
318
- return false;
319
- } // duplicate case value → decline
320
- cases.set(k!, child);
321
- return true;
322
- }
323
- // navigation edge
324
- if (isTest) {
595
+ const taken = forwardingTarget(term.successors[0].block),
596
+ fall = forwardingTarget(term.successors[1].block);
597
+ const isTestOn = (child: Block) => {
598
+ const t = testInfo(child);
599
+ return !!t && t.x === scrut;
600
+ };
601
+ /** Read `child` as the BODY of case `k`. A case entry with a PHI is admitted: the dispatch
602
+ * edge binds those parameters, and `hoistDispatchCopies` re-emits that binding once above
603
+ * the `switch` (or declines the whole recovery). A fall-through chain's accumulator crosses
604
+ * every arm as exactly such a parameter, so refusing it here refuses the whole family. */
605
+ const asCase = (child: Block, k: number): boolean => {
606
+ if (isTestOn(child)) {
607
+ return false;
608
+ } // a case target that's a test → decline
609
+ if (cases.has(k)) {
610
+ return false;
611
+ } // duplicate case value → decline
612
+ cases.set(k, child);
613
+ return true;
614
+ };
615
+ /** Read `child` as a NAVIGATION edge: more dispatch to walk, or a non-test leaf, which is a
616
+ * default candidate. Never declines — the leaf's own reading is settled below. */
617
+ const asNav = (child: Block): boolean => {
618
+ if (isTestOn(child)) {
325
619
  work.push(child);
326
620
  return true;
327
621
  }
328
- defaultCands.add(child); // a non-test leaf reached by nav = default
622
+ defaultCands.add(child);
329
623
  return true;
330
624
  };
331
625
  if (ti.cls === 'eq') {
332
- if (!asLeafOrTest(taken, 'case', ti.k)) {
626
+ if (!asCase(taken, ti.k)) {
333
627
  return null;
334
628
  } // x==k → taken is case k
335
- if (!asLeafOrTest(fall, 'nav')) {
629
+ if (!asNav(fall)) {
336
630
  return null;
337
631
  }
338
632
  } else if (ti.cls === 'ne') {
339
633
  if (!switchAllowsNeqCase) {
340
634
  return null;
341
635
  } // per-compiler gate
342
- if (!asLeafOrTest(fall, 'case', ti.k)) {
636
+ if (!asCase(fall, ti.k)) {
343
637
  return null;
344
638
  } // x!=k → the EQUAL side (fall) is case k
345
- if (!asLeafOrTest(taken, 'nav')) {
639
+ if (!asNav(taken)) {
346
640
  return null;
347
641
  }
348
642
  } else {
349
- // relational → pure navigation
350
- if (!asLeafOrTest(taken, 'nav')) {
643
+ // relational → navigation, except where the BRANCH has collapsed to a single value and
644
+ // lands on a BODY, on a compiler that declared the spelling. Two more refusals:
645
+ // - a bound test at the ROOT. `emit_case_nodes` emits a single-valued node's own
646
+ // `do_jump_if_equal` before either descent test, so a bound test always sits under
647
+ // another test of the same tree; one that OPENS the region did not come from this
648
+ // dispatch, and reading it as a case turns a comparison chain into a `switch`;
649
+ // - a singleton branch onto another TEST of the scrutinee, which is the search
650
+ // descending to pin the value. It is dispatch, so the walk reads it as dispatch —
651
+ // recovering it, or declining at PRE4 if it is not collapsible.
652
+ const k = switchAllowsBoundCase && blk !== b && !isTestOn(taken) ? singletonTaken(ti) : null;
653
+ if (!(k === null ? asNav(taken) : asCase(taken, k))) {
351
654
  return null;
352
655
  }
353
- if (!asLeafOrTest(fall, 'nav')) {
656
+ if (!asNav(fall)) {
354
657
  return null;
355
658
  }
356
659
  }
@@ -361,14 +664,64 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
361
664
  } // not worth a switch (m2c: ≥2 cases)
362
665
  // The default is the single non-test leaf that is NOT a case body. 0 → no default; ≥2 distinct → decline.
363
666
  const caseBlocks = new Set(cases.values());
364
- const defaults = [...defaultCands].filter((d) => !caseBlocks.has(d));
365
- if (defaults.length > 1) {
667
+ const leaves = new Set([...defaultCands].filter((d) => !caseBlocks.has(d)));
668
+ // RESOLVE THROUGH a bare jump onto another candidate. `forwardingTarget` (ir/core.ts) stops at
669
+ // a `br` that carries block ARGUMENTS — skipping it would drop the values it supplies — so a
670
+ // leaf holding `b .Ldefault(v)` and `.Ldefault` itself arrive here as two distinct candidates
671
+ // even though one merely jumps to the other. They are not two defaults: the jumping leaf emits
672
+ // nothing of its own, and the values its `br` hands the other's parameters are the values the
673
+ // DISPATCH hands them. So this is `forwardingTarget`'s walk with the args-carrying step
674
+ // ADMITTED rather than refused, and it does not drop those values either — the step becomes one
675
+ // more dispatch edge, hoisted with the rest, where the hoist's disagreement rule decides
676
+ // whether the two paths can share one statement. (ir/core.ts lists the non-callers and why.)
677
+ //
678
+ // A cycle of such jumps has no target to resolve to and declines. Leaves that pass DIFFERENT
679
+ // values, or that have a body, are untouched and are still two defaults below. A SET so a leaf
680
+ // walked twice — L → X → D reaches X from L's walk and again from X's own turn in the loop —
681
+ // is recorded once.
682
+ const throughEdges = new Set<Block>();
683
+ const resolveDefault = (d: Block): Block | null => {
684
+ const walked = new Set<Block>();
685
+ let cur = d;
686
+ for (;;) {
687
+ if (walked.has(cur)) {
688
+ return null;
689
+ }
690
+ walked.add(cur);
691
+ const t = cur.ops[0];
692
+ if (!isBareExit(cur) || t.opcode !== 'br' || !leaves.has(t.successors[0].block)) {
693
+ return cur;
694
+ }
695
+ throughEdges.add(cur);
696
+ cur = t.successors[0].block;
697
+ }
698
+ };
699
+ const defaults: Block[] = [];
700
+ for (const d of leaves) {
701
+ const r = resolveDefault(d);
702
+ if (r === null) {
703
+ return null;
704
+ }
705
+ if (!defaults.includes(r)) {
706
+ defaults.push(r);
707
+ }
708
+ }
709
+ // ONE default reached by SEVERAL leaves. `balance_case_nodes`/`emit_case_nodes` give each
710
+ // subtree that runs out of case values its own jump to the default, so agbcc's four-case tree
711
+ // reaches it through two `b .Ldefault` blocks, which comparing candidates by BLOCK would count
712
+ // as two different defaults and decline. Two leaves are the same default when each is
713
+ // a bare EXIT to the same place carrying the same values: nothing about them can then differ,
714
+ // so the representative emits what either would. Anything else — a leaf with a body, two
715
+ // leaves passing different values — is still two defaults and still declines.
716
+ if (defaults.length > 1 && !defaults.every((d) => sameBareExit(defaults[0], d))) {
366
717
  return null;
367
718
  }
368
719
  const defaultBlk = defaults[0] ?? null;
369
- if (defaultBlk && defaultBlk.params.length) {
370
- return null;
371
- } // default entry with a phi decline
720
+ // A default entry that takes BLOCK PARAMETERS is admitted on the same terms a case entry is:
721
+ // the dispatch edge binds them and `hoistDispatchCopies` re-emits that binding above the
722
+ // `switch`. The hazard it stands over is silent — without that re-emission,
723
+ // `switch (x) { case 1: … case 2: … }` whose fall-out edge also carried `w = 0` drops the write
724
+ // and looks entirely ordinary doing it — so the admission is only as good as the hoist.
372
725
  // A default candidate that is ALSO a case body means a relational edge hit a case leaf → ambiguous.
373
726
  if ([...defaultCands].some((d) => caseBlocks.has(d))) {
374
727
  return null;
@@ -384,12 +737,28 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
384
737
  return null;
385
738
  }
386
739
 
387
- // PRE2 (fall-through): only NON-fall-through switches are handled decline if any case body
388
- // can reach ANOTHER case body (or the default) while staying inside the region. (The SAME
389
- // predicate serves the Regime-B path, which throws instead.)
740
+ // PRE2 (arm exits), per SITE. A `break` arm closes; a `fallthrough` arm runs on into the one
741
+ // sibling it reaches, which `chainArms` then places directly under it. `unstructurable` is a
742
+ // shape no single linear switch spells — a body reaching two siblings, or a sibling on one path
743
+ // and the switch's end on another — and Regime A declines to if-recovery, which spells every
744
+ // one of those edges. (Regime B reads the same verdicts and fails LOUD on either.)
390
745
  const merge = ipdom.get(b) ?? stop;
391
746
  const targets = new Set<Block>([...caseBlocks, ...(defaultBlk ? [defaultBlk] : [])]);
392
- if (caseRegionReachesSibling(targets, b, merge)) {
747
+ const siblings = new Set([...targets].filter((t) => t !== merge));
748
+ const exitOf = new Map<Block, ArmExit>();
749
+ for (const t of siblings) {
750
+ exitOf.set(t, analyzeArmExit(t, b, merge, siblings));
751
+ }
752
+ // A language with no fall-through in its `case` (Pascal) cannot print a falling arm at all, so
753
+ // for it a falling arm is exactly as unspellable as an `unstructurable` one — and takes the
754
+ // same exit: if-recovery, which that backend prints fine. Asked HERE rather than left to the
755
+ // backend because the backend's refusal is terminal (the whole function becomes a stub) while
756
+ // this one costs nothing but the `switch` spelling.
757
+ if (
758
+ [...exitOf.values()].some(
759
+ (e) => e.kind === 'unstructurable' || (!spellSwitchFallthrough && e.kind === 'fallthrough'),
760
+ )
761
+ ) {
393
762
  return null;
394
763
  }
395
764
 
@@ -403,26 +772,198 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
403
772
  }
404
773
  }
405
774
 
406
- // Build the switch. Cases sorted ascending (safe: no fall-through PRE2). Bodies delegate to the
407
- // existing structureRegion (loops/ifs inside cases, the onStack guard all reused).
775
+ // ARM ORDER. The case values are disjoint (PRE3), so where no arm falls through the order
776
+ // carries no meaning and is pure matching evidence: ascending case VALUE is the neutral
777
+ // spelling, and where a compiler has declared `switchArmsFollowLayout` the layout of the bodies
778
+ // is the SOURCE's arm order instead. A FALLING arm's position is not free, and `chainArms`
779
+ // below re-threads this order for those — reading it for the chain HEADS only.
780
+ //
781
+ // TWO arms the layout cannot order, both falling back rather than recovering:
782
+ // - two case VALUES sharing one body block share its index, so the tie is one the merge (or
783
+ // the source's own stacked labels) erased, and ascending value breaks it. They become ONE
784
+ // arm below, so the tie only orders that arm against the others. ADJACENT stacked labels
785
+ // never get here: `case 2: case 3:` compiles to a range test the walk reads as navigation
786
+ // and declines, while `case 0: case 2:` compiles to the two equality tests it recovers —
787
+ // both checked against agbcc;
788
+ // - an arm with no body of its own (`case k: break;`) has its edge resolve to the MERGE, so it
789
+ // inherits the merge's index and sorts after every arm that HAS a body.
790
+ //
791
+ // BOOKED, UNPAID: the grouping makes arm ORDER observable on a shape the ascending-value
792
+ // fallback did not have before, and IDO turns out to lay case bodies out in SOURCE order too —
793
+ // the same function with `case 1:` written first and last differ (.text md5 ec39af99 against
794
+ // 689f34ec, 144 bytes each), which is what `switchArmsFollowLayout` recovers. target.ts sets
795
+ // the bar for opting a compiler in at a SOURCE-level argument about its passes, not at two
796
+ // objects, and that argument is unpaid for IDO; nothing here changes while it is. What the
797
+ // grouping cannot do is make a non-agbcc row worse, because the ungrouped spelling is not a
798
+ // rival there: under IDO it compiles to two copies and a different ROM entirely.
408
799
  const scrutExpr = expr(scrut);
409
- const sortedCases = [...cases.entries()].sort((a, c) => a[0] - c[0]);
410
- const outCases: SwitchCase[] = sortedCases.map(([k, blk]) => ({
411
- values: [k],
412
- body: structureRegion(blk, merge),
413
- fallsThrough: false,
414
- }));
800
+ const sortedCases = [...cases.entries()].sort((a, c) =>
801
+ switchArmsFollowLayout ? layoutIndex(a[1]) - layoutIndex(c[1]) || a[0] - c[0] : a[0] - c[0],
802
+ );
803
+ // TWO VALUES ONE BODY IS ONE ARM. `SwitchCase.values` stacks labels for exactly this, and the
804
+ // jump-table regime groups the same way (structure.ts's `armOf`). Emitting the body once per
805
+ // label is `structureRegion` run twice over one block: a duplication, not a spelling choice.
806
+ //
807
+ // WHY BLOCK IDENTITY IS THE KEY, and not a body-equality one like `sameBareExit` above. agbcc
808
+ // MERGES two written-out copies into one block — target.ts's `switchArmsFollowLayout` note
809
+ // says so from agbcc's own sources, SRCS compiling jump.c — and compiling both directions at
810
+ // TOOLCHAIN.agbccFlags says WHERE the merged block lands: at the last copy's position. So
811
+ // `case 0: A break; case 1: … case 2: A break;` and the grouped arm placed THERE are one
812
+ // object (.text md5 555abb1a), while the grouped arm placed at the first value is not
813
+ // (fe4d7d35). That is why the grouped spelling round-trips rather than merely reading shorter:
814
+ // agbcc declares `switchArmsFollowLayout`, so the arm goes exactly where the merged block sits.
815
+ // IDO does not merge at all — 224 bytes against the grouped 144 — so on MIPS a shared block can
816
+ // only have come from stacked labels. Two DISTINCT blocks with equal bodies therefore mean one
817
+ // arm on neither compiler: under agbcc that ROM is unreachable, under IDO it is what two arms
818
+ // compile to. Sound also because two dispatch edges onto ONE body cannot bind one name two
819
+ // ways: `hoistDispatchCopies` merges every collapsed edge's copies into a single statement
820
+ // above the `switch` and refuses on disagreement (structure.ts `hoistedDispatchAssigns`), so
821
+ // an arm reached by two case values is reached with one set of parameter values or the whole
822
+ // recovery declines.
823
+ //
824
+ // An arm takes the position of its FIRST value, which keeps the sort above. `defaultLayoutPos`
825
+ // is handed the GROUPED entry list because what it returns is an INDEX INTO the arm array, so
826
+ // the list it counts and the list it indexes must be one list.
827
+ const armsByBlock = new Map<Block, number[]>();
828
+ for (const [k, blk] of sortedCases) {
829
+ const prev = armsByBlock.get(blk);
830
+ if (prev) {
831
+ prev.push(k);
832
+ } else {
833
+ armsByBlock.set(blk, [k]);
834
+ }
835
+ }
836
+ // The `default:` arm is a chain member too — an arm may run on into it, and C prints it last.
837
+ const dfltArm = defaultBlk !== null && defaultBlk !== merge ? defaultBlk : null;
838
+ const preChain = [...armsByBlock.keys()];
839
+ const entries = chainArms(preChain, dfltArm, exitOf);
840
+ if (entries === null) {
841
+ return null;
842
+ }
843
+ // Did the chain move anything? The arm-order POLICY above produced `preChain`; where the chain
844
+ // left it alone, every position still means what that policy said it meant.
845
+ const orderIntact = entries.every((e, i) => e === preChain[i]);
846
+ const fallsInto = (blk: Block): Block | null => {
847
+ const x = exitOf.get(blk);
848
+ return x?.kind === 'fallthrough' ? x.to : null;
849
+ };
850
+ // THE DISPATCH HOIST. Every edge the tree walk is about to COLLAPSE — from any test block, to
851
+ // a case entry, a default candidate, the merge, or another test block — carries the parallel
852
+ // copy that binds its target's parameters, and collapsing the tree is what would discard it.
853
+ // Merge those copies and emit them ONCE, above the `switch`. Placed FIRST because it is
854
+ // emitted first and `argAssignsFor` mints swap-cycle temp names as it goes.
855
+ //
856
+ // TWO CONDITIONS ARE THIS REGIME'S OWN, and both must hold before the emission is asked for:
857
+ //
858
+ // - AVAILABILITY AT THE ROOT. A hoisted copy is evaluated at `b`, not on its edge, so every
859
+ // argument it reads must be defined at a block that DOMINATES `b`. An argument computed
860
+ // inside a collapsed test block is not: the tree reaches that block only on some paths,
861
+ // while the hoist runs on all of them, and re-rendering it above the dispatch would
862
+ // SPECULATE it. (That is the one invariant PRE4's "a use is dominated by its def, so a
863
+ // collapsed op runs on a subset of the paths it already ran on" does not give the hoist,
864
+ // which is why the hoist asks for it here rather than inheriting it.)
865
+ // - The emission's own two refusals, stated at `hoistedDispatchAssigns`: edges disagreeing
866
+ // about one name, and a hoisted name whose value is still live at the switch or into an arm.
867
+ //
868
+ // Either way the answer is a decline to if-recovery, which spells every copy the asm performs.
869
+ const availableAtRoot = (v: Value): boolean => {
870
+ const home = blockOf(v);
871
+ return home !== undefined && dom.get(b)!.has(home);
872
+ };
873
+ const dispatchEdges: { pred: Block; succ: { block: Block; args: Value[] } }[] = [];
874
+ for (const t of new Set([...seen, ...throughEdges])) {
875
+ for (const e of t.ops[t.ops.length - 1].successors) {
876
+ if (e.block.params.length === 0) {
877
+ continue;
878
+ }
879
+ if (!e.args.every(availableAtRoot)) {
880
+ return null;
881
+ }
882
+ dispatchEdges.push({ pred: t, succ: e });
883
+ }
884
+ }
885
+ const hoisted = hoistDispatchCopies(dispatchEdges, [
886
+ b,
887
+ ...caseBlocks,
888
+ ...(defaultBlk ? [defaultBlk] : []),
889
+ ...(merge ? [merge] : []),
890
+ ]);
891
+ if (hoisted === null) {
892
+ return null;
893
+ }
894
+ // Bodies are structured in EMISSION order — `argAssignsFor` mints swap-cycle temp names as it
895
+ // goes, so building them out of order changes the output — and each falling arm's target is
896
+ // re-read off that order rather than trusted from the ordering above: this is the seam where a
897
+ // POSITION acquires control-flow meaning (the l3/ast.ts non-neutrality note).
898
+ const outCases: SwitchCase[] = [];
899
+ for (const [i, blk] of entries.entries()) {
900
+ const to = fallsInto(blk);
901
+ if (to !== null && to !== (entries[i + 1] ?? dfltArm)) {
902
+ return null;
903
+ }
904
+ // The arm fallen INTO may take block parameters, and the TWO paths that reach it are spelled
905
+ // in two different places: entering by its own case value takes the hoisted copy above the
906
+ // `switch` (`hoistDispatchCopies`), while FALLING in takes the copies the falling arm's own
907
+ // `br` emits as its last statements (`structureRegion(blk, to)` walks that terminator, so
908
+ // they are already there). Getting this wrong is SILENT, which is why it rests on the hoist
909
+ // rather than on a local reading of the arm.
910
+ //
911
+ // Regime B refuses the same hazard LOUD at its own `switch_br` path, on the weaker predicate
912
+ // that fits it (the copies `argAssignsFor` actually produced): its arms take their edge
913
+ // copies PER ARM, so a fall-through path would re-run them over what the falling arm
914
+ // computed. Hoisting is what removes that hazard, and Regime B does not hoist — booked, not
915
+ // built, and no row asks for it (`sw_jtfall`/`sw_jtfalldesc` match today).
916
+ outCases.push({
917
+ values: armsByBlock.get(blk)!,
918
+ body: structureRegion(blk, to ?? merge),
919
+ fallsThrough: to !== null,
920
+ });
921
+ }
922
+ // An empty default arm is not a default (see the Regime-B note in structure.ts): the label
923
+ // would carry no statement, which says nothing and is not valid C89.
924
+ const defBody = defaultBlk ? structureRegion(defaultBlk, merge) : [];
925
+ // The `default:` arm is an ARM: where its block is laid out is read exactly as a case body's is
926
+ // (`defaultLayoutPos`). The dispatch placed that block itself when the last test simply RAN OUT
927
+ // into it and no other subtree jumps there — the two references the tree walk can count.
928
+ const dispatchTargets = [...seen].flatMap((t) =>
929
+ t.ops[t.ops.length - 1].successors.map((e) => forwardingTarget(e.block)),
930
+ );
931
+ // THE TWO CONJUNCTS READ THE EDGES DIFFERENTLY, and deliberately. The first is asked RAW, of
932
+ // the terminator's own second successor, because a surviving `b .Ldefault` block is a jump
933
+ // that did NOT collapse into a fall-through — resolving it away would count the jump as the
934
+ // running-out it is not (see the `expand_end_case` paragraph on `defaultLayoutPos`). The
935
+ // second is asked FORWARDED, over `dispatchTargets`, because there a forwarder is transparent:
936
+ // two edges that arrive through one are two references to the same block, which is exactly
937
+ // what "named more than once" has to count.
938
+ const fellThroughIntoIt = (blk: Block) =>
939
+ [...seen].some((t) => {
940
+ const succ = t.ops[t.ops.length - 1].successors;
941
+ return succ.length > 1 && succ[1].block === blk;
942
+ });
943
+ const namedOnlyOnce = (blk: Block) => dispatchTargets.filter((e) => e === blk).length < 2;
944
+ const ranOutInto = (blk: Block) => fellThroughIntoIt(blk) && namedOnlyOnce(blk);
945
+ // `defaultLayoutPos` owns which POSITIONS a chain makes unreadable — see its fall-through
946
+ // withholdings W5..W7. A chain elsewhere in the switch does not delete the evidence for
947
+ // where the label goes, and reading it off the emitted arms is what lets a `default:` written
948
+ // between two closed arms keep its place while a chain runs beside it.
949
+ const defaultAt = defaultBlk
950
+ ? defaultLayoutPos(
951
+ defaultBlk,
952
+ entries.map((e, i) => ({ entry: e, fallsThrough: outCases[i].fallsThrough })),
953
+ { placedByDispatch: ranOutInto(defaultBlk), orderIntact },
954
+ )
955
+ : undefined;
415
956
  const sw: Stmt = {
416
957
  k: 'switch',
417
958
  scrutinee: scrutExpr,
418
959
  cases: outCases,
419
- ...(defaultBlk ? { default: structureRegion(defaultBlk, merge) } : {}),
960
+ ...(defBody.length ? { default: defBody, ...(defaultAt !== undefined ? { defaultAt } : {}) } : {}),
420
961
  };
421
- const out: Stmt[] = [sw];
962
+ const out: Stmt[] = [...hoisted, sw];
422
963
  if (merge && merge !== stop) {
423
964
  out.push(...structureRegion(merge, stop));
424
965
  }
425
966
  return out;
426
967
  };
427
- return { recognizeSwitch, caseRegionReachesSibling };
968
+ return { recognizeSwitch, analyzeArmExit, layoutIndex, defaultLayoutPos, chainArms };
428
969
  }