@asmlift/core 0.5.0 → 0.7.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 (94) 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 +270 -171
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +243 -39
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +11 -0
  10. package/src/frontend/ppc.ts +43 -7
  11. package/src/frontend/ssa.ts +404 -29
  12. package/src/frontend/thumb.ts +2176 -686
  13. package/src/ir/alias.ts +78 -0
  14. package/src/ir/bits.ts +75 -0
  15. package/src/ir/core.ts +345 -2
  16. package/src/ir/opcodes.ts +176 -21
  17. package/src/ir/parse.ts +19 -2
  18. package/src/ir/print.ts +27 -2
  19. package/src/ir/simplify.ts +190 -3
  20. package/src/ir/struct-names.ts +42 -0
  21. package/src/ir/verify.ts +43 -49
  22. package/src/l3/address.ts +62 -0
  23. package/src/l3/advance.ts +373 -0
  24. package/src/l3/argbase.ts +6 -5
  25. package/src/l3/ast.ts +510 -59
  26. package/src/l3/basecse.ts +686 -78
  27. package/src/l3/coalesce.ts +432 -46
  28. package/src/l3/dce.ts +31 -9
  29. package/src/l3/gates.ts +96 -1
  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 +176 -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 +114 -89
  42. package/src/l3/reindex.ts +722 -80
  43. package/src/l3/scopebase.ts +649 -220
  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 +16 -1
  49. package/src/l3/typing.ts +198 -9
  50. package/src/l3/unmerge.ts +687 -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 +239 -16
  57. package/src/pipeline.ts +173 -60
  58. package/src/proto.ts +112 -14
  59. package/src/raise/arrays.ts +6 -1
  60. package/src/raise/const.ts +203 -3
  61. package/src/raise/divpow2.ts +4 -4
  62. package/src/raise/extscale.ts +342 -0
  63. package/src/raise/globalshape.ts +1058 -0
  64. package/src/raise/gvn.ts +33 -18
  65. package/src/raise/latch.ts +126 -0
  66. package/src/raise/magicdiv.ts +2 -2
  67. package/src/raise/memberarrays.ts +594 -0
  68. package/src/raise/narrow.ts +124 -0
  69. package/src/raise/narrowlocal.ts +572 -0
  70. package/src/raise/paramwidth.ts +201 -0
  71. package/src/raise/pre-recovery.ts +169 -21
  72. package/src/raise/recover.ts +56 -23
  73. package/src/raise/retsink.ts +585 -19
  74. package/src/raise/shortcircuit.ts +1050 -89
  75. package/src/raise/struct-arrays.ts +19 -2
  76. package/src/raise/structs.ts +34 -4
  77. package/src/raise/tailsink.ts +126 -0
  78. package/src/rank-declare.ts +256 -0
  79. package/src/rank-variations.ts +760 -0
  80. package/src/rank.ts +2122 -326
  81. package/src/structure/analysis.ts +1398 -150
  82. package/src/structure/bitfields.ts +432 -0
  83. package/src/structure/globalaccess.ts +300 -0
  84. package/src/structure/hazards.ts +411 -20
  85. package/src/structure/loops.ts +2 -49
  86. package/src/structure/namecoalesce.ts +454 -0
  87. package/src/structure/structure.ts +3979 -612
  88. package/src/structure/switch-recover.ts +710 -145
  89. package/src/symbols.ts +188 -6
  90. package/src/target.ts +495 -32
  91. package/src/trace.ts +112 -33
  92. package/src/variation-definitions.ts +1540 -0
  93. package/src/variation-gates.ts +89 -0
  94. package/src/variation-tokens.ts +355 -0
@@ -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,12 +19,41 @@ 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
+ /** DECLINE a recovered tree whose own layout INTERLEAVES a test with a case body. A source
27
+ * `switch` front-loads its whole dispatch ahead of every arm body; an if/else-if LADDER emits
28
+ * each test directly above its own body. See StructureOptions for the compiled evidence, for
29
+ * the frontend premise this shares with `switchArmsFollowLayout`, and for what the decline
30
+ * costs when it is wrong. */
31
+ switchRequiresFrontLoadedTests: boolean;
32
+ /** may the emitted SOURCE say "this arm runs on into the next one"? False for a language whose
33
+ * `case` cannot fall through (Pascal), and then Regime A declines a falling arm to if-recovery
34
+ * — the behaviourally identical recovery that backend CAN print. See StructureOptions. */
35
+ spellSwitchFallthrough: boolean;
36
+ /** does emitting this block's ops carry a statement beyond the ops themselves? Collapsing a
37
+ * test block into a `switch` re-renders its ops at their uses and emits no side effects for it,
38
+ * so any op that renders as a STATEMENT of its own loses that statement. Two produce one: a
39
+ * def-site ANCHORED merge copy (structure.ts anchorConstCopies), whose edge copy stays
40
+ * suppressed, and a MATERIALIZED def, whose `v = …` assignment renders only here while its uses
41
+ * read the bare name — leaving a local declared and never assigned. */
42
+ emitsOwnStatement: (blk: Block) => boolean;
43
+ /** Where a value is WRITTEN: its block for a parameter, its def op's block otherwise — the half
44
+ * of "where is this defined" `defs` does not answer. Taken from `structure.ts`, which builds
45
+ * both halves for def-site anchoring, rather than indexing the parameters a second time. */
46
+ blockOf: (v: Value) => Block | undefined;
47
+ /** THE DISPATCH HOIST: the copies of every edge the tree walk collapsed, merged and re-emitted
48
+ * ONCE ahead of the `switch`. `structure.ts hoistedDispatchAssigns` owns the emission
49
+ * (suppression, identity elision, `undef`, the write-order sort, `sequentialize`), so this
50
+ * regime takes it as a dependency rather than implementing it a second time, exactly as Regime
51
+ * B takes `argAssignsFor`. Null ⇒ no single hoisted statement spells them; the caller declines
52
+ * to if-recovery. `liveAt` are the blocks whose live-in names the writes must not clobber. */
53
+ hoistDispatchCopies: (
54
+ edges: readonly { pred: Block; succ: { block: Block; args: Value[] } }[],
55
+ liveAt: readonly Block[],
56
+ ) => Stmt[] | null;
27
57
  expr: (v: Value) => Expr;
28
58
  structureRegion: (b: Block, stop: Block | null) => Stmt[];
29
59
  }
@@ -34,17 +64,181 @@ export interface SwitchRecoverDeps {
34
64
  * - `break` every path out of the arm reaches the switch's merge (or returns / loops
35
65
  * inside the arm). The ordinary closed arm.
36
66
  * - `fallthrough` every path out leaves into exactly ONE sibling arm's entry: C's fall-through.
37
- * Only spellable when that sibling is the arm emitted NEXT (the caller checks
38
- * emission adjacency see the l3/ast.ts non-neutrality note).
67
+ * Only spellable when that sibling is the arm emitted NEXT, which `chainArms`
68
+ * below arranges and both regimes then re-read off the emission array (see the
69
+ * l3/ast.ts non-neutrality note).
39
70
  * - `unstructurable` anything else: two different siblings, or a mix of "into a sibling" and
40
- * "out to the merge". C needs a `goto` for those, so callers decline LOUD. */
71
+ * "out to the merge". C needs a `goto` for those. Regime A declines to
72
+ * if-recovery on this verdict; Regime B, having no fallback, fails LOUD. */
41
73
  export type ArmExit = { kind: 'break' } | { kind: 'fallthrough'; to: Block } | { kind: 'unstructurable'; why: string };
42
74
 
43
75
  export interface SwitchRecovery {
44
76
  recognizeSwitch: (b: Block, stop: Block | null) => Stmt[] | null;
45
- /** shared with the Regime-B (`switch_br`) path in structure.ts, which recovers the fall-through
46
- * this returns; Regime A only accepts `break` arms and otherwise declines to if-recovery. */
77
+ /** shared with the Regime-B (`switch_br`) path in structure.ts. Both regimes recover the
78
+ * fall-through this returns; on an `unstructurable` verdict Regime A declines to if-recovery and
79
+ * Regime B, which has no fallback, fails loud. */
47
80
  analyzeArmExit: (entry: Block, b: Block, merge: Block | null, siblings: Set<Block>) => ArmExit;
81
+ /** a block's position in the ASSEMBLY — the arm-order evidence, shared with Regime B so the two
82
+ * regimes read it from one definition (and one statement of what it rests on). */
83
+ layoutIndex: (blk: Block) => number;
84
+ /** where the `default:` label goes among the EMITTED arms, or `undefined` for C's last position —
85
+ * shared with Regime B so both regimes state those refusals once. */
86
+ defaultLayoutPos: (
87
+ defaultBlk: Block,
88
+ arms: readonly { entry: Block; fallsThrough: boolean }[],
89
+ opts: { placedByDispatch: boolean; orderIntact: boolean },
90
+ ) => number | undefined;
91
+ /** ONE linear emission order for a set of arms, re-threaded so every falling arm sits directly
92
+ * above the one it falls into — or null when no linear order spells them. Shared with Regime B,
93
+ * so the chain, the adjacency it guarantees and the three refusals below it have one definition
94
+ * (Regime A maps null to if-recovery, Regime B to a loud StructureError). */
95
+ chainArms: (order: Block[], dflt: Block | null, exitOf: Map<Block, ArmExit>) => Block[] | null;
96
+ }
97
+
98
+ /** A block with no body of its own: no params, and one op that only LEAVES. `ret` qualifies as
99
+ * well as `br` because raise/retsink.ts rewrites the one into the other — a cross-jumped arm
100
+ * body has two dispatch preds, which is exactly the shape that makes retsink sink the merge's
101
+ * return into every leaf, the fall-out jumps included. */
102
+ function isBareExit(blk: Block): boolean {
103
+ return isBodyless(blk) && (blk.ops[0].opcode === 'br' || blk.ops[0].opcode === 'ret');
104
+ }
105
+
106
+ /** Are these two blocks the SAME bare exit — the same jump with the same args, or the same return
107
+ * of the same values? Neither has a body, so two of them are indistinguishable at emission. */
108
+ function sameBareExit(a: Block, c: Block): boolean {
109
+ if (a === c) {
110
+ return true;
111
+ }
112
+ if (!isBareExit(a) || !isBareExit(c) || a.ops[0].opcode !== c.ops[0].opcode) {
113
+ return false;
114
+ }
115
+ const same = (x: readonly Value[], y: readonly Value[]) => x.length === y.length && x.every((v, i) => v === y[i]);
116
+ if (a.ops[0].opcode === 'ret') {
117
+ return same(a.ops[0].operands, c.ops[0].operands);
118
+ }
119
+ const [x, y] = [a, c].map((blk) => blk.ops[0].successors[0]);
120
+ return x.block === y.block && same(x.args, y.args);
121
+ }
122
+
123
+ export interface TestInfo {
124
+ x: Value;
125
+ k: number;
126
+ cls: 'eq' | 'ne' | 'rel';
127
+ opcode: string;
128
+ xOnLeft: boolean;
129
+ }
130
+
131
+ // Evaluate a test predicate for a CONCRETE scrutinee value — used to SIMULATE the decision tree and
132
+ // verify recovered case values (below). Returns true iff the `taken` (successors[0]) edge is followed.
133
+ // Signed/unsigned per the icmp opcode (PRE3, done concretely rather than via interval lattices).
134
+ function evalCmp(opcode: string, xOnLeft: boolean, xv: number, k: number): boolean {
135
+ const uns = opcode.startsWith('icmp_u');
136
+ const [xn, kn] = uns ? [xv >>> 0, k >>> 0] : [xv | 0, k | 0];
137
+ const [l, r] = xOnLeft ? [xn, kn] : [kn, xn]; // put the scrutinee where it textually appears
138
+ switch (opcode) {
139
+ case 'icmp_eq':
140
+ return l === r;
141
+ case 'icmp_ne':
142
+ return l !== r;
143
+ case 'icmp_slt':
144
+ case 'icmp_ult':
145
+ return l < r;
146
+ case 'icmp_sle':
147
+ case 'icmp_ule':
148
+ return l <= r;
149
+ case 'icmp_sgt':
150
+ case 'icmp_ugt':
151
+ return l > r;
152
+ case 'icmp_sge':
153
+ case 'icmp_uge':
154
+ return l >= r;
155
+ default:
156
+ return false;
157
+ }
158
+ }
159
+
160
+ // Which single scrutinee value does this relational test's BRANCH admit, if exactly one? A
161
+ // relational side is a HALF-LINE in the compare's own ordering, so it can hold one value only at
162
+ // a domain endpoint — which is why testing the two endpoints and their neighbours decides it,
163
+ // with no interval lattice. `x < 1` over an unsigned scrutinee admits `{0}` and is agbcc's
164
+ // spelling of `case 0` in a balanced search: `emit_case_nodes` tests the subtree BOUND, not the
165
+ // value, whenever the remaining range has collapsed to one. Read as navigation instead, that
166
+ // arm's body becomes a second default candidate and the whole tree declines.
167
+ //
168
+ // THE BRANCH, never the fall-through. Every jump in `emit_case_nodes` that lands on a case body
169
+ // is its test's BRANCH — for a single-valued node, LT to `node->left->code_label` and GT to
170
+ // `node->right->code_label`, each guarded by `node_is_bounded` on that side — while the
171
+ // fall-through always continues into more dispatch, so a fall-side reading has no producer in
172
+ // this dispatch — and none turns up in 3176 generated agbcc dispatches.
173
+ //
174
+ // TWO PREMISES ABOUT THE DOMAIN. It is the 32-bit REGISTER's, not the scrutinee's recovered
175
+ // type, so a narrower type has a nearer endpoint this misses — which costs a case and never
176
+ // invents one. And it is the WHOLE of that domain, so an ancestor that already excluded the
177
+ // value makes the reading wrong; PRE3 is what catches that, simulating the original tree for
178
+ // every recovered case value and declining unless it lands on the recorded body, exactly as it
179
+ // does for the `eq` cases. Null when the branch admits none, several, or the whole domain.
180
+ function singletonTaken(ti: TestInfo): number | null {
181
+ const [min, max] = ti.opcode.startsWith('icmp_u') ? [0, -1] : [-0x80000000, 0x7fffffff];
182
+ for (const [v, next] of [
183
+ [min, min + 1],
184
+ [max, max - 1],
185
+ ]) {
186
+ if (evalCmp(ti.opcode, ti.xOnLeft, v, ti.k) && !evalCmp(ti.opcode, ti.xOnLeft, next, ti.k)) {
187
+ return v;
188
+ }
189
+ }
190
+ return null;
191
+ }
192
+
193
+ /** Re-thread `order` so every FALLING arm sits directly above the arm it falls into. Each
194
+ * fall-through chain is emitted contiguously and takes the position of its HEAD in `order`,
195
+ * which is the caller's own arm-order policy — so with no fall-through every chain is a
196
+ * singleton and `order` comes back unchanged. `dflt` is the `default:` arm's block when it has
197
+ * one, and it is pinned LAST because that is where C prints the label.
198
+ *
199
+ * THREE REFUSALS (null ⇒ the caller declines), each a shape no single linear order spells:
200
+ * - two arms falling into the SAME arm — C drops into an arm from above along one edge only;
201
+ * - the `default:` arm falling into a case, since nothing is emitted below it;
202
+ * - a fall-through CYCLE, whose members are all fallen-into and so are never a chain head. */
203
+ export function chainArms(order: Block[], dflt: Block | null, exitOf: Map<Block, ArmExit>): Block[] | null {
204
+ const next = new Map<Block, Block>();
205
+ const fallenInto = new Set<Block>();
206
+ for (const e of [...order, ...(dflt ? [dflt] : [])]) {
207
+ const x = exitOf.get(e);
208
+ if (x?.kind !== 'fallthrough') {
209
+ continue;
210
+ }
211
+ if (e === dflt || fallenInto.has(x.to)) {
212
+ return null;
213
+ }
214
+ fallenInto.add(x.to);
215
+ next.set(e, x.to);
216
+ }
217
+ const chains: Block[][] = [];
218
+ const seen = new Set<Block>();
219
+ let intoDefault = -1;
220
+ for (const head of order) {
221
+ if (fallenInto.has(head)) {
222
+ continue;
223
+ }
224
+ const chain: Block[] = [];
225
+ for (let cur: Block | undefined = head; cur !== undefined && cur !== dflt && !seen.has(cur); cur = next.get(cur)) {
226
+ seen.add(cur);
227
+ chain.push(cur);
228
+ }
229
+ if (dflt !== null && next.get(chain[chain.length - 1]) === dflt) {
230
+ intoDefault = chains.length;
231
+ }
232
+ chains.push(chain);
233
+ }
234
+ // A cycle's every member is fallen-into, so none of them is a head and none is walked.
235
+ if (seen.size !== order.length) {
236
+ return null;
237
+ }
238
+ if (intoDefault >= 0) {
239
+ chains.push(...chains.splice(intoDefault, 1));
240
+ }
241
+ return chains.flat();
48
242
  }
49
243
 
50
244
  export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
@@ -57,14 +251,110 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
57
251
  isNamed,
58
252
  isCmpOpcode,
59
253
  switchAllowsNeqCase,
60
- emitsAnchoredWrite,
254
+ switchAllowsBoundCase,
255
+ switchArmsFollowLayout,
256
+ switchRequiresFrontLoadedTests,
257
+ spellSwitchFallthrough,
258
+ emitsOwnStatement,
259
+ blockOf,
260
+ hoistDispatchCopies,
61
261
  expr,
62
262
  structureRegion,
63
263
  } = deps;
64
264
 
265
+ // A block's index in `fn.blocks` as its position in the ASSEMBLY — the warrant for every reading
266
+ // of layout below, and true PER FRONTEND rather than of the IR:
267
+ // - thumb.ts and mips.ts build the list by scanning the instruction stream in address order;
268
+ // - ppc.ts does not. It APPENDS a synthetic return block (`synthReturn`) at the end of the list
269
+ // for every conditional-return branch, wherever in the stream that branch sits, so its list
270
+ // is not address order at all;
271
+ // - raising only ever REMOVES blocks from the list (raise/{divpow2,latch,retsink,shortcircuit}
272
+ // .ts all `filter`), never inserts or reorders, so the frontend's order is what survives.
273
+ // TWO READERS, both target-gated, and they need DIFFERENT strengths of the compiler half:
274
+ // - `switchArmsFollowLayout` (arm ORDER, the sort below) needs the full claim — no block moved
275
+ // at all — because it PLACES the arms from the layout;
276
+ // - `switchRequiresFrontLoadedTests` (PRE5, whether to recover at all) needs only that no case
277
+ // BODY was moved above a dispatch test, which a compiler with a scheduler can still satisfy;
278
+ // `MIPS_GCC` declares `switchRequiresFrontLoadedTests` and NOT `switchArmsFollowLayout` for
279
+ // exactly that reason — it has a scheduler and fills delay slots (target.ts).
280
+ // Both are therefore claims about a target's FRONTEND as much as about its compiler, and a target
281
+ // opts in on both halves — which is why PPC_MWCC, whose frontend fails the frontend half outright,
282
+ // declares neither. Anything added below that reads `layoutIndex` inherits the frontend half and
283
+ // owes a statement of which strength of the compiler half it needs.
284
+ const blockIndex = new Map(fn.blocks.map((blk, i) => [blk, i] as const));
285
+ const layoutIndex = (blk: Block): number => blockIndex.get(blk) ?? -1;
286
+
287
+ // Where the `default:` label goes among the arms, as a COUNT of the arms laid out before it — the
288
+ // same evidence the case bodies carry, read the same way. Compiled at every position of a 3- to
289
+ // 8-case switch, the default's block lands where the source wrote it. `undefined` ⇒ C's
290
+ // conventional last position, which is what every other producer of this node means.
291
+ //
292
+ // SEVEN WITHHOLDINGS, W1..W7, numbered in the order the code asks them so the prose and the code
293
+ // index ONE list, and stated here so both regimes state them once. W1 is the target's own opt-in;
294
+ // W2..W4 are about a block the DISPATCH placed rather than the arm; W5..W7 are about
295
+ // fall-through — W5 about the LIST the count would index, W6 and W7 about the POSITION it names.
296
+ //
297
+ // W1 the target does not read arm order off the layout at all (`switchArmsFollowLayout`).
298
+ // W2 a block with no body of its own is one the dispatch minted (`b .Ldefault`), and which of
299
+ // several such the collapse below keeps is a walk-order accident.
300
+ // W3 the walk already read that block as a CASE arm — a dense table sends every unwritten
301
+ // value's slot to the default's block, so grouping the slots gives that block an arm of its
302
+ // own and its index is where THAT arm sits.
303
+ // W4 `emit_case_nodes` ends every exhausted subtree with `emit_jump_if_reachable
304
+ // (default_label)` and `expand_end_case` reorders the whole dispatch, those jumps included,
305
+ // ahead of the arm bodies — so a jump survives as a plain FALL-THROUGH exactly when the
306
+ // default's body is the arm the source wrote FIRST. That reading holds only while a second
307
+ // subtree still names the label: a two-case chain names it once, and agbcc then lays that
308
+ // block right after the tests whatever the source wrote, both spellings compiling to
309
+ // identical instructions.
310
+ // W5 the chain RE-THREADED the arm order (`orderIntact` false), so the emitted list is no
311
+ // longer the one the layout count describes and no position in it means what the count
312
+ // says. This one is whole-switch because the re-threading is. A per-position reading —
313
+ // bracket the label between the two arms that straddle it in LAYOUT, then map that into the
314
+ // emitted list — is possible and unbuilt, and hard to need: a compiler that lays bodies out
315
+ // in source order already writes a falling arm directly above its target, so the order it
316
+ // declares is a chain order too.
317
+ // W6 the LAST emitted arm falls through, which can only be into the default (the adjacency
318
+ // check leaves no other target) — the label must then be last, which IS `undefined`.
319
+ // W7 the position lands directly after a falling arm, where printing the label would divert
320
+ // that arm into the default. cfamily.ts fails loud on exactly that, and this is the producer
321
+ // side of the same rule.
322
+ //
323
+ // A switch with a chain elsewhere keeps its evidence: the reason to withhold is the position,
324
+ // never "some arm somewhere falls".
325
+ const defaultLayoutPos = (
326
+ defaultBlk: Block,
327
+ arms: readonly { entry: Block; fallsThrough: boolean }[],
328
+ opts: { placedByDispatch: boolean; orderIntact: boolean },
329
+ ): number | undefined => {
330
+ if (!switchArmsFollowLayout) {
331
+ return undefined; // W1 target does not read layout
332
+ }
333
+ if (isBareExit(defaultBlk)) {
334
+ return undefined; // W2 dispatch-minted bodyless block
335
+ }
336
+ if (arms.some((a) => a.entry === defaultBlk)) {
337
+ return undefined; // W3 already an emitted arm
338
+ }
339
+ if (opts.placedByDispatch) {
340
+ return undefined; // W4 dispatch ran out into it
341
+ }
342
+ if (!opts.orderIntact) {
343
+ return undefined; // W5 chain re-threaded the arms
344
+ }
345
+ if (arms[arms.length - 1]?.fallsThrough) {
346
+ return undefined; // W6 last arm falls through
347
+ }
348
+ const at = arms.filter((a) => layoutIndex(a.entry) < layoutIndex(defaultBlk)).length;
349
+ // W7 the label would land directly after a falling arm
350
+ return at > 0 && arms[at - 1].fallsThrough ? undefined : at;
351
+ };
352
+
65
353
  // --- Regime A: comparison-tree switch recovery ----------------------------------------------------
66
- // Every ambiguity declines. Four preconditions are enforced below, annotated PRE1..PRE4:
67
- // scrutinee identity/dominance, no fall-through, concrete interval consistency, test purity.
354
+ // Every ambiguity declines. Five preconditions are enforced below, annotated PRE1..PRE5:
355
+ // scrutinee identity/dominance, ARM EXITS (per site: `break`, `fallthrough` — which `chainArms`
356
+ // then places — or a decline), concrete interval consistency, test purity, and — where the target
357
+ // has declared that its layout answers it — which SPELLING the source wrote.
68
358
 
69
359
  // Fold a value that is a compile-time constant (a `const`, or a synthesized immediate like agbcc's
70
360
  // `250 << 2` for a large sparse case) to a number — else null.
@@ -137,20 +427,23 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
137
427
  }
138
428
  };
139
429
 
140
- // A "pure test block": its only computation is constants + one integer comparison feeding its
141
- // cond_br terminator (no store/call/load/opaque its body is DISCARDED when the tree collapses to a
142
- // switch, so a side effect there would be lost). PRE4 (purity). The root block is exempt from the
143
- // "only const/icmp" rule because its non-terminator ops are already emitted as sideEffects(b) before
144
- // the switch; a non-root test block must be strictly pure.
145
- const SIDE_EFFECTFUL = new Set(['store', 'astore', 'call', 'load', 'aload', 'opaque']);
146
- interface TestInfo {
147
- x: Value;
148
- k: number;
149
- cls: 'eq' | 'ne' | 'rel';
150
- opcode: string;
151
- xOnLeft: boolean;
152
- }
153
- const testInfo = (blk: Block, isRoot: boolean): TestInfo | null => {
430
+ // TWO QUESTIONS about a block, asked separately because the answers diverge and the walk needs
431
+ // both: WHAT does it test (`testInfo`), and may this recovery DISCARD it (`collapsible`)?
432
+ //
433
+ // PRE4 (purity) is the second. A collapsed test block's ops re-render at whichever use inlines
434
+ // them, at a point the switch decides so the question is motion, not deletion, and
435
+ // `ORDER_SENSITIVE_OPS` is the set that asks it. NOT the trapping divides: a use is dominated by
436
+ // its def, so the re-rendered op runs on a subset of the paths it already ran on — nothing is
437
+ // speculated. `emitsOwnStatement` covers what motion cannot save: a statement belonging to the
438
+ // block rather than to a use. The root is exempt from all of it — its ops are already emitted as
439
+ // sideEffects(b) before the switch.
440
+ //
441
+ // A block that tests the scrutinee and is NOT collapsible is still dispatch, so the walk must
442
+ // read it as dispatch and decline, never re-read it as a case body — that would spell an arm
443
+ // whose guard the dispatch has already decided.
444
+ const collapsible = (blk: Block): boolean =>
445
+ !blk.ops.some((op) => ORDER_SENSITIVE_OPS.has(op.opcode)) && !emitsOwnStatement(blk);
446
+ const testInfo = (blk: Block): TestInfo | null => {
154
447
  const term = blk.ops[blk.ops.length - 1];
155
448
  if (term.opcode !== 'cond_br') {
156
449
  return null;
@@ -159,9 +452,6 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
159
452
  if (!cmp || !isCmpOpcode(cmp.opcode)) {
160
453
  return null;
161
454
  }
162
- if (!isRoot && (blk.ops.some((op) => SIDE_EFFECTFUL.has(op.opcode)) || emitsAnchoredWrite(blk))) {
163
- return null;
164
- } // PRE4 — anchored writes included: discarded with the block, while their edge copies stay suppressed
165
455
  // Which operand is the scrutinee, which is the constant?
166
456
  const [lo, ro] = cmp.operands;
167
457
  const lc = evalConst(lo),
@@ -182,45 +472,16 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
182
472
  return { x, k, cls, opcode: cmp.opcode, xOnLeft };
183
473
  };
184
474
 
185
- // Evaluate a test predicate for a CONCRETE scrutinee value — used to SIMULATE the decision tree and
186
- // verify recovered case values (below). Returns true iff the `taken` (successors[0]) edge is followed.
187
- // Signed/unsigned per the icmp opcode (PRE3, done concretely rather than via interval lattices).
188
- const evalCmp = (opcode: string, xOnLeft: boolean, xv: number, k: number): boolean => {
189
- const uns = opcode.startsWith('icmp_u');
190
- const [xn, kn] = uns ? [xv >>> 0, k >>> 0] : [xv | 0, k | 0];
191
- const [l, r] = xOnLeft ? [xn, kn] : [kn, xn]; // put the scrutinee where it textually appears
192
- switch (opcode) {
193
- case 'icmp_eq':
194
- return l === r;
195
- case 'icmp_ne':
196
- return l !== r;
197
- case 'icmp_slt':
198
- case 'icmp_ult':
199
- return l < r;
200
- case 'icmp_sle':
201
- case 'icmp_ule':
202
- return l <= r;
203
- case 'icmp_sgt':
204
- case 'icmp_ugt':
205
- return l > r;
206
- case 'icmp_sge':
207
- case 'icmp_uge':
208
- return l >= r;
209
- default:
210
- return false;
211
- }
212
- };
213
-
214
475
  // Where does one arm's region LEAVE? Walk it from `entry`, never stepping THROUGH the merge or a
215
476
  // sibling arm's entry, and classify what it steps INTO. `siblings` is every OTHER arm entry the
216
477
  // caller can emit a `case`/`default` label for — the merge is deliberately not among them, so a
217
478
  // switch whose default block IS the merge (agbcc's usual "the default just leaves") reads as an
218
479
  // ordinary `break`, not as falling into the default.
219
480
  //
220
- // Region membership is `dom(blk) ∋ b` as before: a block NOT dominated by the switch is outside
221
- // this switch's region and is not walked. It IS recorded as an escape, because an arm that can
222
- // leave sideways does not fall into the next case — but only the fall-through verdict consults
223
- // that, so no arm that used to be accepted as closed becomes a decline.
481
+ // Region membership is `dom(blk) ∋ b`: a block NOT dominated by the switch is outside this
482
+ // switch's region and is not walked. It IS recorded as an escape, because an arm that can leave
483
+ // sideways does not fall into the next case — and only the FALL-THROUGH verdict consults that
484
+ // record, so an arm that reaches no sibling still closes with a plain `break`.
224
485
  //
225
486
  // A CONSEQUENCE, not a hole: a sibling reachable only THROUGH such a block is never seen, so the
226
487
  // arm reads as closed and `structureRegion` walks into the sibling's blocks and emits them again
@@ -261,7 +522,7 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
261
522
  }
262
523
  // Name what is actually missing. These three are different facts, and only the first is a shape
263
524
  // C has no spelling for — the other two are asmlift's own limits, so say so rather than blame C.
264
- const names = () => [...into].map((x) => `#${fn.blocks.indexOf(x)}`).join(', ');
525
+ const names = () => [...into].map((x) => `#${layoutIndex(x)}`).join(', ');
265
526
  if (into.size > 1) {
266
527
  return {
267
528
  kind: 'unstructurable',
@@ -283,15 +544,8 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
283
544
  };
284
545
  };
285
546
 
286
- /** Every arm closed (`break`)? The precondition Regime A needs — it has a behaviourally identical
287
- * fallback (if-recovery), so it declines on anything else instead of recovering fall-through. */
288
- const allArmsClosed = (targets: Set<Block>, b: Block, merge: Block | null): boolean => {
289
- const siblings = new Set([...targets].filter((t) => t !== merge));
290
- return [...siblings].every((t) => analyzeArmExit(t, b, merge, siblings).kind === 'break');
291
- };
292
-
293
547
  const recognizeSwitch = (b: Block, stop: Block | null): Stmt[] | null => {
294
- const root = testInfo(b, true);
548
+ const root = testInfo(b);
295
549
  if (!root) {
296
550
  return null;
297
551
  }
@@ -314,27 +568,10 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
314
568
  // Walk the test tree. `cases`: value → case-entry block. `defaultCands`: leaves reached without an
315
569
  // equality pin. A test-block DAG cycle, or a `!=` case when the compiler disallows it, declines.
316
570
  const cases = new Map<number, Block>();
571
+ // Reached through `forwardingTarget`: agbcc's binary-search layout branches to the shared default
572
+ // through empty `b .Ldef` blocks, and without resolving them each becomes a DISTINCT default
573
+ // candidate and the whole tree declines.
317
574
  const defaultCands = new Set<Block>();
318
- // Skip pure forwarding blocks — a block whose only op is an unconditional `br` (no side effects, no
319
- // params). agbcc's binary-search layout branches to the shared default through such empty `b .Ldef`
320
- // blocks; without skipping them each becomes a DISTINCT default candidate and the whole tree declines.
321
- const skipForward = (blk: Block): Block => {
322
- let cur = blk;
323
- const guard = new Set<Block>();
324
- // Only skip a truly empty forwarding block: a lone `br` with no params AND no successor ARGS —
325
- // an edge that carries a phi arg is NOT transparent (skipping it would drop that assignment).
326
- while (
327
- cur.ops.length === 1 &&
328
- cur.ops[0].opcode === 'br' &&
329
- cur.params.length === 0 &&
330
- cur.ops[0].successors[0].args.length === 0 &&
331
- !guard.has(cur)
332
- ) {
333
- guard.add(cur);
334
- cur = cur.ops[0].successors[0].block;
335
- }
336
- return cur;
337
- };
338
575
  // Concretely SIMULATE the decision tree for a scrutinee value `xv`, returning the leaf block it
339
576
  // reaches (or null on an unexpected cycle). This is PRE3 done concretely: it lets us verify each
340
577
  // recovered case value actually routes to its recorded body in the ORIGINAL tree.
@@ -342,7 +579,7 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
342
579
  let cur = b;
343
580
  const guard = new Set<Block>();
344
581
  for (;;) {
345
- const ti = testInfo(cur, cur === b);
582
+ const ti = testInfo(cur);
346
583
  if (!ti || ti.x !== scrut) {
347
584
  return cur;
348
585
  } // reached a leaf (case body / default)
@@ -352,7 +589,7 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
352
589
  guard.add(cur);
353
590
  const term = cur.ops[cur.ops.length - 1];
354
591
  const taken = evalCmp(ti.opcode, ti.xOnLeft, xv, ti.k);
355
- cur = skipForward(term.successors[taken ? 0 : 1].block);
592
+ cur = forwardingTarget(term.successors[taken ? 0 : 1].block);
356
593
  }
357
594
  };
358
595
  const seen = new Set<Block>();
@@ -363,59 +600,76 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
363
600
  return null;
364
601
  } // a test-block DAG cycle → decline
365
602
  seen.add(blk);
366
- const ti = testInfo(blk, blk === b);
603
+ const ti = testInfo(blk);
367
604
  if (!ti || ti.x !== scrut) {
368
605
  return null;
369
606
  } // PRE1: every test is on the SAME Value
607
+ if (blk !== b && !collapsible(blk)) {
608
+ return null;
609
+ } // PRE4
370
610
  const term = blk.ops[blk.ops.length - 1];
371
- const taken = skipForward(term.successors[0].block),
372
- fall = skipForward(term.successors[1].block);
373
- const asLeafOrTest = (child: Block, role: 'case' | 'nav', k?: number) => {
374
- const isTest = !!testInfo(child, false) && testInfo(child, false)!.x === scrut;
375
- if (role === 'case') {
376
- if (isTest) {
377
- return false;
378
- } // a case target that's a test → decline
379
- if (child.params.length) {
380
- return false;
381
- } // case entry with a phi decline
382
- if (cases.has(k!)) {
383
- return false;
384
- } // duplicate case value → decline
385
- cases.set(k!, child);
386
- return true;
387
- }
388
- // navigation edge
389
- if (isTest) {
611
+ const taken = forwardingTarget(term.successors[0].block),
612
+ fall = forwardingTarget(term.successors[1].block);
613
+ const isTestOn = (child: Block) => {
614
+ const t = testInfo(child);
615
+ return !!t && t.x === scrut;
616
+ };
617
+ /** Read `child` as the BODY of case `k`. A case entry with a PHI is admitted: the dispatch
618
+ * edge binds those parameters, and `hoistDispatchCopies` re-emits that binding once above
619
+ * the `switch` (or declines the whole recovery). A fall-through chain's accumulator crosses
620
+ * every arm as exactly such a parameter, so refusing it here refuses the whole family. */
621
+ const asCase = (child: Block, k: number): boolean => {
622
+ if (isTestOn(child)) {
623
+ return false;
624
+ } // a case target that's a test → decline
625
+ if (cases.has(k)) {
626
+ return false;
627
+ } // duplicate case value → decline
628
+ cases.set(k, child);
629
+ return true;
630
+ };
631
+ /** Read `child` as a NAVIGATION edge: more dispatch to walk, or a non-test leaf, which is a
632
+ * default candidate. Never declines — the leaf's own reading is settled below. */
633
+ const asNav = (child: Block): boolean => {
634
+ if (isTestOn(child)) {
390
635
  work.push(child);
391
636
  return true;
392
637
  }
393
- defaultCands.add(child); // a non-test leaf reached by nav = default
638
+ defaultCands.add(child);
394
639
  return true;
395
640
  };
396
641
  if (ti.cls === 'eq') {
397
- if (!asLeafOrTest(taken, 'case', ti.k)) {
642
+ if (!asCase(taken, ti.k)) {
398
643
  return null;
399
644
  } // x==k → taken is case k
400
- if (!asLeafOrTest(fall, 'nav')) {
645
+ if (!asNav(fall)) {
401
646
  return null;
402
647
  }
403
648
  } else if (ti.cls === 'ne') {
404
649
  if (!switchAllowsNeqCase) {
405
650
  return null;
406
651
  } // per-compiler gate
407
- if (!asLeafOrTest(fall, 'case', ti.k)) {
652
+ if (!asCase(fall, ti.k)) {
408
653
  return null;
409
654
  } // x!=k → the EQUAL side (fall) is case k
410
- if (!asLeafOrTest(taken, 'nav')) {
655
+ if (!asNav(taken)) {
411
656
  return null;
412
657
  }
413
658
  } else {
414
- // relational → pure navigation
415
- if (!asLeafOrTest(taken, 'nav')) {
659
+ // relational → navigation, except where the BRANCH has collapsed to a single value and
660
+ // lands on a BODY, on a compiler that declared the spelling. Two more refusals:
661
+ // - a bound test at the ROOT. `emit_case_nodes` emits a single-valued node's own
662
+ // `do_jump_if_equal` before either descent test, so a bound test always sits under
663
+ // another test of the same tree; one that OPENS the region did not come from this
664
+ // dispatch, and reading it as a case turns a comparison chain into a `switch`;
665
+ // - a singleton branch onto another TEST of the scrutinee, which is the search
666
+ // descending to pin the value. It is dispatch, so the walk reads it as dispatch —
667
+ // recovering it, or declining at PRE4 if it is not collapsible.
668
+ const k = switchAllowsBoundCase && blk !== b && !isTestOn(taken) ? singletonTaken(ti) : null;
669
+ if (!(k === null ? asNav(taken) : asCase(taken, k))) {
416
670
  return null;
417
671
  }
418
- if (!asLeafOrTest(fall, 'nav')) {
672
+ if (!asNav(fall)) {
419
673
  return null;
420
674
  }
421
675
  }
@@ -426,14 +680,64 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
426
680
  } // not worth a switch (m2c: ≥2 cases)
427
681
  // The default is the single non-test leaf that is NOT a case body. 0 → no default; ≥2 distinct → decline.
428
682
  const caseBlocks = new Set(cases.values());
429
- const defaults = [...defaultCands].filter((d) => !caseBlocks.has(d));
430
- if (defaults.length > 1) {
683
+ const leaves = new Set([...defaultCands].filter((d) => !caseBlocks.has(d)));
684
+ // RESOLVE THROUGH a bare jump onto another candidate. `forwardingTarget` (ir/core.ts) stops at
685
+ // a `br` that carries block ARGUMENTS — skipping it would drop the values it supplies — so a
686
+ // leaf holding `b .Ldefault(v)` and `.Ldefault` itself arrive here as two distinct candidates
687
+ // even though one merely jumps to the other. They are not two defaults: the jumping leaf emits
688
+ // nothing of its own, and the values its `br` hands the other's parameters are the values the
689
+ // DISPATCH hands them. So this is `forwardingTarget`'s walk with the args-carrying step
690
+ // ADMITTED rather than refused, and it does not drop those values either — the step becomes one
691
+ // more dispatch edge, hoisted with the rest, where the hoist's disagreement rule decides
692
+ // whether the two paths can share one statement. (ir/core.ts lists the non-callers and why.)
693
+ //
694
+ // A cycle of such jumps has no target to resolve to and declines. Leaves that pass DIFFERENT
695
+ // values, or that have a body, are untouched and are still two defaults below. A SET so a leaf
696
+ // walked twice — L → X → D reaches X from L's walk and again from X's own turn in the loop —
697
+ // is recorded once.
698
+ const throughEdges = new Set<Block>();
699
+ const resolveDefault = (d: Block): Block | null => {
700
+ const walked = new Set<Block>();
701
+ let cur = d;
702
+ for (;;) {
703
+ if (walked.has(cur)) {
704
+ return null;
705
+ }
706
+ walked.add(cur);
707
+ const t = cur.ops[0];
708
+ if (!isBareExit(cur) || t.opcode !== 'br' || !leaves.has(t.successors[0].block)) {
709
+ return cur;
710
+ }
711
+ throughEdges.add(cur);
712
+ cur = t.successors[0].block;
713
+ }
714
+ };
715
+ const defaults: Block[] = [];
716
+ for (const d of leaves) {
717
+ const r = resolveDefault(d);
718
+ if (r === null) {
719
+ return null;
720
+ }
721
+ if (!defaults.includes(r)) {
722
+ defaults.push(r);
723
+ }
724
+ }
725
+ // ONE default reached by SEVERAL leaves. `balance_case_nodes`/`emit_case_nodes` give each
726
+ // subtree that runs out of case values its own jump to the default, so agbcc's four-case tree
727
+ // reaches it through two `b .Ldefault` blocks, which comparing candidates by BLOCK would count
728
+ // as two different defaults and decline. Two leaves are the same default when each is
729
+ // a bare EXIT to the same place carrying the same values: nothing about them can then differ,
730
+ // so the representative emits what either would. Anything else — a leaf with a body, two
731
+ // leaves passing different values — is still two defaults and still declines.
732
+ if (defaults.length > 1 && !defaults.every((d) => sameBareExit(defaults[0], d))) {
431
733
  return null;
432
734
  }
433
735
  const defaultBlk = defaults[0] ?? null;
434
- if (defaultBlk && defaultBlk.params.length) {
435
- return null;
436
- } // default entry with a phi decline
736
+ // A default entry that takes BLOCK PARAMETERS is admitted on the same terms a case entry is:
737
+ // the dispatch edge binds them and `hoistDispatchCopies` re-emits that binding above the
738
+ // `switch`. The hazard it stands over is silent — without that re-emission,
739
+ // `switch (x) { case 1: … case 2: … }` whose fall-out edge also carried `w = 0` drops the write
740
+ // and looks entirely ordinary doing it — so the admission is only as good as the hoist.
437
741
  // A default candidate that is ALSO a case body means a relational edge hit a case leaf → ambiguous.
438
742
  if ([...defaultCands].some((d) => caseBlocks.has(d))) {
439
743
  return null;
@@ -449,13 +753,28 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
449
753
  return null;
450
754
  }
451
755
 
452
- // PRE2 (fall-through): only NON-fall-through switches are handled decline if any case body
453
- // can reach ANOTHER case body (or a default that has its own block) while staying inside the
454
- // region. (The SAME analysis serves the Regime-B path, which RECOVERS the adjacent-sibling
455
- // case as C fall-through instead of declining; A has if-recovery to fall back on, B does not.)
756
+ // PRE2 (arm exits), per SITE. A `break` arm closes; a `fallthrough` arm runs on into the one
757
+ // sibling it reaches, which `chainArms` then places directly under it. `unstructurable` is a
758
+ // shape no single linear switch spells a body reaching two siblings, or a sibling on one path
759
+ // and the switch's end on another and Regime A declines to if-recovery, which spells every
760
+ // one of those edges. (Regime B reads the same verdicts and fails LOUD on either.)
456
761
  const merge = ipdom.get(b) ?? stop;
457
762
  const targets = new Set<Block>([...caseBlocks, ...(defaultBlk ? [defaultBlk] : [])]);
458
- if (!allArmsClosed(targets, b, merge)) {
763
+ const siblings = new Set([...targets].filter((t) => t !== merge));
764
+ const exitOf = new Map<Block, ArmExit>();
765
+ for (const t of siblings) {
766
+ exitOf.set(t, analyzeArmExit(t, b, merge, siblings));
767
+ }
768
+ // A language with no fall-through in its `case` (Pascal) cannot print a falling arm at all, so
769
+ // for it a falling arm is exactly as unspellable as an `unstructurable` one — and takes the
770
+ // same exit: if-recovery, which that backend prints fine. Asked HERE rather than left to the
771
+ // backend because the backend's refusal is terminal (the whole function becomes a stub) while
772
+ // this one costs nothing but the `switch` spelling.
773
+ if (
774
+ [...exitOf.values()].some(
775
+ (e) => e.kind === 'unstructurable' || (!spellSwitchFallthrough && e.kind === 'fallthrough'),
776
+ )
777
+ ) {
459
778
  return null;
460
779
  }
461
780
 
@@ -469,29 +788,275 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
469
788
  }
470
789
  }
471
790
 
472
- // Build the switch. Cases sorted ascending (safe: no fall-through PRE2). Bodies delegate to the
473
- // existing structureRegion (loops/ifs inside cases, the onStack guard all reused).
791
+ // PRE5 (WHICH SPELLING THE SOURCE WROTE), per SITE, and only where the caller has declared
792
+ // that the layout answers it. Everything above establishes that this tree CAN be spelled as a
793
+ // `switch`; this asks whether it WAS. The two spellings are different objects, not one object
794
+ // two ways: a source `switch` front-loads its whole dispatch ahead of every arm body (agbcc's
795
+ // `expand_end_case` closes with a `reorder_insns` that moves the dispatch in front of the
796
+ // bodies expanded before it — the same sources `switchArmsFollowLayout` is read off), while an
797
+ // if/else-if ladder emits each test directly above the body it guards. So a test block laid
798
+ // out AFTER a case body cannot have come from a source `switch`, and recovering one here spells
799
+ // a ladder as a `switch` with no dual anywhere in the fan for a differ to prefer.
800
+ //
801
+ // READ PER SITE, off THIS recovery's own blocks, never off the function. A function holding a
802
+ // ladder and a real `switch` must keep both readings, and a function-wide OR would collapse
803
+ // them into one boolean and be wrong on one site by construction.
804
+ //
805
+ // WHAT IT REFUSES TO DO. The reading runs BACKWARDS, from emission to spelling, so it inherits
806
+ // `layoutIndex`'s premise (above) at the weaker of the two strengths stated there. A frontend
807
+ // or compiler that breaks it can hand this a front-loaded layout for a ladder, and then the
808
+ // gate simply does not fire. The converse error, a real `switch` whose layout interleaves,
809
+ // costs only the `switch` SPELLING: the recovery declines to if-recovery, behaviourally
810
+ // identical (the file header) and scored by the differ. This gate can lose a match; it can
811
+ // never produce a wrong answer.
812
+ //
813
+ // WHAT THE DECLINE PRODUCES, AND IT IS NOT ALWAYS A CLEAN LADDER. Recovery is RECURSIVE:
814
+ // declining a tree here hands the tree back to if-recovery, which then runs recovery AGAIN on
815
+ // each sub-tree it structures. On a flat tree that is a ladder over every case value. On a
816
+ // NESTED tree it is not: `corpus/agbcc-swnested.s` (a source `switch` nested inside an
817
+ // if/else-if ladder) declines HERE on the outer tree — correctly, the ladder arm's body really
818
+ // does sit between the tests — and the sub-tree then re-recovers, so the emitted C is an `if`
819
+ // nest holding a `switch` over a STRICT SUBSET of the source's case labels. Still behaviourally
820
+ // identical, and no worse than the alternative (with the reading withdrawn that same function
821
+ // comes back as one `switch` merging two arms the source wrote apart), but it is a THIRD
822
+ // spelling that neither reading would give, and nothing in the output says a dispatch was
823
+ // half-declined: "the decline is a clean ladder" holds at the OUTERMOST tree only. Pinned by
824
+ // the test "PRE5 declines a tree RECURSIVELY" on the committed fixture.
825
+ //
826
+ // WHAT THE POSITIONS ARE READ THROUGH, which is the premise that lives inside asmlift rather
827
+ // than in the compiler. `caseBlocks` holds `forwardingTarget` results (`asCase` is called on the
828
+ // resolved block), so an arm whose body is a bare jump contributes ITS TARGET's position, not
829
+ // its own. A forwarding target laid out ABOVE the dispatch would then read as a body above a
830
+ // test and decline a real `switch`.
831
+ //
832
+ // WHY THAT HAS NO INHABITANT IS MEASURED, NOT ARGUED. Two arguments that look like proofs are
833
+ // not: "the block is reached by a back edge" needs the block to DOMINATE the dispatch, which an
834
+ // `if` arm ahead of the `switch` does not, and a cross-jumped arm tail reaches it on an
835
+ // ordinary forward edge; "this flag's targets reorder nothing" is false — `MIPS_GCC` declares
836
+ // it WITH a scheduler, on the half-premise stated at `layoutIndex` above. Measured: across 28
837
+ // compiled probes on agbcc, gcc2.7.2 -O1 and gcc2.7.2kmc -O2 — arms cross-jumped onto a shared
838
+ // tail ahead of the dispatch, `goto`s out of an arm to a label above it, shared early returns,
839
+ // `continue`/`break` out of a switch inside a loop — no toolchain placed an arm's forwarding
840
+ // target above its own dispatch: every firing came back with zero bodies above the first test.
841
+ // Safe because no compiler in the corpus emits that shape, not because none can; whoever makes
842
+ // one — a new frontend, a relaxed loop recovery, a scheduler that hoists a merged tail —
843
+ // inherits this reader.
844
+ //
845
+ // `bodyPos` covers the CASE bodies only. `defaultBlk` is left out deliberately: it can be the
846
+ // dispatch's own fall-out block rather than an arm the source wrote (the W2/W4 withholdings
847
+ // above), so its position is one the source never chose. Both omissions can only make the gate
848
+ // UNDER-fire, i.e. keep a `switch`, which is the safe direction.
849
+ //
850
+ // AND THE READING REFUSES ITSELF where it has nothing to read. `layoutIndex` answers `-1` for a
851
+ // block absent from `fn.blocks`, which is not a position — taken as one it sorts below every
852
+ // real body and declines the tree holding it. That state has no inhabitant and cannot get one
853
+ // without a bug upstream: `ir/verify.ts` rejects a successor that is not a block of the fn and
854
+ // the tower runs it after the lift and after every raising pass, and `predecessorBlocks` throws
855
+ // by name on the same state near the top of `structure()`. So this is belt-and-braces over a
856
+ // VERIFIER BUG, not over an expected shape — kept because standing down costs nothing. Its test
857
+ // pins the UNREACHABILITY (both throws fire first); the `placed` branch itself stays
858
+ // unexecuted, and cannot be covered without breaking one of those two invariants.
859
+ if (switchRequiresFrontLoadedTests) {
860
+ const testPos = [...seen].map(layoutIndex);
861
+ const bodyPos = [...caseBlocks].map(layoutIndex);
862
+ const placed = [...testPos, ...bodyPos].every((i) => i >= 0);
863
+ if (placed && Math.max(...testPos) > Math.min(...bodyPos)) {
864
+ return null;
865
+ }
866
+ }
867
+
868
+ // ARM ORDER. The case values are disjoint (PRE3), so where no arm falls through the order
869
+ // carries no meaning and is pure matching evidence: ascending case VALUE is the neutral
870
+ // spelling, and where a compiler has declared `switchArmsFollowLayout` the layout of the bodies
871
+ // is the SOURCE's arm order instead. A FALLING arm's position is not free, and `chainArms`
872
+ // below re-threads this order for those — reading it for the chain HEADS only.
873
+ //
874
+ // TWO arms the layout cannot order, both falling back rather than recovering:
875
+ // - two case VALUES sharing one body block share its index, so the tie is one the merge (or
876
+ // the source's own stacked labels) erased, and ascending value breaks it. They become ONE
877
+ // arm below, so the tie only orders that arm against the others. ADJACENT stacked labels
878
+ // never get here: `case 2: case 3:` compiles to a range test the walk reads as navigation
879
+ // and declines, while `case 0: case 2:` compiles to the two equality tests it recovers —
880
+ // both checked against agbcc;
881
+ // - an arm with no body of its own (`case k: break;`) has its edge resolve to the MERGE, so it
882
+ // inherits the merge's index and sorts after every arm that HAS a body.
883
+ //
884
+ // BOOKED, UNPAID: the grouping makes arm ORDER observable on a shape the ascending-value
885
+ // fallback did not have before, and IDO turns out to lay case bodies out in SOURCE order too —
886
+ // the same function with `case 1:` written first and last differ (.text md5 ec39af99 against
887
+ // 689f34ec, 144 bytes each), which is what `switchArmsFollowLayout` recovers. target.ts sets
888
+ // the bar for opting a compiler in at a SOURCE-level argument about its passes, not at two
889
+ // objects, and that argument is unpaid for IDO; nothing here changes while it is. What the
890
+ // grouping cannot do is make a non-agbcc row worse, because the ungrouped spelling is not a
891
+ // rival there: under IDO it compiles to two copies and a different ROM entirely.
474
892
  const scrutExpr = expr(scrut);
475
- const sortedCases = [...cases.entries()].sort((a, c) => a[0] - c[0]);
476
- const outCases: SwitchCase[] = sortedCases.map(([k, blk]) => ({
477
- values: [k],
478
- body: structureRegion(blk, merge),
479
- fallsThrough: false,
480
- }));
893
+ const sortedCases = [...cases.entries()].sort((a, c) =>
894
+ switchArmsFollowLayout ? layoutIndex(a[1]) - layoutIndex(c[1]) || a[0] - c[0] : a[0] - c[0],
895
+ );
896
+ // TWO VALUES ONE BODY IS ONE ARM. `SwitchCase.values` stacks labels for exactly this, and the
897
+ // jump-table regime groups the same way (structure.ts's `armOf`). Emitting the body once per
898
+ // label is `structureRegion` run twice over one block: a duplication, not a spelling choice.
899
+ //
900
+ // WHY BLOCK IDENTITY IS THE KEY, and not a body-equality one like `sameBareExit` above. agbcc
901
+ // MERGES two written-out copies into one block — target.ts's `switchArmsFollowLayout` note
902
+ // says so from agbcc's own sources, SRCS compiling jump.c — and compiling both directions at
903
+ // TOOLCHAIN.agbccFlags says WHERE the merged block lands: at the last copy's position. So
904
+ // `case 0: A break; case 1: … case 2: A break;` and the grouped arm placed THERE are one
905
+ // object (.text md5 555abb1a), while the grouped arm placed at the first value is not
906
+ // (fe4d7d35). That is why the grouped spelling round-trips rather than merely reading shorter:
907
+ // agbcc declares `switchArmsFollowLayout`, so the arm goes exactly where the merged block sits.
908
+ // IDO does not merge at all — 224 bytes against the grouped 144 — so on MIPS a shared block can
909
+ // only have come from stacked labels. Two DISTINCT blocks with equal bodies therefore mean one
910
+ // arm on neither compiler: under agbcc that ROM is unreachable, under IDO it is what two arms
911
+ // compile to. Sound also because two dispatch edges onto ONE body cannot bind one name two
912
+ // ways: `hoistDispatchCopies` merges every collapsed edge's copies into a single statement
913
+ // above the `switch` and refuses on disagreement (structure.ts `hoistedDispatchAssigns`), so
914
+ // an arm reached by two case values is reached with one set of parameter values or the whole
915
+ // recovery declines.
916
+ //
917
+ // An arm takes the position of its FIRST value, which keeps the sort above. `defaultLayoutPos`
918
+ // is handed the GROUPED entry list because what it returns is an INDEX INTO the arm array, so
919
+ // the list it counts and the list it indexes must be one list.
920
+ const armsByBlock = new Map<Block, number[]>();
921
+ for (const [k, blk] of sortedCases) {
922
+ const prev = armsByBlock.get(blk);
923
+ if (prev) {
924
+ prev.push(k);
925
+ } else {
926
+ armsByBlock.set(blk, [k]);
927
+ }
928
+ }
929
+ // The `default:` arm is a chain member too — an arm may run on into it, and C prints it last.
930
+ const dfltArm = defaultBlk !== null && defaultBlk !== merge ? defaultBlk : null;
931
+ const preChain = [...armsByBlock.keys()];
932
+ const entries = chainArms(preChain, dfltArm, exitOf);
933
+ if (entries === null) {
934
+ return null;
935
+ }
936
+ // Did the chain move anything? The arm-order POLICY above produced `preChain`; where the chain
937
+ // left it alone, every position still means what that policy said it meant.
938
+ const orderIntact = entries.every((e, i) => e === preChain[i]);
939
+ const fallsInto = (blk: Block): Block | null => {
940
+ const x = exitOf.get(blk);
941
+ return x?.kind === 'fallthrough' ? x.to : null;
942
+ };
943
+ // THE DISPATCH HOIST. Every edge the tree walk is about to COLLAPSE — from any test block, to
944
+ // a case entry, a default candidate, the merge, or another test block — carries the parallel
945
+ // copy that binds its target's parameters, and collapsing the tree is what would discard it.
946
+ // Merge those copies and emit them ONCE, above the `switch`. Placed FIRST because it is
947
+ // emitted first and `argAssignsFor` mints swap-cycle temp names as it goes.
948
+ //
949
+ // TWO CONDITIONS ARE THIS REGIME'S OWN, and both must hold before the emission is asked for:
950
+ //
951
+ // - AVAILABILITY AT THE ROOT. A hoisted copy is evaluated at `b`, not on its edge, so every
952
+ // argument it reads must be defined at a block that DOMINATES `b`. An argument computed
953
+ // inside a collapsed test block is not: the tree reaches that block only on some paths,
954
+ // while the hoist runs on all of them, and re-rendering it above the dispatch would
955
+ // SPECULATE it. (That is the one invariant PRE4's "a use is dominated by its def, so a
956
+ // collapsed op runs on a subset of the paths it already ran on" does not give the hoist,
957
+ // which is why the hoist asks for it here rather than inheriting it.)
958
+ // - The emission's own two refusals, stated at `hoistedDispatchAssigns`: edges disagreeing
959
+ // about one name, and a hoisted name whose value is still live at the switch or into an arm.
960
+ //
961
+ // Either way the answer is a decline to if-recovery, which spells every copy the asm performs.
962
+ const availableAtRoot = (v: Value): boolean => {
963
+ const home = blockOf(v);
964
+ return home !== undefined && dom.get(b)!.has(home);
965
+ };
966
+ const dispatchEdges: { pred: Block; succ: { block: Block; args: Value[] } }[] = [];
967
+ for (const t of new Set([...seen, ...throughEdges])) {
968
+ for (const e of t.ops[t.ops.length - 1].successors) {
969
+ if (e.block.params.length === 0) {
970
+ continue;
971
+ }
972
+ if (!e.args.every(availableAtRoot)) {
973
+ return null;
974
+ }
975
+ dispatchEdges.push({ pred: t, succ: e });
976
+ }
977
+ }
978
+ const hoisted = hoistDispatchCopies(dispatchEdges, [
979
+ b,
980
+ ...caseBlocks,
981
+ ...(defaultBlk ? [defaultBlk] : []),
982
+ ...(merge ? [merge] : []),
983
+ ]);
984
+ if (hoisted === null) {
985
+ return null;
986
+ }
987
+ // Bodies are structured in EMISSION order — `argAssignsFor` mints swap-cycle temp names as it
988
+ // goes, so building them out of order changes the output — and each falling arm's target is
989
+ // re-read off that order rather than trusted from the ordering above: this is the seam where a
990
+ // POSITION acquires control-flow meaning (the l3/ast.ts non-neutrality note).
991
+ const outCases: SwitchCase[] = [];
992
+ for (const [i, blk] of entries.entries()) {
993
+ const to = fallsInto(blk);
994
+ if (to !== null && to !== (entries[i + 1] ?? dfltArm)) {
995
+ return null;
996
+ }
997
+ // The arm fallen INTO may take block parameters, and the TWO paths that reach it are spelled
998
+ // in two different places: entering by its own case value takes the hoisted copy above the
999
+ // `switch` (`hoistDispatchCopies`), while FALLING in takes the copies the falling arm's own
1000
+ // `br` emits as its last statements (`structureRegion(blk, to)` walks that terminator, so
1001
+ // they are already there). Getting this wrong is SILENT, which is why it rests on the hoist
1002
+ // rather than on a local reading of the arm.
1003
+ //
1004
+ // Regime B refuses the same hazard LOUD at its own `switch_br` path, on the weaker predicate
1005
+ // that fits it (the copies `argAssignsFor` actually produced): its arms take their edge
1006
+ // copies PER ARM, so a fall-through path would re-run them over what the falling arm
1007
+ // computed. Hoisting is what removes that hazard, and Regime B does not hoist — booked, not
1008
+ // built, and no row asks for it (`sw_jtfall`/`sw_jtfalldesc` match today).
1009
+ outCases.push({
1010
+ values: armsByBlock.get(blk)!,
1011
+ body: structureRegion(blk, to ?? merge),
1012
+ fallsThrough: to !== null,
1013
+ });
1014
+ }
481
1015
  // An empty default arm is not a default (see the Regime-B note in structure.ts): the label
482
1016
  // would carry no statement, which says nothing and is not valid C89.
483
1017
  const defBody = defaultBlk ? structureRegion(defaultBlk, merge) : [];
1018
+ // The `default:` arm is an ARM: where its block is laid out is read exactly as a case body's is
1019
+ // (`defaultLayoutPos`). The dispatch placed that block itself when the last test simply RAN OUT
1020
+ // into it and no other subtree jumps there — the two references the tree walk can count.
1021
+ const dispatchTargets = [...seen].flatMap((t) =>
1022
+ t.ops[t.ops.length - 1].successors.map((e) => forwardingTarget(e.block)),
1023
+ );
1024
+ // THE TWO CONJUNCTS READ THE EDGES DIFFERENTLY, and deliberately. The first is asked RAW, of
1025
+ // the terminator's own second successor, because a surviving `b .Ldefault` block is a jump
1026
+ // that did NOT collapse into a fall-through — resolving it away would count the jump as the
1027
+ // running-out it is not (see the `expand_end_case` paragraph on `defaultLayoutPos`). The
1028
+ // second is asked FORWARDED, over `dispatchTargets`, because there a forwarder is transparent:
1029
+ // two edges that arrive through one are two references to the same block, which is exactly
1030
+ // what "named more than once" has to count.
1031
+ const fellThroughIntoIt = (blk: Block) =>
1032
+ [...seen].some((t) => {
1033
+ const succ = t.ops[t.ops.length - 1].successors;
1034
+ return succ.length > 1 && succ[1].block === blk;
1035
+ });
1036
+ const namedOnlyOnce = (blk: Block) => dispatchTargets.filter((e) => e === blk).length < 2;
1037
+ const ranOutInto = (blk: Block) => fellThroughIntoIt(blk) && namedOnlyOnce(blk);
1038
+ // `defaultLayoutPos` owns which POSITIONS a chain makes unreadable — see its fall-through
1039
+ // withholdings W5..W7. A chain elsewhere in the switch does not delete the evidence for
1040
+ // where the label goes, and reading it off the emitted arms is what lets a `default:` written
1041
+ // between two closed arms keep its place while a chain runs beside it.
1042
+ const defaultAt = defaultBlk
1043
+ ? defaultLayoutPos(
1044
+ defaultBlk,
1045
+ entries.map((e, i) => ({ entry: e, fallsThrough: outCases[i].fallsThrough })),
1046
+ { placedByDispatch: ranOutInto(defaultBlk), orderIntact },
1047
+ )
1048
+ : undefined;
484
1049
  const sw: Stmt = {
485
1050
  k: 'switch',
486
1051
  scrutinee: scrutExpr,
487
1052
  cases: outCases,
488
- ...(defBody.length ? { default: defBody } : {}),
1053
+ ...(defBody.length ? { default: defBody, ...(defaultAt !== undefined ? { defaultAt } : {}) } : {}),
489
1054
  };
490
- const out: Stmt[] = [sw];
1055
+ const out: Stmt[] = [...hoisted, sw];
491
1056
  if (merge && merge !== stop) {
492
1057
  out.push(...structureRegion(merge, stop));
493
1058
  }
494
1059
  return out;
495
1060
  };
496
- return { recognizeSwitch, analyzeArmExit };
1061
+ return { recognizeSwitch, analyzeArmExit, layoutIndex, defaultLayoutPos, chainArms };
497
1062
  }