parseman 0.45.0 → 0.47.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 (333) hide show
  1. package/README.md +8 -3
  2. package/dist/analysis/choice-cost-gate.d.ts +158 -0
  3. package/dist/analysis/choice-cost-gate.d.ts.map +1 -0
  4. package/dist/analysis/choice-cost-render.d.ts +96 -0
  5. package/dist/analysis/choice-cost-render.d.ts.map +1 -0
  6. package/dist/analysis/choice-cost.d.ts +383 -0
  7. package/dist/analysis/choice-cost.d.ts.map +1 -0
  8. package/dist/analysis/commitment.d.ts +131 -0
  9. package/dist/analysis/commitment.d.ts.map +1 -0
  10. package/dist/analysis/corpus.d.ts +73 -0
  11. package/dist/analysis/corpus.d.ts.map +1 -0
  12. package/dist/analysis/diagnose-render.d.ts +65 -0
  13. package/dist/analysis/diagnose-render.d.ts.map +1 -0
  14. package/dist/analysis/diagnose.d.ts +15 -0
  15. package/dist/analysis/diagnose.d.ts.map +1 -1
  16. package/dist/analysis/diagnostics.cjs +13736 -0
  17. package/dist/analysis/diagnostics.cjs.map +6 -0
  18. package/dist/analysis/diagnostics.d.ts +27 -0
  19. package/dist/analysis/diagnostics.d.ts.map +1 -0
  20. package/dist/analysis/diagnostics.js +13713 -0
  21. package/dist/analysis/diagnostics.js.map +6 -0
  22. package/dist/analysis/duplication.d.ts.map +1 -1
  23. package/dist/analysis/fix-render.d.ts +43 -0
  24. package/dist/analysis/fix-render.d.ts.map +1 -0
  25. package/dist/analysis/fix.d.ts +172 -0
  26. package/dist/analysis/fix.d.ts.map +1 -0
  27. package/dist/analysis/format-number.d.ts +23 -0
  28. package/dist/analysis/format-number.d.ts.map +1 -0
  29. package/dist/analysis/gating.d.ts +47 -2
  30. package/dist/analysis/gating.d.ts.map +1 -1
  31. package/dist/analysis/rebuild.d.ts +61 -0
  32. package/dist/analysis/rebuild.d.ts.map +1 -0
  33. package/dist/analysis/terminal.d.ts +145 -0
  34. package/dist/analysis/terminal.d.ts.map +1 -0
  35. package/dist/cli/index.cjs +14837 -0
  36. package/dist/cli/index.cjs.map +6 -0
  37. package/dist/cli/index.d.ts +3 -0
  38. package/dist/cli/index.d.ts.map +1 -0
  39. package/dist/cli/index.js +14814 -0
  40. package/dist/cli/index.js.map +6 -0
  41. package/dist/combinators/adjacency.d.ts +100 -0
  42. package/dist/combinators/adjacency.d.ts.map +1 -0
  43. package/dist/combinators/choice.d.ts.map +1 -1
  44. package/dist/combinators/completions.d.ts.map +1 -1
  45. package/dist/combinators/dispatch.d.ts +8 -1
  46. package/dist/combinators/dispatch.d.ts.map +1 -1
  47. package/dist/combinators/expect.d.ts.map +1 -1
  48. package/dist/combinators/first-set.d.ts.map +1 -1
  49. package/dist/combinators/gate.d.ts.map +1 -1
  50. package/dist/combinators/grammar.d.ts +22 -0
  51. package/dist/combinators/grammar.d.ts.map +1 -1
  52. package/dist/combinators/literal.d.ts +8 -0
  53. package/dist/combinators/literal.d.ts.map +1 -1
  54. package/dist/combinators/map.d.ts +0 -1
  55. package/dist/combinators/map.d.ts.map +1 -1
  56. package/dist/combinators/node.d.ts +13 -3
  57. package/dist/combinators/node.d.ts.map +1 -1
  58. package/dist/combinators/parser.d.ts +1 -1
  59. package/dist/combinators/parser.d.ts.map +1 -1
  60. package/dist/combinators/ref.d.ts.map +1 -1
  61. package/dist/combinators/regex.d.ts +1 -16
  62. package/dist/combinators/regex.d.ts.map +1 -1
  63. package/dist/combinators/repeat.d.ts +30 -2
  64. package/dist/combinators/repeat.d.ts.map +1 -1
  65. package/dist/combinators/scanTo.d.ts +71 -1
  66. package/dist/combinators/scanTo.d.ts.map +1 -1
  67. package/dist/combinators/sequence.d.ts.map +1 -1
  68. package/dist/combinators/token.d.ts.map +1 -1
  69. package/dist/combinators/trivia-boundary.d.ts +26 -0
  70. package/dist/combinators/trivia-boundary.d.ts.map +1 -0
  71. package/dist/combinators/trivia-skip.d.ts +40 -1
  72. package/dist/combinators/trivia-skip.d.ts.map +1 -1
  73. package/dist/combinators/withCtx.d.ts.map +1 -1
  74. package/dist/compiler/build-arity.d.ts.map +1 -1
  75. package/dist/compiler/compile-linkable-table.d.ts +104 -0
  76. package/dist/compiler/compile-linkable-table.d.ts.map +1 -0
  77. package/dist/compiler/degradation.d.ts +0 -2
  78. package/dist/compiler/degradation.d.ts.map +1 -1
  79. package/dist/compiler/fields.d.ts +13 -0
  80. package/dist/compiler/fields.d.ts.map +1 -1
  81. package/dist/compiler/grammar-coverage-ids.d.ts +5 -5
  82. package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
  83. package/dist/compiler/ir-serialize.d.ts.map +1 -1
  84. package/dist/compiler/linker.d.ts +79 -47
  85. package/dist/compiler/linker.d.ts.map +1 -1
  86. package/dist/compiler/token-alphabet.d.ts +81 -0
  87. package/dist/compiler/token-alphabet.d.ts.map +1 -0
  88. package/dist/compiler/token-dispatch.d.ts +95 -0
  89. package/dist/compiler/token-dispatch.d.ts.map +1 -0
  90. package/dist/compiler/token-scanner.d.ts +53 -0
  91. package/dist/compiler/token-scanner.d.ts.map +1 -0
  92. package/dist/compiler/value-usage.d.ts.map +1 -1
  93. package/dist/coverage.d.ts +1 -1
  94. package/dist/coverage.d.ts.map +1 -1
  95. package/dist/cst/capture-buffer.d.ts +35 -0
  96. package/dist/cst/capture-buffer.d.ts.map +1 -1
  97. package/dist/cst/reflection-attach.d.ts +8 -0
  98. package/dist/cst/reflection-attach.d.ts.map +1 -0
  99. package/dist/cst/reflection-symbols.d.ts +10 -0
  100. package/dist/cst/reflection-symbols.d.ts.map +1 -0
  101. package/dist/cst/reflection.d.ts +2 -4
  102. package/dist/cst/reflection.d.ts.map +1 -1
  103. package/dist/cst/root-trivia-scope.d.ts +26 -0
  104. package/dist/cst/root-trivia-scope.d.ts.map +1 -0
  105. package/dist/cst/trivia-charscan.d.ts +18 -0
  106. package/dist/cst/trivia-charscan.d.ts.map +1 -0
  107. package/dist/cst/trivia-kinds.d.ts +19 -0
  108. package/dist/cst/trivia-kinds.d.ts.map +1 -1
  109. package/dist/functional/doc.d.ts.map +1 -1
  110. package/dist/functional/run-tabled.d.ts +12 -0
  111. package/dist/functional/run-tabled.d.ts.map +1 -0
  112. package/dist/functional/run.d.ts +25 -42
  113. package/dist/functional/run.d.ts.map +1 -1
  114. package/dist/grammar-metadata.d.ts +3 -0
  115. package/dist/grammar-metadata.d.ts.map +1 -0
  116. package/dist/index.cjs +10499 -7478
  117. package/dist/index.cjs.map +3 -4
  118. package/dist/index.d.ts +24 -9
  119. package/dist/index.d.ts.map +1 -1
  120. package/dist/index.js +10499 -7478
  121. package/dist/index.js.map +3 -4
  122. package/dist/language-service/index.cjs +311 -121
  123. package/dist/language-service/index.cjs.map +3 -4
  124. package/dist/language-service/index.js +311 -121
  125. package/dist/language-service/index.js.map +3 -4
  126. package/dist/oracle/index.cjs.map +0 -1
  127. package/dist/oracle/index.js.map +0 -1
  128. package/dist/parse-context.d.ts +49 -0
  129. package/dist/parse-context.d.ts.map +1 -0
  130. package/dist/plugin/evaluator.d.ts +41 -2
  131. package/dist/plugin/evaluator.d.ts.map +1 -1
  132. package/dist/plugin/free-identifiers.d.ts +23 -0
  133. package/dist/plugin/free-identifiers.d.ts.map +1 -0
  134. package/dist/plugin/index.cjs +11381 -9442
  135. package/dist/plugin/index.cjs.map +3 -4
  136. package/dist/plugin/index.d.ts.map +1 -1
  137. package/dist/plugin/index.js +11381 -9442
  138. package/dist/plugin/index.js.map +3 -4
  139. package/dist/plugin/reducer-resolver.d.ts +17 -1
  140. package/dist/plugin/reducer-resolver.d.ts.map +1 -1
  141. package/dist/regex/classes.d.ts +28 -0
  142. package/dist/regex/classes.d.ts.map +1 -1
  143. package/dist/regex/first-set.d.ts +14 -0
  144. package/dist/regex/first-set.d.ts.map +1 -1
  145. package/dist/run/index.cjs +136 -51
  146. package/dist/run/index.cjs.map +2 -3
  147. package/dist/run/index.js +136 -51
  148. package/dist/run/index.js.map +2 -3
  149. package/dist/spec/index.cjs +2898 -1622
  150. package/dist/spec/index.cjs.map +3 -4
  151. package/dist/spec/index.js +2898 -1622
  152. package/dist/spec/index.js.map +3 -4
  153. package/dist/spec/model.d.ts +8 -0
  154. package/dist/spec/model.d.ts.map +1 -1
  155. package/dist/table/assemble.d.ts +307 -0
  156. package/dist/table/assemble.d.ts.map +1 -0
  157. package/dist/table/cell.d.ts +42 -0
  158. package/dist/table/cell.d.ts.map +1 -0
  159. package/dist/table/child-slots.d.ts +30 -0
  160. package/dist/table/child-slots.d.ts.map +1 -0
  161. package/dist/table/compile-rule-map.d.ts +136 -0
  162. package/dist/table/compile-rule-map.d.ts.map +1 -0
  163. package/dist/table/compile.d.ts +56 -0
  164. package/dist/table/compile.d.ts.map +1 -0
  165. package/dist/table/duplication-hook.d.ts +32 -0
  166. package/dist/table/duplication-hook.d.ts.map +1 -0
  167. package/dist/table/emit-assembly.d.ts +139 -0
  168. package/dist/table/emit-assembly.d.ts.map +1 -0
  169. package/dist/table/emit.d.ts +113 -0
  170. package/dist/table/emit.d.ts.map +1 -0
  171. package/dist/table/encode-baseline.d.ts +16 -0
  172. package/dist/table/encode-baseline.d.ts.map +1 -0
  173. package/dist/table/encode.d.ts +71 -0
  174. package/dist/table/encode.d.ts.map +1 -0
  175. package/dist/table/exec-baseline.d.ts +9 -0
  176. package/dist/table/exec-baseline.d.ts.map +1 -0
  177. package/dist/table/exec.d.ts +72 -0
  178. package/dist/table/exec.d.ts.map +1 -0
  179. package/dist/table/fold.d.ts +25 -0
  180. package/dist/table/fold.d.ts.map +1 -0
  181. package/dist/table/index.cjs +11375 -0
  182. package/dist/table/index.cjs.map +6 -0
  183. package/dist/table/index.d.ts +100 -0
  184. package/dist/table/index.d.ts.map +1 -0
  185. package/dist/table/index.js +11352 -0
  186. package/dist/table/index.js.map +6 -0
  187. package/dist/table/inspect.d.ts +17 -0
  188. package/dist/table/inspect.d.ts.map +1 -0
  189. package/dist/table/ops.d.ts +446 -0
  190. package/dist/table/ops.d.ts.map +1 -0
  191. package/dist/table/program.d.ts +493 -0
  192. package/dist/table/program.d.ts.map +1 -0
  193. package/dist/table/run-support.d.ts +38 -0
  194. package/dist/table/run-support.d.ts.map +1 -0
  195. package/dist/{compiler/scannable-run.d.ts → table/scan-shapes.d.ts} +14 -13
  196. package/dist/table/scan-shapes.d.ts.map +1 -0
  197. package/dist/table/site-labels.d.ts +58 -0
  198. package/dist/table/site-labels.d.ts.map +1 -0
  199. package/dist/table/stamp.d.ts +70 -0
  200. package/dist/table/stamp.d.ts.map +1 -0
  201. package/dist/types.d.ts +80 -24
  202. package/dist/types.d.ts.map +1 -1
  203. package/dist/version.d.ts +1 -1
  204. package/package.json +41 -3
  205. package/src/analysis/choice-cost-gate.ts +359 -0
  206. package/src/analysis/choice-cost-render.ts +293 -0
  207. package/src/analysis/choice-cost.ts +1008 -0
  208. package/src/analysis/commitment.ts +557 -0
  209. package/src/analysis/corpus.ts +130 -0
  210. package/src/analysis/diagnose-render.ts +625 -0
  211. package/src/analysis/diagnose.ts +317 -0
  212. package/src/analysis/diagnostics.ts +33 -0
  213. package/src/analysis/duplication.ts +1955 -0
  214. package/src/analysis/fix-render.ts +217 -0
  215. package/src/analysis/fix.ts +669 -0
  216. package/src/analysis/format-number.ts +31 -0
  217. package/src/analysis/gating.ts +792 -0
  218. package/src/analysis/grammar.ts +72 -0
  219. package/src/analysis/rebuild.ts +287 -0
  220. package/src/analysis/terminal.ts +336 -0
  221. package/src/cli/index.ts +413 -0
  222. package/src/combinators/adjacency.ts +171 -0
  223. package/src/combinators/attempt.ts +41 -0
  224. package/src/combinators/case-fold.ts +90 -0
  225. package/src/combinators/choice.ts +466 -0
  226. package/src/combinators/completions.ts +64 -0
  227. package/src/combinators/dispatch.ts +358 -0
  228. package/src/combinators/expect.ts +159 -0
  229. package/src/combinators/first-set.ts +322 -0
  230. package/src/combinators/gate.ts +50 -0
  231. package/src/combinators/grammar.ts +272 -0
  232. package/src/combinators/keywords.ts +145 -0
  233. package/src/combinators/literal.ts +102 -0
  234. package/src/combinators/map.ts +106 -0
  235. package/src/combinators/node.ts +345 -0
  236. package/src/combinators/not.ts +55 -0
  237. package/src/combinators/parser.ts +266 -0
  238. package/src/combinators/peek.ts +63 -0
  239. package/src/combinators/probe.ts +25 -0
  240. package/src/combinators/ref.ts +109 -0
  241. package/src/combinators/regex.ts +170 -0
  242. package/src/combinators/repeat.ts +561 -0
  243. package/src/combinators/scanTo.ts +393 -0
  244. package/src/combinators/sequence.ts +221 -0
  245. package/src/combinators/token.ts +120 -0
  246. package/src/combinators/trivia-boundary.ts +48 -0
  247. package/src/combinators/trivia-skip.ts +821 -0
  248. package/src/combinators/withCtx.ts +45 -0
  249. package/src/compiler/build-arity.ts +330 -0
  250. package/src/compiler/compile-linkable-table.ts +209 -0
  251. package/src/compiler/degradation.ts +266 -0
  252. package/src/compiler/fields.ts +191 -0
  253. package/src/compiler/grammar-coverage-ids.ts +129 -0
  254. package/src/compiler/ir-serialize.ts +615 -0
  255. package/src/compiler/linker.ts +954 -0
  256. package/src/compiler/token-alphabet.ts +209 -0
  257. package/src/compiler/token-dispatch.ts +353 -0
  258. package/src/compiler/token-scanner.ts +318 -0
  259. package/src/compiler/value-usage.ts +147 -0
  260. package/src/coverage.ts +383 -0
  261. package/src/cst/capture-buffer.ts +297 -0
  262. package/src/cst/host-mode.ts +69 -0
  263. package/src/cst/reflection-attach.ts +11 -0
  264. package/src/cst/reflection-symbols.ts +9 -0
  265. package/src/cst/reflection.ts +149 -0
  266. package/src/cst/relative-spans.ts +207 -0
  267. package/src/cst/root-trivia-scope.ts +30 -0
  268. package/src/cst/trivia-charscan.ts +312 -0
  269. package/src/cst/trivia-entries.ts +285 -0
  270. package/src/cst/trivia-index.ts +131 -0
  271. package/src/cst/trivia-kinds.ts +358 -0
  272. package/src/cst/types.ts +76 -0
  273. package/src/cst/walk.ts +101 -0
  274. package/src/functional/doc.ts +959 -0
  275. package/src/functional/run-tabled.ts +153 -0
  276. package/src/functional/run.ts +558 -0
  277. package/src/grammar-metadata.ts +2 -0
  278. package/src/index.ts +189 -0
  279. package/src/language-service/index.ts +221 -0
  280. package/src/line-index.ts +131 -0
  281. package/src/oracle/digest.ts +620 -0
  282. package/src/oracle/index.ts +55 -0
  283. package/src/parse-context.ts +91 -0
  284. package/src/plugin/direct-builder-static.ts +102 -0
  285. package/src/plugin/evaluator.ts +1505 -0
  286. package/src/plugin/free-identifiers.ts +338 -0
  287. package/src/plugin/index.ts +2281 -0
  288. package/src/plugin/reducer-resolver.ts +606 -0
  289. package/src/recovery/scan.ts +155 -0
  290. package/src/regex/classes.ts +183 -0
  291. package/src/regex/first-set.ts +316 -0
  292. package/src/run/index.ts +31 -0
  293. package/src/spec/ebnf.ts +128 -0
  294. package/src/spec/index.ts +54 -0
  295. package/src/spec/model.ts +636 -0
  296. package/src/spec/railroad-lib.ts +559 -0
  297. package/src/spec/railroad.ts +256 -0
  298. package/src/table/assemble.ts +2994 -0
  299. package/src/table/cell.ts +42 -0
  300. package/src/table/child-slots.ts +113 -0
  301. package/src/table/compile-rule-map.ts +336 -0
  302. package/src/table/compile.ts +244 -0
  303. package/src/table/duplication-hook.ts +89 -0
  304. package/src/table/emit-assembly.ts +1766 -0
  305. package/src/table/emit.ts +418 -0
  306. package/src/table/encode-baseline.ts +340 -0
  307. package/src/table/encode.ts +1569 -0
  308. package/src/table/exec-baseline.ts +537 -0
  309. package/src/table/exec.ts +1672 -0
  310. package/src/table/fold.ts +38 -0
  311. package/src/table/index.ts +108 -0
  312. package/src/table/inspect.ts +52 -0
  313. package/src/table/ops.ts +459 -0
  314. package/src/table/program.ts +855 -0
  315. package/src/table/run-support.ts +66 -0
  316. package/src/table/scan-shapes.ts +1578 -0
  317. package/src/table/site-labels.ts +228 -0
  318. package/src/table/stamp.ts +229 -0
  319. package/src/types.ts +563 -0
  320. package/src/version.ts +16 -0
  321. package/dist/compiler/codegen.d.ts +0 -334
  322. package/dist/compiler/codegen.d.ts.map +0 -1
  323. package/dist/compiler/inline-build.d.ts +0 -11
  324. package/dist/compiler/inline-build.d.ts.map +0 -1
  325. package/dist/compiler/inline-callback.d.ts +0 -21
  326. package/dist/compiler/inline-callback.d.ts.map +0 -1
  327. package/dist/compiler/line-index.d.ts +0 -2
  328. package/dist/compiler/line-index.d.ts.map +0 -1
  329. package/dist/compiler/scannable-run.d.ts.map +0 -1
  330. package/dist/compiler/scannable-terminal.d.ts +0 -23
  331. package/dist/compiler/scannable-terminal.d.ts.map +0 -1
  332. package/dist/compiler/trivia-fast-path.d.ts +0 -38
  333. package/dist/compiler/trivia-fast-path.d.ts.map +0 -1
@@ -0,0 +1,1578 @@
1
+ /**
2
+ * SHAPE RECOGNITION AND SOURCE EMISSION FOR SCANNABLE REGEXES.
3
+ *
4
+ * Restored from `archive/codegen-fastpaths:src/compiler/scannable-run.ts` and
5
+ * re-aimed at `emit-assembly.ts`. It is a SOURCE emitter, not a runtime
6
+ * interpreter: `emitShapeMatch` constant-folds every range and code point into
7
+ * the text of a per-site `_pf<N>` body via `classCond`/`litCond`/`foldEq`. Built
8
+ * instead as static runtime bodies the nine shapes would reproduce exactly the
9
+ * `inRanges` per-character loop this file exists to remove.
10
+ *
11
+ * The two trivia-loop emitters (`scanBranch`, `scanBranchLabeled`) are NOT
12
+ * restored: fusing the trivia path into piece bodies is a separate unit, and
13
+ * carrying dead emitters here would make this file look like it already did it.
14
+ *
15
+ * Structural recognition of "scannable" parser arms — regex shapes that lower to
16
+ * a tight character-scan loop instead of `regex.exec` / combinator dispatch. Each
17
+ * shape is derived from the regex STRUCTURE, not from any hardcoded knowledge
18
+ * that a given regex "is whitespace" or "is a comment":
19
+ *
20
+ * [X]+ / [X]* → run while the char ∈ X (chars)
21
+ * <lit>[^X]* → consume <lit>, run until char ∈ X (until)
22
+ * <open>(?:…)*<close> → consume <open>, run to <close> literal (delimited)
23
+ * x?<lit><[X]*>… → a general linear chain of lit/run (seq)
24
+ *
25
+ * `seq` is the CATEGORY generalization: any chain of literal segments (required
26
+ * or optional `x?`) and char-class runs (positive/negated, `?`/`*`/`+`). It
27
+ * subsumes CSS/Less `-?ident`, `--custom`, `@-?keyword`, `[^…]+`, `::?`, etc.
28
+ * without hardcoding a single byte — lowered only when a greedy one-pass scan
29
+ * provably equals the engine's backtracking (see `seqIsUnambiguous`).
30
+ *
31
+ * A `oneOrMore(choice(a, b, …))` where every arm is one of these compiles to a
32
+ * single char-dispatch loop with one branch per arm — any count/order, because
33
+ * the shapes dispatch on their first 1–2 chars and are checked in turn. Trivia
34
+ * (whitespace + comments) is just the value-discarded instance of this; nothing
35
+ * here is trivia-specific.
36
+ */
37
+
38
+ /**
39
+ * One segment of a `seq` shape. A `seq` is the general category "a fixed linear
40
+ * chain of literals and char-class runs" — no alternation, no groups, no
41
+ * backtracking. It subsumes every CSS/Less token that is "(optional prefix)?
42
+ * literal* char-run* …" without hardcoding any particular byte:
43
+ *
44
+ * lit — a run of fixed code points, optionally present (`x?`) or required.
45
+ * run — a char-class run: positive/negated ranges, min 0/1, bounded to one
46
+ * char (`[x]`, `[x]?`) or unbounded (`[x]*`, `[x]+`).
47
+ * group — a non-capturing sub-pattern `(?:…)` (§8f), quantified the same way
48
+ * as `run` (`min`/`unbounded`). `inner` is any already-recognized
49
+ * `ScanShape` for the group's body (so a group can itself contain
50
+ * `seq`/`chars`/`alt`/… — including a nested alternation via §8e).
51
+ * Only used when `groupInnerSafe(inner)` holds (see that function).
52
+ *
53
+ * We only lower a `seq` when greedy left-to-right scanning provably equals the
54
+ * regex engine's backtracking match (see `seqIsUnambiguous`).
55
+ */
56
+ export type SeqPart =
57
+ | { part: 'lit'; cps: number[]; optional: boolean }
58
+ // A char-class run with a general counted quantifier: `min`..`max` occurrences.
59
+ // `max === Infinity` is unbounded (`+`/`*`/`{n,}`). The four legacy shapes map
60
+ // as `+`→{1,∞} `*`→{0,∞} `?`→{0,1} bare→{1,1}; `{n,m}` (§8c) fills the rest.
61
+ // Greedy scanning has "wiggle" a backtracker could exploit iff `max > min`.
62
+ | { part: 'run'; ranges: Array<[number, number]>; negated: boolean; min: number; max: number }
63
+ | { part: 'group'; inner: ScanShape; min: 0 | 1; unbounded: boolean }
64
+
65
+ export type ScanShape =
66
+ | { kind: 'chars'; ranges: Array<[number, number]>; minOne: boolean }
67
+ | { kind: 'ident'; head: Array<[number, number]>; tail: Array<[number, number]> }
68
+ | { kind: 'until'; open: number[]; stop: Array<[number, number]> }
69
+ | { kind: 'delimited'; open: number[]; close: number[] }
70
+ // <q>(?:[^<q>\\]|\\.)*<q> — a quote-delimited string with backslash escapes.
71
+ // `excluded` = the body's negated class (always contains the quote + backslash,
72
+ // maybe a newline); `escLineTerm` = whether `\\` may be followed by a line
73
+ // terminator (`\\[\s\S]` → true, `\\.` → false, since `.` excludes them).
74
+ | { kind: 'string'; quote: number; excluded: Array<[number, number]>; escLineTerm: boolean }
75
+ // A linear chain of literal/char-run segments (optional prefixes, literal
76
+ // openers, negated runs, …) — the general CSS/Less token category.
77
+ | { kind: 'seq'; parts: SeqPart[] }
78
+ // A pure literal matched case-insensitively (`/i`): the general "ASCII
79
+ // case-fold literal" category (CSS `url(`, `@media` keywords under `i`, …).
80
+ | { kind: 'litFold'; open: number[] }
81
+ // `<inner>(?!class)` / `<inner>(?=class)` — a trailing lookahead boundary
82
+ // check on an already-lowered shape. Zero-width: `end` is `inner`'s end, never
83
+ // extended. `classNegated` is the bracket's OWN `[^…]` negation (e.g.
84
+ // `(?![0-9a-fA-F])` vs `(?=[^0-9])`), independent of `negative` (`!` vs `=`).
85
+ | { kind: 'lookahead'; inner: ScanShape; ranges: Array<[number, number]>; classNegated: boolean; negative: boolean }
86
+ // `A|B|C` — top-level alternation (§8e), split outside any `[]`/`()`, each arm
87
+ // independently lowered. `disjoint` is true iff every pair of arms has
88
+ // provably non-overlapping first-char sets (`firsts[i]` holds that arm's set,
89
+ // or `null` if it could start with any char — an unbounded-optional `chars`
90
+ // arm, or a non-disjoint mix). When `disjoint`, codegen dispatches straight to
91
+ // the one matching arm; otherwise it tries arms in order and takes the first
92
+ // that succeeds — the same ordered-choice semantics as regex `|` itself (see
93
+ // the ordered-vs-longest verification in PERF_IDEAS §8e).
94
+ | { kind: 'alt'; arms: ScanShape[]; disjoint: boolean; firsts: Array<CharSet | null> }
95
+
96
+ /** Backslash (`\`) code point — the escape lead char in string shapes. */
97
+ export const BACKSLASH = 92
98
+
99
+ const META = new Set('()[]{}*+?|^$.'.split(''))
100
+
101
+ // Regex char-class primitives are shared with the interpreter's first-set
102
+ // analyzer and `regex()`'s scanner — see `../regex/classes.ts`. `SPACE_RANGES`
103
+ // and `parseClassRanges` are re-exported here because codegen's boundary-class
104
+ // lowering imports them from this module.
105
+ import { CLASS_ESCAPES, SPACE_RANGES, literalCodePoints, parseClassOperand, shorthandRanges, readUnicodeEscape, parseClassRanges } from '../regex/classes.ts'
106
+ export { SPACE_RANGES, parseClassRanges }
107
+
108
+ /** A single class token (`[...]` or `\d`/`\w`) to ranges, or null. Rejects negation. */
109
+ function classToRanges(cls: string): Array<[number, number]> | null {
110
+ if (cls === '\\d') return shorthandRanges('d')
111
+ if (cls === '\\w') return shorthandRanges('w')
112
+ if (cls === '\\s') return shorthandRanges('s')
113
+ const body = cls.slice(1, -1)
114
+ if (body.startsWith('^')) return null
115
+ return parseClassRanges(body)
116
+ }
117
+
118
+ /** ASCII case-insensitive letter check for `i`-flag literal lowering. */
119
+ // ASCII case-insensitive char compare. For a letter, upper/lower differ only in
120
+ // bit 0x20, and `(c | 32) === <lowercase>` is satisfied by EXACTLY that letter's
121
+ // two cases (nothing else) — one OR + one compare, ~1.75× faster than the
122
+ // two-compare `(c===U || c===L)` form (measured). `(… | 32)` is parenthesized
123
+ // because `===` binds tighter than `|`. Non-letters keep an exact compare.
124
+ // Exported so the case-insensitive `literal()` emitter (codegen) shares the exact
125
+ // same ASCII fold as `/i`-flag regex lowering — no `Intl.Collator` on any path.
126
+ export const foldEq = (cVar: string, cp: number): string => {
127
+ if (cp >= 65 && cp <= 90) return `(${cVar} | 32) === ${cp + 32}`
128
+ if (cp >= 97 && cp <= 122) return `(${cVar} | 32) === ${cp}`
129
+ return `${cVar} === ${cp}`
130
+ }
131
+
132
+ /**
133
+ * Recognize a quote-delimited string with backslash escapes:
134
+ * <q>(?:[^…]|\\.)*<q> or <q>(?:\\.|[^…])*<q> (`?:` optional)
135
+ * where the negated body class contains the quote and the backslash, and the
136
+ * escape body is `.` (no line terminators) or `[\s\S]` (any char). Returns null
137
+ * for anything else so the caller falls back to `regex.exec`.
138
+ */
139
+ function parseStringShape(source: string): ScanShape | null {
140
+ const q = source[0]
141
+ if (q === undefined || q === '\\' || META.has(q)) return null
142
+ if (source.length < 3 || source[source.length - 1] !== q) return null
143
+ const inner = source.slice(1, -1)
144
+ const CLS = String.raw`\[\^((?:\\.|[^\]])+)\]`
145
+ const ESC = String.raw`\\\\(\.|\[\\s\\S\]|\[\\S\\s\])`
146
+ // Two arm orders; `?:` optional. Capture groups differ, so read both out.
147
+ let body: string | undefined
148
+ let esc: string | undefined
149
+ let m = new RegExp(`^\\((?:\\?:)?${CLS}\\|${ESC}\\)\\*$`).exec(inner)
150
+ if (m) { body = m[1]; esc = m[2] }
151
+ else {
152
+ m = new RegExp(`^\\((?:\\?:)?${ESC}\\|${CLS}\\)\\*$`).exec(inner)
153
+ if (m) { esc = m[1]; body = m[2] }
154
+ }
155
+ if (body === undefined || esc === undefined) return null
156
+ const excluded = parseClassRanges(body)
157
+ if (!excluded) return null
158
+ const qcp = q.codePointAt(0)!
159
+ const inSet = (cp: number) => excluded.some(([lo, hi]) => cp >= lo && cp <= hi)
160
+ if (!inSet(qcp) || !inSet(BACKSLASH)) return null
161
+ return { kind: 'string', quote: qcp, excluded, escLineTerm: esc !== '.' }
162
+ }
163
+
164
+ /**
165
+ * Parse a regex source into a linear chain of literal / char-run segments, or
166
+ * null if it uses any construct outside that category (alternation, groups,
167
+ * `.`, `{n,m}`, lookaround, unknown escapes). This is the STRUCTURAL recognizer
168
+ * for the `seq` shape — it encodes categories (optional prefix, literal opener,
169
+ * negated run, …), never any specific byte value.
170
+ */
171
+ function parseSeqParts(source: string): SeqPart[] | null {
172
+ const parts: SeqPart[] = []
173
+ let lit: number[] = []
174
+ const flush = () => {
175
+ if (lit.length) { parts.push({ part: 'lit', cps: lit, optional: false }); lit = [] }
176
+ }
177
+ // Parse a `{n}` / `{n,}` / `{n,m}` counted quantifier at `source[at]` (`'{'`).
178
+ // Returns the bounds and the token length, or null when it isn't a well-formed
179
+ // brace quantifier (then the `{` is left for the caller's metachar decline).
180
+ const braceQuant = (at: number): { min: number; max: number; len: number } | null => {
181
+ const close = source.indexOf('}', at)
182
+ if (close === -1) return null
183
+ const body = source.slice(at + 1, close)
184
+ const m = /^(\d+)(,(\d*)?)?$/.exec(body)
185
+ if (!m) return null
186
+ const min = Number(m[1])
187
+ const max = m[2] === undefined ? min : m[3] ? Number(m[3]!) : Infinity
188
+ // Decline degenerate/empty-capable or invalid bounds — RegExp handles them.
189
+ if (max < 1 || min > max) return null
190
+ return { min, max, len: close - at + 1 }
191
+ }
192
+ const runFrom = (ranges: Array<[number, number]>, negated: boolean, at: number) => {
193
+ const q = source[at]
194
+ if (q === '+') { parts.push({ part: 'run', ranges, negated, min: 1, max: Infinity }); return 1 }
195
+ if (q === '*') { parts.push({ part: 'run', ranges, negated, min: 0, max: Infinity }); return 1 }
196
+ if (q === '?') { parts.push({ part: 'run', ranges, negated, min: 0, max: 1 }); return 1 }
197
+ if (q === '{') {
198
+ const b = braceQuant(at)
199
+ if (b) { parts.push({ part: 'run', ranges, negated, min: b.min, max: b.max }); return b.len }
200
+ }
201
+ parts.push({ part: 'run', ranges, negated, min: 1, max: 1 })
202
+ return 0
203
+ }
204
+ let i = 0
205
+ while (i < source.length) {
206
+ const ch = source[i]!
207
+ if (ch === '[') {
208
+ // Read a char class, honoring `\]`.
209
+ let k = i + 1
210
+ const neg = source[k] === '^'
211
+ if (neg) k++
212
+ let body = ''
213
+ while (k < source.length && source[k] !== ']') {
214
+ if (source[k] === '\\') { body += source[k]! + (source[k + 1] ?? ''); k += 2 }
215
+ else { body += source[k]!; k++ }
216
+ }
217
+ if (source[k] !== ']') return null
218
+ const ranges = parseClassRanges(body)
219
+ if (!ranges) return null
220
+ flush()
221
+ i = k + 1
222
+ i += runFrom(ranges, neg, i)
223
+ continue
224
+ }
225
+ if (ch === '\\') {
226
+ const e = source[i + 1]
227
+ if (e === 'd' || e === 'w' || e === 's') {
228
+ flush()
229
+ i += 2
230
+ i += runFrom(shorthandRanges(e), false, i)
231
+ continue
232
+ }
233
+ // Escaped literal code point (incl. `\uXXXX`, `\t`, `\(`, …).
234
+ const uni = readUnicodeEscape(source, i)
235
+ let cp: number
236
+ if (uni) { cp = uni.cp; i = uni.next }
237
+ else if (e === undefined) return null
238
+ else if (e in CLASS_ESCAPES) { cp = CLASS_ESCAPES[e]!; i += 2 }
239
+ else if ((e >= 'a' && e <= 'z') || (e >= 'A' && e <= 'Z')) return null
240
+ else { cp = e.codePointAt(0)!; i += 2 }
241
+ if (source[i] === '?') { flush(); parts.push({ part: 'lit', cps: [cp], optional: true }); i++ }
242
+ else lit.push(cp)
243
+ continue
244
+ }
245
+ if (ch === '(') {
246
+ // Only a non-capturing group `(?:…)` is modeled (§8f) — a bare capturing
247
+ // group, or a lookaround appearing mid-pattern (a TRAILING one is peeled
248
+ // off separately by `stripTrailingLookahead` before we ever get here), is
249
+ // declined outright rather than risk misreading it.
250
+ if (source[i + 1] !== '?' || source[i + 2] !== ':') return null
251
+ let k = i + 3
252
+ let depth = 1
253
+ while (k < source.length && depth > 0) {
254
+ const c2 = source[k]
255
+ if (c2 === '\\') { k += 2; continue }
256
+ if (c2 === '[') {
257
+ let j = k + 1
258
+ if (source[j] === '^') j++
259
+ while (j < source.length && source[j] !== ']') {
260
+ if (source[j] === '\\') j += 2
261
+ else j++
262
+ }
263
+ if (source[j] !== ']') return null
264
+ k = j + 1
265
+ continue
266
+ }
267
+ if (c2 === '(') depth++
268
+ else if (c2 === ')') depth--
269
+ k++
270
+ }
271
+ if (depth !== 0) return null
272
+ const body = source.slice(i + 3, k - 1)
273
+ const inner = parseScanShape(body)
274
+ if (!inner || !groupInnerSafe(inner)) return null
275
+ const q = source[k]
276
+ // `{n,m}` on a group is a §8c/§8f combination not modeled — decline.
277
+ if (q === '{') return null
278
+ flush()
279
+ if (q === '+') {
280
+ if (shapeCanBeEmpty(inner)) return null // would loop forever — decline
281
+ parts.push({ part: 'group', inner, min: 1, unbounded: true })
282
+ i = k + 1
283
+ } else if (q === '*') {
284
+ if (shapeCanBeEmpty(inner)) return null
285
+ parts.push({ part: 'group', inner, min: 0, unbounded: true })
286
+ i = k + 1
287
+ } else if (q === '?') {
288
+ parts.push({ part: 'group', inner, min: 0, unbounded: false })
289
+ i = k + 1
290
+ } else {
291
+ parts.push({ part: 'group', inner, min: 1, unbounded: false })
292
+ i = k
293
+ }
294
+ continue
295
+ }
296
+ // A bare metachar (`|`, `.`, `{`, stray `*`/`+`/`?`, …) is outside the
297
+ // linear-chain category — bail to the RegExp fallback.
298
+ if (META.has(ch)) return null
299
+ const cp = ch.codePointAt(0)!
300
+ i += ch.length
301
+ if (source[i] === '?') { flush(); parts.push({ part: 'lit', cps: [cp], optional: true }); i++ }
302
+ else lit.push(cp)
303
+ }
304
+ flush()
305
+ return parts.length ? parts : null
306
+ }
307
+
308
+ /**
309
+ * Only lower a `seq` when greedy left-to-right scanning matches EXACTLY what the
310
+ * backtracking regex engine would. The dangerous cases are (a) an optional part
311
+ * that could also be consumed by what follows, and (b) an unbounded/repeated
312
+ * part whose accepted class overlaps what follows — both let the engine
313
+ * backtrack to a different boundary than a one-pass scan. We also require at
314
+ * least one mandatory segment (so the token can't match zero-width).
315
+ *
316
+ * A skippable/repeatable part `p` at index `k` is checked against
317
+ * `seqFirstAccept(parts.slice(k + 1))` — the accept-set of EVERYTHING that
318
+ * could start at the very next position, unioned through any further chain of
319
+ * optionals up to the next mandatory part (the exact same computation
320
+ * `seqFirstAccept` already does for a seq's OWN leading accept-set). This
321
+ * subsumes the simpler "check only the immediate neighbor" rule as the
322
+ * one-part-following case, while also correctly handling a CHAIN of several
323
+ * consecutive optional parts (e.g. two independent optional groups in a row,
324
+ * `(?:\.\d+)?(?:[eE][+-]?\d+)?` — JSON/GraphQL's number tail, §8f): each is
325
+ * safe precisely when disjoint from everything that could follow it, however
326
+ * many further optionals that spans. An empty tail (`p` is the last part)
327
+ * naturally resolves to a trivially-disjoint empty set — nothing to check.
328
+ *
329
+ * Uniform across `lit`/`run`/`group`: each part's own "first char it could
330
+ * start with" (`partFirstAccept`) is compared via `firstSetsDisjoint` — the
331
+ * same subset/disjoint math used everywhere else in this file (§8b's
332
+ * lookahead guard, §8e's alt-dispatch guard).
333
+ */
334
+ function seqIsUnambiguous(parts: SeqPart[]): boolean {
335
+ let hasMandatory = false
336
+ for (let k = 0; k < parts.length; k++) {
337
+ const p = parts[k]!
338
+ const mandatory = p.part === 'lit' ? !p.optional : p.min >= 1
339
+ if (mandatory) hasMandatory = true
340
+ // A `group` part has a right-edge exposure (`groupPartExposure`): the chars a
341
+ // backtracking engine could give back by shrinking the body's own trailing
342
+ // quantifier, dropping the group (if optional/repeated), or — for a
343
+ // non-disjoint alt body — switching arms. Ordered-choice greedy scanning
344
+ // matches the engine only when NONE of those relinquished chars could also
345
+ // start what follows the group (else the scan and the engine disagree on
346
+ // where the group ends). Two outcomes:
347
+ // - a concrete class → must be disjoint from `seqFirstAccept(rest)`. This
348
+ // is the general fix for a group whose body ends in an unbounded run
349
+ // (`(?:\d+)\d` must NOT lower), and it lets a mutually-exclusive fixed
350
+ // alt (§8i, exposure `null`) lower at ANY position, not just trailing.
351
+ // - `'unsupported'` → the exposure can't be bounded (e.g. a non-disjoint
352
+ // alt like `(?:a|ab)` that genuinely needs arm-switching). Sound only
353
+ // when nothing follows to force reconsideration — the group must be the
354
+ // trailing, matched-exactly-once part (§8h); otherwise decline.
355
+ if (p.part === 'group') {
356
+ const exposure = groupPartExposure(p)
357
+ if (exposure === 'unsupported') {
358
+ const trailingOnce = k === parts.length - 1 && p.min === 1 && !p.unbounded
359
+ if (!trailingOnce) return false
360
+ } else if (exposure !== null) {
361
+ if (!firstSetsDisjoint(exposure, seqFirstAccept(parts.slice(k + 1)))) return false
362
+ }
363
+ }
364
+ // A part needs the disjoint-from-continuation check when a greedy one-pass
365
+ // scan has freedom a backtracking engine could resolve differently: an
366
+ // optional `lit`, a `run` with wiggle (`max > min` — covers `?`/`*`/`+` and
367
+ // bounded `{n,m}` with `m > n`; a fixed `{n}` run has none), or an
368
+ // optional/repeated `group`. A fixed-length run (`max === min`) consumes an
369
+ // exact count with no boundary to shift, so it never needs the check.
370
+ const needsDisjoint =
371
+ p.part === 'lit' ? p.optional
372
+ : p.part === 'run' ? p.max > p.min
373
+ : (p.unbounded || p.min === 0)
374
+ if (needsDisjoint) {
375
+ const rest = seqFirstAccept(parts.slice(k + 1))
376
+ if (!firstSetsDisjoint(partFirstAccept(p), rest)) return false
377
+ }
378
+ }
379
+ return hasMandatory
380
+ }
381
+
382
+ function rangesOverlap(a: Array<[number, number]>, b: Array<[number, number]>): boolean {
383
+ return a.some(([lo, hi]) => b.some(([lo2, hi2]) => lo <= hi2 && lo2 <= hi))
384
+ }
385
+
386
+ /** Is every range in `sub` fully contained in some range of `sup`? */
387
+ function rangesSubset(sub: Array<[number, number]>, sup: Array<[number, number]>): boolean {
388
+ return sub.every(([lo, hi]) => sup.some(([lo2, hi2]) => lo >= lo2 && hi <= hi2))
389
+ }
390
+
391
+ type TrailingLookahead = {
392
+ base: string
393
+ ranges: Array<[number, number]>
394
+ classNegated: boolean
395
+ negative: boolean
396
+ }
397
+
398
+ type CharSet = { ranges: Array<[number, number]>; negated: boolean }
399
+
400
+ /** Is char-set `t` fully contained in char-set `u`? Handles all 4 sign combos. */
401
+ function classSubset(t: CharSet, u: CharSet): boolean {
402
+ if (!t.negated && !u.negated) return rangesSubset(t.ranges, u.ranges)
403
+ if (!t.negated && u.negated) return !rangesOverlap(t.ranges, u.ranges) // t ⊆ ¬u ⟺ t ∩ u = ∅
404
+ if (t.negated && !u.negated) return false // ¬t ⊆ u is never true for finite ranges u
405
+ return rangesSubset(u.ranges, t.ranges) // ¬t ⊆ ¬u ⟺ u ⊆ t
406
+ }
407
+
408
+ /** Are char-sets `t` and `u` disjoint (no overlap)? Handles all 4 sign combos. */
409
+ function classDisjoint(t: CharSet, u: CharSet): boolean {
410
+ if (!t.negated && !u.negated) return !rangesOverlap(t.ranges, u.ranges)
411
+ if (!t.negated && u.negated) return rangesSubset(t.ranges, u.ranges) // t ∩ ¬u = ∅ ⟺ t ⊆ u
412
+ if (t.negated && !u.negated) return rangesSubset(u.ranges, t.ranges) // symmetric
413
+ return false // ¬t ∩ ¬u = ∅ would require t ∪ u = every code point — can't prove with finite ranges
414
+ }
415
+
416
+ /** `classDisjoint`, `'any'`-aware: an empty-capable part's first-set degrades
417
+ * to `'any'` (see `shapeFirstAccept`), and `'any'` is never disjoint from
418
+ * anything (it could start with literally any char, including whatever the
419
+ * other side accepts). */
420
+ function firstSetsDisjoint(a: CharSet | 'any', b: CharSet | 'any'): boolean {
421
+ if (a === 'any' || b === 'any') return false
422
+ return classDisjoint(a, b)
423
+ }
424
+
425
+ /**
426
+ * A shape's match as a fixed-length list of per-position char-sets, or `null`
427
+ * if its match length is NOT fixed. Only a `seq` of mandatory `lit`s (each code
428
+ * point is one fixed position) and mandatory single-char runs (`[x]`, min 1,
429
+ * bounded) qualifies — anything with an optional part, an unbounded/optional
430
+ * run, or a nested group has a variable length and returns `null`. Used to
431
+ * decide when a non-disjoint alternation's arms are mutually exclusive (§8i).
432
+ */
433
+ function fixedClassSeq(shape: ScanShape): CharSet[] | null {
434
+ // A case-fold literal is fixed-length: each code point becomes a single-char
435
+ // class holding BOTH ASCII cases (so mutual-exclusivity is judged on what the
436
+ // `/i` match actually accepts — `and` vs `or` are disjoint at position 0).
437
+ if (shape.kind === 'litFold') {
438
+ return shape.open.map(cp => {
439
+ const other = cp >= 65 && cp <= 90 ? cp + 32 : cp >= 97 && cp <= 122 ? cp - 32 : cp
440
+ return other === cp
441
+ ? { ranges: [[cp, cp]] as Array<[number, number]>, negated: false }
442
+ : { ranges: [[Math.min(cp, other), Math.min(cp, other)], [Math.max(cp, other), Math.max(cp, other)]] as Array<[number, number]>, negated: false }
443
+ })
444
+ }
445
+ if (shape.kind !== 'seq') return null
446
+ const out: CharSet[] = []
447
+ for (const p of shape.parts) {
448
+ if (p.part === 'lit') {
449
+ if (p.optional) return null
450
+ for (const cp of p.cps) out.push({ ranges: [[cp, cp]], negated: false })
451
+ } else if (p.part === 'run' && p.max === p.min && Number.isFinite(p.max)) {
452
+ // A fixed-count run `[x]{n}` (incl. the bare `[x]`, n=1) is n fixed
453
+ // positions of the same class. Variable-length runs return null below.
454
+ for (let c = 0; c < p.max; c++) out.push({ ranges: p.ranges, negated: p.negated })
455
+ } else {
456
+ return null // optional/variable/unbounded run, or a nested group — length not fixed
457
+ }
458
+ }
459
+ return out.length ? out : null
460
+ }
461
+
462
+ /**
463
+ * §8i — is a NON-disjoint `alt` shape one whose arms are pairwise MUTUALLY
464
+ * EXCLUSIVE fixed-length token sets? Every arm must be a `fixedClassSeq`, and no
465
+ * arm's match may ever be a prefix (proper, or an equal-length overlap) of
466
+ * another arm's — i.e. for every ordered pair (A, B) with `A.length <= B.length`
467
+ * there is a position within A where their classes are DISJOINT. That property
468
+ * guarantees at most one arm matches at any input position, so ordered-choice
469
+ * commit lands on the SAME arm (and the SAME fixed end) the engine would, with
470
+ * no arm to switch to under backtracking — sound in ANY position, not just
471
+ * trailing (unlike the general non-disjoint alt, which needs the trailing-once
472
+ * gate in `seqIsUnambiguous`). `(?:ab|ac)` and `(?:foo|barn)` qualify;
473
+ * `(?:a|ab)` (arm `a` is a prefix of `ab`) does NOT.
474
+ */
475
+ function altFixedMutuallyExclusive(shape: ScanShape): boolean {
476
+ if (shape.kind !== 'alt' || shape.disjoint) return false
477
+ const lists: CharSet[][] = []
478
+ for (const arm of shape.arms) {
479
+ const f = fixedClassSeq(arm)
480
+ if (!f) return false
481
+ lists.push(f)
482
+ }
483
+ for (let i = 0; i < lists.length; i++) {
484
+ for (let j = 0; j < lists.length; j++) {
485
+ if (i === j) continue
486
+ const a = lists[i]!
487
+ const b = lists[j]!
488
+ if (a.length > b.length) continue // only check the shorter (a) as a prefix of the longer (b)
489
+ let disjointAt = false
490
+ for (let p = 0; p < a.length; p++) {
491
+ if (classDisjoint(a[p]!, b[p]!)) { disjointAt = true; break }
492
+ }
493
+ if (!disjointAt) return false // `a` could be a prefix of `b` — not mutually exclusive
494
+ }
495
+ }
496
+ return true
497
+ }
498
+
499
+ /**
500
+ * The char-set a backtracking engine could relinquish at a `group` PART's right
501
+ * edge — its "trailing exposure". Combines two independent backtrack moves: the
502
+ * group body's OWN trailing wiggle (`trailingBacktrackClass(inner)`, e.g. the
503
+ * body ends in an unbounded run), and — if the group is optional (`min 0`) or
504
+ * repeated (`unbounded`) — the "drop the last occurrence" move, which exposes
505
+ * the body's own first-char set. A required-once group (`min 1`, bounded) has
506
+ * only the first move. Returns `'unsupported'` if either component can't be
507
+ * pinned to a concrete set. This is the single computation used everywhere a
508
+ * group's right boundary matters: `trailingBacktrackClass` (a `seq` ending in a
509
+ * group) and `seqIsUnambiguous` (a group at ANY position, followed by more).
510
+ */
511
+ function groupPartExposure(part: Extract<SeqPart, { part: 'group' }>): CharSet | null | 'unsupported' {
512
+ const innerWiggle = trailingBacktrackClass(part.inner)
513
+ if (innerWiggle === 'unsupported') return 'unsupported'
514
+ const requiredOnce = !part.unbounded && part.min === 1
515
+ if (requiredOnce) return innerWiggle // no "drop it" move possible — only inner's own wiggle matters
516
+ const dropExposed = shapeFirstAccept(part.inner)
517
+ if (dropExposed === 'any') return 'unsupported'
518
+ if (innerWiggle === null) return dropExposed
519
+ const unioned = unionCharSets([innerWiggle, dropExposed])
520
+ return unioned === 'any' ? 'unsupported' : unioned
521
+ }
522
+
523
+ /**
524
+ * The char-class a backtracking engine could expose by shrinking `shape`'s
525
+ * OWN trailing quantifier by one position — i.e. the "wiggle room" a trailing
526
+ * lookahead could interact with. Returns `null` when `shape`'s matched length
527
+ * is fully fixed once it succeeds (no quantifier to shrink), which makes
528
+ * wrapping it in a lookahead unconditionally safe. Returns `'unsupported'` for
529
+ * shape kinds whose backtracking semantics aren't modeled here (declines the
530
+ * lookahead lowering rather than risking an unproven guard).
531
+ *
532
+ * Only the LAST quantifier matters: `seqIsUnambiguous` already proves every
533
+ * EARLIER part of a `seq` has exactly one valid parse (no alternate length),
534
+ * so the sole remaining freedom a backtracking engine has — once we attach an
535
+ * external trailing lookahead — is in whatever quantifier sits at the very end.
536
+ */
537
+ function trailingBacktrackClass(shape: ScanShape): CharSet | null | 'unsupported' {
538
+ if (shape.kind === 'chars') return { ranges: shape.ranges, negated: false }
539
+ if (shape.kind === 'ident') return { ranges: shape.tail, negated: false }
540
+ if (shape.kind === 'litFold') return null // fixed-length literal — no quantifier at all
541
+ if (shape.kind === 'seq') {
542
+ const last = shape.parts[shape.parts.length - 1]!
543
+ if (last.part === 'lit') {
544
+ // A REQUIRED literal tail is fixed — no quantifier, no wiggle.
545
+ //
546
+ // An OPTIONAL one is the opposite, and reading it as fixed was a defect.
547
+ // `::?(?![ \t\n\r\f])` on `":: "` matches ONE colon: the engine takes both,
548
+ // the lookahead rejects the space, it gives the second colon back, and the
549
+ // lookahead then sees `:` and passes. A greedy scan that resolves `::`
550
+ // once and never reconsiders reports NO MATCH — which is what this
551
+ // returned `null` for, so `lookaheadUnambiguous` waved it through and the
552
+ // shape lowered wrongly. Found by the oracle on jess's css grammar the
553
+ // first time that grammar emitted at all.
554
+ //
555
+ // Declined rather than modelled. The exposed class IS computable — drop
556
+ // the literal and the char at the new end is `cps[0]` — but a chain of
557
+ // optional parts (`a?b?`) exposes more than one level, and this analysis
558
+ // is single-level by construction. Wrong here is a silently wrong parse;
559
+ // declining costs one regex row.
560
+ return last.optional ? 'unsupported' : null
561
+ }
562
+ if (last.part === 'run') {
563
+ // Wiggle iff `max > min`: unbounded (`+`/`*`/`{n,}`), optional (`[x]?`),
564
+ // or bounded `{n,m}` with `m > n` all let a backtracker shrink the trailing
565
+ // run by ≥1 position. A fixed run (`[x]`, `{n}`, `max === min`) has no
566
+ // choice — no wiggle, hence no risk.
567
+ if (last.max > last.min) return { ranges: last.ranges, negated: last.negated }
568
+ return null
569
+ }
570
+ // A trailing `group` (§8f): its trailing exposure combines its body's own
571
+ // wiggle and (if optional/repeated) the "drop it" move — see
572
+ // `groupPartExposure`, shared with `seqIsUnambiguous`.
573
+ return groupPartExposure(last)
574
+ }
575
+ if (shape.kind === 'alt') {
576
+ // Arm-switching hazard (see `groupInnerSafe`) applies here too: a
577
+ // non-disjoint alt can't be trusted to expose the right wiggle class,
578
+ // since which arm ultimately "wins" could itself change under backtrack.
579
+ // A disjoint alt has no such hazard — exactly one arm is ever reachable
580
+ // from a given starting char, so the union of every arm's OWN wiggle
581
+ // covers whichever one is actually taken. A non-disjoint alt whose arms are
582
+ // fixed-length and mutually exclusive (§8i) also has no wiggle — exactly one
583
+ // arm matches at any position and its length is fixed, so `null`.
584
+ if (!shape.disjoint) return altFixedMutuallyExclusive(shape) ? null : 'unsupported'
585
+ const classes = shape.arms.map(trailingBacktrackClass)
586
+ if (classes.some(c => c === 'unsupported')) return 'unsupported'
587
+ const concrete = classes.filter((c): c is CharSet => c !== null)
588
+ if (concrete.length === 0) return null
589
+ const unioned = unionCharSets(concrete)
590
+ return unioned === 'any' ? 'unsupported' : unioned
591
+ }
592
+ // `until`/`delimited`/`string`/nested `lookahead` — not modeled; decline.
593
+ return 'unsupported'
594
+ }
595
+
596
+ /**
597
+ * Is it safe to lower `inner(?!ranges)` / `inner(?=ranges)` to a one-shot
598
+ * post-match check (vs. requiring real backtracking)? See PERF_IDEAS §8b.
599
+ */
600
+ function lookaheadUnambiguous(inner: ScanShape, operand: CharSet, negative: boolean): boolean {
601
+ const tail = trailingBacktrackClass(inner)
602
+ if (tail === 'unsupported') return false
603
+ if (tail === null) return true // inner's length is fixed — no backtracking possible
604
+ // Negative `(?!op)`: safe iff every char the tail could expose is ALSO
605
+ // excluded by `op` (so shrinking never turns a failure into a pass).
606
+ // Positive `(?=op)`: safe iff the tail is disjoint from `op` (so shrinking
607
+ // could never expose a char that suddenly satisfies `op`).
608
+ return negative ? classSubset(tail, operand) : classDisjoint(tail, operand)
609
+ }
610
+
611
+ /**
612
+ * Strip a trailing `(?!class)` / `(?=class)` boundary assertion, if present.
613
+ * Only a TRAILING lookahead with a char-class/single-char operand is
614
+ * recognized — no sub-patterns, no nested groups. Anything else containing an
615
+ * unescaped `(` is left to the other recognizers, which reject it as a
616
+ * metachar (`META`) and fall back to `RegExp.exec`, so a false read here can
617
+ * only fail to lower, never mis-lower.
618
+ */
619
+ function stripTrailingLookahead(source: string): TrailingLookahead | null {
620
+ if (!source.endsWith(')')) return null
621
+ for (const negative of [true, false] as const) {
622
+ const lead = negative ? '(?!' : '(?='
623
+ const idx = source.lastIndexOf(lead)
624
+ if (idx === -1) continue
625
+ // The operand must be the ENTIRE lookahead body. `parseClassOperand` enforces
626
+ // that a bracketed body is ONE class token, so a sequence like
627
+ // `[ \t\n\r\f]*[\$(]` is declined here rather than read as one garbage class.
628
+ const operand = parseClassOperand(source.slice(idx + lead.length, -1))
629
+ if (!operand) continue
630
+ return { base: source.slice(0, idx), ranges: operand.ranges, classNegated: operand.negated, negative }
631
+ }
632
+ return null
633
+ }
634
+
635
+ /**
636
+ * Strip ONE redundant outer `(?:…)` wrapper, if it spans the ENTIRE source
637
+ * (nothing before it, nothing — not even a quantifier — after its matching
638
+ * close). A non-capturing group with no trailing quantifier is semantically
639
+ * identical to its bare contents, so this is always safe to unwrap; it's what
640
+ * lets `(?:a|b|c)` (a whole-token alternation written as idiomatic regex
641
+ * style, e.g. CSS `basicSel`) reach the top-level `|` split below. Bails
642
+ * (leaves `source` untouched) on anything that isn't a clean whole-string
643
+ * wrap: unbalanced parens, or a group that closes before the string ends
644
+ * (which would mean a quantifier or trailing content follows it).
645
+ */
646
+ function unwrapOuterGroup(source: string): string {
647
+ while (source.startsWith('(?:')) {
648
+ let depth = 1
649
+ let i = 3
650
+ let ok = true
651
+ while (i < source.length && depth > 0) {
652
+ const ch = source[i]
653
+ if (ch === '\\') { i += 2; continue }
654
+ if (ch === '[') {
655
+ let j = i + 1
656
+ if (source[j] === '^') j++
657
+ while (j < source.length && source[j] !== ']') {
658
+ if (source[j] === '\\') j += 2
659
+ else j++
660
+ }
661
+ if (source[j] !== ']') { ok = false; break }
662
+ i = j + 1
663
+ continue
664
+ }
665
+ if (ch === '(') depth++
666
+ else if (ch === ')') depth--
667
+ i++
668
+ }
669
+ if (!ok || depth !== 0 || i !== source.length) break
670
+ source = source.slice(3, -1)
671
+ }
672
+ return source
673
+ }
674
+
675
+ /**
676
+ * Split a regex source on top-level `|` — i.e. `|` outside any `[]` bracket
677
+ * class and outside any `(...)` group (parens still count toward depth even
678
+ * though we don't otherwise understand groups; a `|` nested in one is never a
679
+ * split point). Returns null if there's no top-level `|` at all, if brackets/
680
+ * parens are unbalanced, or if any resulting alternative is empty (a
681
+ * zero-width arm — an unmodeled edge case, decline rather than mis-lower).
682
+ * One redundant whole-string `(?:…)` wrapper is stripped first (see
683
+ * `unwrapOuterGroup`) so `(?:a|b)` splits the same as bare `a|b`.
684
+ */
685
+ function splitTopLevelAlternation(source: string): string[] | null {
686
+ const body = unwrapOuterGroup(source)
687
+ const parts: string[] = []
688
+ let depth = 0
689
+ let last = 0
690
+ let i = 0
691
+ while (i < body.length) {
692
+ const ch = body[i]
693
+ if (ch === '\\') { i += 2; continue }
694
+ if (ch === '[') {
695
+ let j = i + 1
696
+ if (body[j] === '^') j++
697
+ while (j < body.length && body[j] !== ']') {
698
+ if (body[j] === '\\') j += 2
699
+ else j++
700
+ }
701
+ if (body[j] !== ']') return null
702
+ i = j + 1
703
+ continue
704
+ }
705
+ if (ch === '(') { depth++; i++; continue }
706
+ if (ch === ')') { depth--; if (depth < 0) return null; i++; continue }
707
+ if (ch === '|' && depth === 0) {
708
+ parts.push(body.slice(last, i))
709
+ last = i + 1
710
+ i++
711
+ continue
712
+ }
713
+ i++
714
+ }
715
+ if (depth !== 0) return null
716
+ parts.push(body.slice(last))
717
+ if (parts.length < 2 || parts.some(p => p.length === 0)) return null
718
+ return parts
719
+ }
720
+
721
+ /** Can `shape` match the empty string? Only an unbounded `chars` run with no
722
+ * `+` (`[x]*`) can — every other shape requires at least one fixed/mandatory
723
+ * char (`seq` always has ≥1 mandatory part per `seqIsUnambiguous`; `ident`/
724
+ * `until`/`delimited`/`string`/`litFold` all require a fixed opening char). A
725
+ * `lookahead`/`alt` defer to what they wrap. */
726
+ function shapeCanBeEmpty(shape: ScanShape): boolean {
727
+ if (shape.kind === 'chars') return !shape.minOne
728
+ if (shape.kind === 'lookahead') return shapeCanBeEmpty(shape.inner)
729
+ if (shape.kind === 'alt') return shape.arms.some(shapeCanBeEmpty)
730
+ return false
731
+ }
732
+
733
+ /** Exact union of char-sets when it's computable, else `'any'` (conservative:
734
+ * forces ordered dispatch rather than guessing at a mixed-polarity union, or
735
+ * when any input is already `'any'`). A single concrete-set list is returned
736
+ * as-is (including if negated) — the ambiguity is only in combining ≥2 sets
737
+ * of differing polarity. */
738
+ function unionCharSets(sets: Array<CharSet | 'any'>): CharSet | 'any' {
739
+ if (sets.some(s => s === 'any')) return 'any'
740
+ const concrete = sets as CharSet[]
741
+ if (concrete.length === 1) return concrete[0]!
742
+ if (concrete.some(s => s.negated)) return 'any'
743
+ return { ranges: concrete.flatMap(s => s.ranges), negated: false }
744
+ }
745
+
746
+ /** A single seq part's own first-char set, ignoring whether it's skippable.
747
+ * A `group` part delegates to its inner shape's own first-set (§8f) —
748
+ * possibly `'any'` if the inner shape can match empty or is a non-disjoint
749
+ * `alt` (see `shapeFirstAccept`). */
750
+ function partFirstAccept(part: SeqPart): CharSet | 'any' {
751
+ if (part.part === 'lit') return { ranges: [[part.cps[0]!, part.cps[0]!]], negated: false }
752
+ if (part.part === 'run') return { ranges: part.ranges, negated: part.negated }
753
+ return shapeFirstAccept(part.inner)
754
+ }
755
+
756
+ /** The char-set a `seq` can start with: union of each leading skippable part's
757
+ * own set up to and including the first mandatory part (after which later
758
+ * parts can no longer be what the very first input char matches). */
759
+ function seqFirstAccept(parts: SeqPart[]): CharSet | 'any' {
760
+ const sets: Array<CharSet | 'any'> = []
761
+ for (const p of parts) {
762
+ const mandatory = p.part === 'lit' ? !p.optional : p.min >= 1
763
+ sets.push(partFirstAccept(p))
764
+ if (mandatory) break
765
+ }
766
+ return unionCharSets(sets)
767
+ }
768
+
769
+ /**
770
+ * Is `inner` safe to use as a group's body (§8f)? `chars`/`ident`/`seq`/
771
+ * `litFold` are always safe: each is only ever constructed after proving (via
772
+ * `seqIsUnambiguous` or equivalent) that greedy resolution is the UNIQUE valid
773
+ * match at a given position — there is no alternate length for a backtracker
774
+ * to reconsider, so treating the group as an atomic, resolve-once unit is
775
+ * sound regardless of what follows it. A `lookahead` defers to what it wraps
776
+ * (zero-width, adds no new choice). An `alt` is safe ONLY when `disjoint`:
777
+ * a NON-disjoint alt (`(?:a|ab)`) genuinely can need real backtracking into a
778
+ * DIFFERENT arm if something after the group fails (verified: `/^(?:a|ab)c/`
779
+ * matches "abc" via the SECOND arm, only because the first arm's match left
780
+ * "c" unsatisfied) — our ordered-dispatch codegen resolves once and never
781
+ * reconsiders, so that case must decline. `until`/`delimited`/`string` are
782
+ * declined outright, same conservative call as `trailingBacktrackClass`.
783
+ */
784
+ function groupInnerSafe(shape: ScanShape): boolean {
785
+ if (shape.kind === 'chars' || shape.kind === 'ident' || shape.kind === 'seq' || shape.kind === 'litFold') return true
786
+ if (shape.kind === 'lookahead') return groupInnerSafe(shape.inner)
787
+ // A disjoint alt is safe anywhere. A NON-disjoint alt is admitted here too,
788
+ // but its ordered-choice-commit is only sound in the trailing-once position —
789
+ // `seqIsUnambiguous` (§8h) enforces that positional constraint; a non-trailing
790
+ // or optional/repeated non-disjoint-alt group is rejected there.
791
+ if (shape.kind === 'alt') return true
792
+ return false
793
+ }
794
+
795
+ /**
796
+ * The set of first chars a NON-EMPTY match of `shape` could start with, or
797
+ * `'any'` if that can't be pinned down to a single char-set (used to decide
798
+ * whether `alt` arms have disjoint first-sets). A shape that can match empty
799
+ * degrades to `'any'`: since it could succeed with zero chars regardless of
800
+ * what follows, it would "accept" any first char by never actually needing one
801
+ * — treating that as disjoint from anything else would be unsound.
802
+ */
803
+ function shapeFirstAccept(shape: ScanShape): CharSet | 'any' {
804
+ if (shapeCanBeEmpty(shape)) return 'any'
805
+ switch (shape.kind) {
806
+ case 'chars': return { ranges: shape.ranges, negated: false }
807
+ case 'ident': return { ranges: shape.head, negated: false }
808
+ case 'until': return { ranges: [[shape.open[0]!, shape.open[0]!]], negated: false }
809
+ case 'delimited': return { ranges: [[shape.open[0]!, shape.open[0]!]], negated: false }
810
+ case 'string': return { ranges: [[shape.quote, shape.quote]], negated: false }
811
+ case 'litFold': {
812
+ const cp = shape.open[0]!
813
+ if (cp >= 65 && cp <= 90) return { ranges: [[cp, cp], [cp + 32, cp + 32]], negated: false }
814
+ if (cp >= 97 && cp <= 122) return { ranges: [[cp, cp], [cp - 32, cp - 32]], negated: false }
815
+ return { ranges: [[cp, cp]], negated: false }
816
+ }
817
+ case 'seq': return seqFirstAccept(shape.parts)
818
+ case 'lookahead': return shapeFirstAccept(shape.inner) // zero-width — doesn't change first-char acceptance
819
+ case 'alt': {
820
+ // The set of first chars is the union of every arm's own first-set,
821
+ // independent of whether the arms are mutually disjoint (that flag only
822
+ // governs dispatch, not what the shape can start with). Any arm whose
823
+ // first-set is itself `'any'` (stored as `null`) makes the union `'any'`.
824
+ const sets = shape.firsts.map((f): CharSet | 'any' => (f === null ? 'any' : f))
825
+ return unionCharSets(sets)
826
+ }
827
+ }
828
+ }
829
+
830
+ /** True iff every pair of `sets` is provably disjoint; `'any'` is disjoint from nothing. */
831
+ function allPairsDisjoint(sets: Array<CharSet | 'any'>): boolean {
832
+ for (let i = 0; i < sets.length; i++) {
833
+ const a = sets[i]!
834
+ if (a === 'any') return false
835
+ for (let j = i + 1; j < sets.length; j++) {
836
+ const b = sets[j]!
837
+ if (b === 'any' || !classDisjoint(a, b)) return false
838
+ }
839
+ }
840
+ return true
841
+ }
842
+
843
+ /**
844
+ * Recognize top-level alternation `A|B|C` (§8e): split on `|` outside any
845
+ * `[]`/`()`, lower each arm independently (an arm may itself be a `seq`,
846
+ * `chars`, `lookahead`, …, or even nested alternation via one redundant
847
+ * `(?:…)` wrapper — see `unwrapOuterGroup`). Declines (null) unless EVERY arm
848
+ * independently lowers — a single unsupported arm (e.g. one with a nested
849
+ * capturing/non-capturing group, §8f) falls the whole alternation back to
850
+ * `RegExp.exec` rather than partially lowering.
851
+ */
852
+ function parseAlternation(source: string): ScanShape | null {
853
+ const parts = splitTopLevelAlternation(source)
854
+ if (!parts) return null
855
+ const arms: ScanShape[] = []
856
+ for (const p of parts) {
857
+ const arm = parseScanShape(p)
858
+ if (!arm) return null
859
+ arms.push(arm)
860
+ }
861
+ const firstSets = arms.map(shapeFirstAccept)
862
+ const disjoint = allPairsDisjoint(firstSets)
863
+ return { kind: 'alt', arms, disjoint, firsts: firstSets.map(f => (f === 'any' ? null : f)) }
864
+ }
865
+
866
+ /**
867
+ * Recognize one scannable arm from its regex source, or null if it isn't one of
868
+ * the structural shapes. Top-level alternation (§8e) is tried FIRST — `|` has
869
+ * the lowest precedence in regex, so splitting on it happens before anything
870
+ * else, with each arm then recursively re-entering this same function (so an
871
+ * arm's own trailing lookahead, or its own nested `(?:…)`-wrapped alternation,
872
+ * is still recognized). Failing that: char-class run, ident, string, then
873
+ * open-until-terminator, delimited, and finally the general linear `seq` chain.
874
+ * A trailing `(?!class)`/`(?=class)` is peeled off next (§8b) and re-wraps
875
+ * whatever shape the remaining base recognizes as.
876
+ */
877
+ export function parseScanShape(source: string): ScanShape | null {
878
+ const alt = parseAlternation(source)
879
+ if (alt) return alt
880
+ const la = stripTrailingLookahead(source)
881
+ if (la) {
882
+ const inner = parseScanShapeCore(la.base)
883
+ if (!inner) return null
884
+ const operand: CharSet = { ranges: la.ranges, negated: la.classNegated }
885
+ if (!lookaheadUnambiguous(inner, operand, la.negative)) return null
886
+ return { kind: 'lookahead', inner, ranges: la.ranges, classNegated: la.classNegated, negative: la.negative }
887
+ }
888
+ return parseScanShapeCore(source)
889
+ }
890
+
891
+ /**
892
+ * Split a regex fragment on top-level `|` (outside `[]`/`()`), escape-aware.
893
+ * Unlike `splitTopLevelAlternation` this allows a SINGLE arm (no `|` at all) and
894
+ * does not unwrap an outer group — it's used to enumerate the arms of a
895
+ * `delimited` body, which may legitimately be one bare arm (`[^x]`). Returns
896
+ * null on unbalanced brackets/parens.
897
+ */
898
+ function splitDelimArms(body: string): string[] | null {
899
+ const arms: string[] = []
900
+ let depth = 0
901
+ let last = 0
902
+ let i = 0
903
+ while (i < body.length) {
904
+ const ch = body[i]
905
+ if (ch === '\\') { i += 2; continue }
906
+ if (ch === '[') {
907
+ let j = i + 1
908
+ if (body[j] === '^') j++
909
+ while (j < body.length && body[j] !== ']') {
910
+ if (body[j] === '\\') j += 2
911
+ else j++
912
+ }
913
+ if (body[j] !== ']') return null
914
+ i = j + 1
915
+ continue
916
+ }
917
+ if (ch === '(') { depth++; i++; continue }
918
+ if (ch === ')') { depth--; if (depth < 0) return null; i++; continue }
919
+ if (ch === '|' && depth === 0) { arms.push(body.slice(last, i)); last = i + 1 }
920
+ i++
921
+ }
922
+ if (depth !== 0) return null
923
+ arms.push(body.slice(last))
924
+ return arms
925
+ }
926
+
927
+ /**
928
+ * Prove that the `delimited` lowering — "scan the raw input to the first full
929
+ * `close` literal, ignoring the body entirely" — is EQUIVALENT to the real regex
930
+ * `<open>(?:body)*<close>`. That equivalence holds iff `(?:body)*` can never
931
+ * match a string that contains the `close` literal: then greedy repetition stops
932
+ * exactly before the first `close`, which is where the scan lands too.
933
+ *
934
+ * We only prove this for the two-code-point delimiter-safe idiom (the sole shape that expresses
935
+ * "any char that doesn't form the close"):
936
+ *
937
+ * n === 1 (`close` is one char l0): body must be exactly `[^l0]`.
938
+ * n === 2 (`close` is `l0 l1`): body must be exactly `[^l0] | l0(?!l1)`.
939
+ *
940
+ * In both the bulk arm `[^l0]` consumes every char except l0, and the guard arm
941
+ * (n === 2) consumes l0 only when it is NOT followed by l1 — so the body admits
942
+ * l0 but never the full `l0 l1`, and rejects NOTHING else (a body that excluded
943
+ * some non-close char would stop early while the raw scan skipped ahead to the
944
+ * next close, diverging). Anything outside this template — an alternation whose
945
+ * arms can consume the close (`z(?:a|[0-2]+)*a`), a bulk class narrower/wider
946
+ * than `[^l0]`, a longer close, extra arms — is declined, falling through to
947
+ * `RegExp.exec`. Sound-by-construction: we only ever return true for a body we
948
+ * can prove never contains `close`.
949
+ */
950
+ function delimitedBodySound(body: string, close: number[]): boolean {
951
+ const l0 = close[0]!
952
+ if (close.length > 2) return false
953
+ const arms = splitDelimArms(body)
954
+ if (!arms) return false
955
+ let bulkSeen = false
956
+ let guardSeen = false
957
+ for (const arm of arms) {
958
+ // Bulk arm: a bare negated class `[^l0]` — excluded set is exactly {l0}.
959
+ const bulk = /^\[\^((?:\\.|[^\]])+)\]$/.exec(arm)
960
+ if (bulk) {
961
+ if (bulkSeen) return false
962
+ const ranges = parseClassRanges(bulk[1]!)
963
+ if (!ranges || ranges.length !== 1 || ranges[0]![0] !== l0 || ranges[0]![1] !== l0) return false
964
+ bulkSeen = true
965
+ continue
966
+ }
967
+ // Guard arm (n === 2 only): `l0(?!l1)` — consume l0 unless it starts the close.
968
+ if (close.length === 2 && arm.endsWith(')')) {
969
+ const idx = arm.indexOf('(?!')
970
+ if (idx === -1) return false
971
+ const prefix = literalCodePoints(arm.slice(0, idx))
972
+ const op = parseClassOperand(arm.slice(idx + 3, -1))
973
+ const prefixIsL0 = prefix?.length === 1 && prefix[0] === l0
974
+ const forbidsL1 = op && !op.negated && op.ranges.length === 1 &&
975
+ op.ranges[0]![0] === close[1] && op.ranges[0]![1] === close[1]
976
+ if (!prefixIsL0 || !forbidsL1 || guardSeen) return false
977
+ guardSeen = true
978
+ continue
979
+ }
980
+ return false
981
+ }
982
+ // Require the exact arm set: bulk always; guard iff the close is two chars.
983
+ return bulkSeen && guardSeen === (close.length === 2)
984
+ }
985
+
986
+ /**
987
+ * Structurally recognize `<openLit>(?:<body>)*<closeLit>` — literal opener, a
988
+ * single non-capturing `*`-repeated group, literal closer — and lower it to a
989
+ * `delimited` scan ONLY when `delimitedBodySound` proves the body can't contain
990
+ * the close. Returns null (→ fall through to `seq`/`RegExp.exec`) otherwise.
991
+ */
992
+ function parseDelimited(source: string): ScanShape | null {
993
+ // An escape-aware body (a literal `\\` in the source ⇒ string-like) is handled
994
+ // by `parseStringShape`; here "scan to first close" would wrongly stop at an
995
+ // escaped delimiter, so decline.
996
+ if (source.includes('\\\\')) return null
997
+ // Opener: literal chars up to the first unescaped `(`, which must open `(?:`.
998
+ let i = 0
999
+ while (i < source.length && source[i] !== '(') {
1000
+ i += source[i] === '\\' ? 2 : 1
1001
+ }
1002
+ if (source[i] !== '(' || source[i + 1] !== '?' || source[i + 2] !== ':') return null
1003
+ const openFrag = source.slice(0, i)
1004
+ // Balanced scan of the group body to its matching `)`.
1005
+ let k = i + 3
1006
+ let depth = 1
1007
+ while (k < source.length && depth > 0) {
1008
+ const ch = source[k]
1009
+ if (ch === '\\') { k += 2; continue }
1010
+ if (ch === '[') {
1011
+ let j = k + 1
1012
+ if (source[j] === '^') j++
1013
+ while (j < source.length && source[j] !== ']') {
1014
+ if (source[j] === '\\') j += 2
1015
+ else j++
1016
+ }
1017
+ if (source[j] !== ']') return null
1018
+ k = j + 1
1019
+ continue
1020
+ }
1021
+ if (ch === '(') depth++
1022
+ else if (ch === ')') depth--
1023
+ k++
1024
+ }
1025
+ if (depth !== 0) return null
1026
+ const body = source.slice(i + 3, k - 1)
1027
+ if (source[k] !== '*') return null
1028
+ const closeFrag = source.slice(k + 1)
1029
+ const open = literalCodePoints(openFrag)
1030
+ const close = literalCodePoints(closeFrag)
1031
+ if (!open || !close || !delimitedBodySound(body, close)) return null
1032
+ return { kind: 'delimited', open, close }
1033
+ }
1034
+
1035
+ function parseScanShapeCore(source: string): ScanShape | null {
1036
+ // [X]+ / [X]* — a positive char-class run. A negated `[^X]+`/`[^X]*` is left to
1037
+ // the general `seq` path (which lowers `[^X]+` and declines zero-width `[^X]*`).
1038
+ let m = /^\[((?:\\.|[^\]])+)\]([+*])$/.exec(source)
1039
+ if (m && !m[1]!.startsWith('^')) {
1040
+ const ranges = parseClassRanges(m[1]!)
1041
+ return ranges ? { kind: 'chars', ranges, minOne: m[2] === '+' } : null
1042
+ }
1043
+ // \d+ / \w+ / \s+ / \d* / \w* / \s* — a bare shorthand-class run.
1044
+ m = /^\\([dws])([+*])$/.exec(source)
1045
+ if (m) {
1046
+ return { kind: 'chars', ranges: shorthandRanges(m[1] as 'd' | 'w' | 's'), minOne: m[2] === '+' }
1047
+ }
1048
+ // <head><tail>* — identifier run: one head char, then a run of tail chars.
1049
+ m = /^(\[(?:\\.|[^\]])+\]|\\[dws])(\[(?:\\.|[^\]])+\]|\\[dws])\*$/.exec(source)
1050
+ if (m) {
1051
+ const head = classToRanges(m[1]!)
1052
+ const tail = classToRanges(m[2]!)
1053
+ if (head && tail) return { kind: 'ident', head, tail }
1054
+ }
1055
+ // <q>(?:[^q\\]|\\.)*<q> — a quote-delimited string with escapes.
1056
+ const str = parseStringShape(source)
1057
+ if (str) return str
1058
+ // <lit>[^X]* — consume a literal opener, then run until a terminator char.
1059
+ m = /^(.*?)\[\^((?:\\.|[^\]])+)\]\*$/.exec(source)
1060
+ if (m) {
1061
+ const open = literalCodePoints(m[1]!)
1062
+ const stop = parseClassRanges(m[2]!)
1063
+ if (open && stop) return { kind: 'until', open, stop }
1064
+ }
1065
+ // <open>(?:…)*<close> — delimited token scanned to its first close literal.
1066
+ // Only lowered when the body provably can't contain the close (see
1067
+ // `parseDelimited` / `delimitedBodySound`); any other `X(?:…)*Y` declines here
1068
+ // and falls through to the `seq` path / `RegExp.exec`.
1069
+ const delim = parseDelimited(source)
1070
+ if (delim) return delim
1071
+ // General category: a linear chain of literals + char runs (optional prefixes,
1072
+ // literal openers, negated runs), lowered only when unambiguously greedy.
1073
+ const parts = parseSeqParts(source)
1074
+ if (parts && seqIsUnambiguous(parts)) return { kind: 'seq', parts }
1075
+ return null
1076
+ }
1077
+
1078
+ /**
1079
+ * Flag-aware wrapper around `parseScanShape`. Lowering to a raw code-point scan
1080
+ * assumes default regex semantics, so any flag that changes matching (`i` case
1081
+ * folding, `u` surrogate handling, `m`/`s` anchor/dot behavior) disables it.
1082
+ * `g`/`y` are stickiness-only and safe.
1083
+ */
1084
+ export function scanShapeFromRegex(source: string, flags: string): ScanShape | null {
1085
+ if (/[msu]/.test(flags)) return null
1086
+ if (/i/.test(flags)) return caseFoldShape(source)
1087
+ return parseScanShape(source)
1088
+ }
1089
+
1090
+ /**
1091
+ * Case-insensitive (`/i`) scannable shapes. Beyond a pure case-fold literal
1092
+ * (`litFold`), this recognizes a top-level alternation of literals (an ordered
1093
+ * `alt` of litFolds — CSS `(?:and|or)`, `(?:media|container|supports)`), each
1094
+ * optionally wrapped in a trailing lookahead boundary (`(?![-\w])`, `(?=\()`).
1095
+ * That last form is the regex spelling of `makeWord` — a keyword plus a word
1096
+ * boundary — the common CSS at-rule/keyword dispatcher. Anything else declines
1097
+ * (→ `RegExp.exec`); a false read here can only fail to lower, never mis-lower.
1098
+ */
1099
+ function caseFoldShape(source: string): ScanShape | null {
1100
+ const la = stripTrailingLookahead(source)
1101
+ if (la) {
1102
+ const inner = caseFoldLiteralOrAlt(la.base)
1103
+ if (!inner) return null
1104
+ const operand: CharSet = { ranges: la.ranges, negated: la.classNegated }
1105
+ if (!lookaheadUnambiguous(inner, operand, la.negative)) return null
1106
+ return { kind: 'lookahead', inner, ranges: la.ranges, classNegated: la.classNegated, negative: la.negative }
1107
+ }
1108
+ return caseFoldLiteralOrAlt(source)
1109
+ }
1110
+
1111
+ /** A case-fold literal, or a top-level `a|b|c` alternation of pure literals. */
1112
+ function caseFoldLiteralOrAlt(source: string): ScanShape | null {
1113
+ // `foldEq` only folds ASCII letters (65–90 / 97–122). A non-ASCII code point
1114
+ // with a Unicode case pair (é/É, ä/Ä, …) would be emitted as an exact compare
1115
+ // and silently miss its alternate case — a mis-lower, not just a failed lower.
1116
+ // Decline those so they fall back to `RegExp.exec` (correct Unicode folding),
1117
+ // preserving this file's "a false read can only fail to lower, never mis-lower".
1118
+ const asciiOnly = (cps: number[]): boolean => cps.every(cp => cp <= 127)
1119
+
1120
+ const open = literalCodePoints(source)
1121
+ if (open) return asciiOnly(open) ? { kind: 'litFold', open } : null
1122
+ // `splitTopLevelAlternation` unwraps the outer group itself, so no pre-strip.
1123
+ const arms = splitTopLevelAlternation(source)
1124
+ if (!arms || arms.length < 2) return null
1125
+ const litArms: ScanShape[] = []
1126
+ for (const arm of arms) {
1127
+ const cp = literalCodePoints(arm)
1128
+ if (!cp || !asciiOnly(cp)) return null
1129
+ litArms.push({ kind: 'litFold', open: cp })
1130
+ }
1131
+ // Ordered choice: each litFold arm case-folds on its own, so skipping the
1132
+ // first-char dispatch guard (disjoint:false, firsts:null) is always correct —
1133
+ // it just tries arms in order, exactly like regex `|`.
1134
+ return { kind: 'alt', arms: litArms, disjoint: false, firsts: litArms.map(() => null) }
1135
+ }
1136
+
1137
+ /**
1138
+ * `PARSEMAN_SCANTO=indexof` swaps the single-stop-char `until` scan loop for
1139
+ * `String.prototype.indexOf`. Defaults OFF so a baseline build is provably
1140
+ * byte-identical to the unmodified compiler, which is what makes the A/B a
1141
+ * toggle rather than two different compilers (docs/design/derived-tokenization.md
1142
+ * §16.3: the gate is a byte-identical parse tree against a toggled baseline).
1143
+ */
1144
+ const scanToIndexOfEnabled = (): boolean => process.env.PARSEMAN_SCANTO === 'indexof'
1145
+
1146
+ export const classCond = (cVar: string, ranges: Array<[number, number]>): string =>
1147
+ ranges
1148
+ .map(([lo, hi]) => (lo === hi ? `${cVar} === ${lo}` : `(${cVar} >= ${lo} && ${cVar} <= ${hi})`))
1149
+ .join(' || ')
1150
+
1151
+ /** Literal-match condition at `base + k` for each code point; uses `firstVar` at offset 0. */
1152
+ export const litCond = (base: string, cps: number[], firstVar?: string): string =>
1153
+ cps
1154
+ .map((cp, k) =>
1155
+ k === 0 && firstVar
1156
+ ? `${firstVar} === ${cp}`
1157
+ : `input.charCodeAt(${base}${k ? ` + ${k}` : ''}) === ${cp}`,
1158
+ )
1159
+ .join(' && ')
1160
+
1161
+ /** Line-terminator code points `.` does not match (`\n \r \u2028 \u2029`). */
1162
+ export const LINE_TERMINATORS = [10, 13, 8232, 8233] as const
1163
+
1164
+ /** The body-stop chars that abort a string match (excluded set minus quote/backslash). */
1165
+ export function stringHardStop(
1166
+ shape: Extract<ScanShape, { kind: 'string' }>,
1167
+ ): Array<[number, number]> {
1168
+ return shape.excluded.filter(
1169
+ ([lo, hi]) => !(lo === hi && (lo === shape.quote || lo === BACKSLASH)),
1170
+ )
1171
+ }
1172
+
1173
+ /** Mints a fresh, unique local variable name (`prefix` + counter). */
1174
+ export type Mint = (prefix?: string) => string
1175
+
1176
+ /**
1177
+ * The SINGLE source of truth for how a scannable shape matches at `start`. Both
1178
+ * the terminal emitter and the trivia scan loop consume this, so no context can
1179
+ * silently reinterpret an incomplete match (e.g. an unterminated string):
1180
+ *
1181
+ * - `setup` statements (indented by `ind`) that compute the match.
1182
+ * - `ok` a boolean expr: did a token match at `start`? (zero-width for
1183
+ * `chars*` counts as a match — terminals allow it.)
1184
+ * - `end` the position AFTER the token. **Invariant:** `end === start`
1185
+ * whenever there is no progress (match failed or matched empty),
1186
+ * so the trivia loop can gate purely on `end > start`.
1187
+ *
1188
+ * `firstChar`, when supplied, is an expression already equal to
1189
+ * `charCodeAt(start)` (the trivia loop reads it once and shares it).
1190
+ */
1191
+ export type ShapeMatch = { setup: string[]; ok: string; end: string }
1192
+
1193
+ const codeAt = (start: string, firstChar?: string): string =>
1194
+ firstChar ?? `input.charCodeAt(${start})`
1195
+
1196
+ // Dispatch-planning thresholds for a disjoint alt's first-char `switch` — same
1197
+ // tuning as codegen's `planDisjointDispatch` (kept local to avoid a scannable-run
1198
+ // → codegen import cycle): only worth a jump table when every arm keys off a few
1199
+ // DISCRETE points; a wide range (`[a-z]+`) would explode into dozens of cases.
1200
+ const ALT_SWITCH_RANGE_LIMIT = 4
1201
+ const ALT_SWITCH_MAX_CASES = 48
1202
+ const ALT_SWITCH_MIN_CASES = 3
1203
+
1204
+ /**
1205
+ * A `switch` jump table for a *disjoint* alt when every arm's first-set is a few
1206
+ * discrete code points (operator/keyword first chars); else `{ kind: 'if' }` to
1207
+ * keep the range-comparison `if/else if` chain. Arms are pairwise-disjoint, so
1208
+ * each code point maps to exactly one arm.
1209
+ */
1210
+ function planAltSwitch(firsts: Array<CharSet | null>): { kind: 'switch'; cases: number[][] } | { kind: 'if' } {
1211
+ const cases: number[][] = []
1212
+ let total = 0
1213
+ for (const fs of firsts) {
1214
+ if (!fs || fs.negated) return { kind: 'if' } // any / negated → no discrete keys
1215
+ const pts: number[] = []
1216
+ for (const [lo, hi] of fs.ranges) {
1217
+ if (hi - lo + 1 > ALT_SWITCH_RANGE_LIMIT) return { kind: 'if' }
1218
+ for (let cp = lo; cp <= hi; cp++) pts.push(cp)
1219
+ }
1220
+ total += pts.length
1221
+ if (total > ALT_SWITCH_MAX_CASES) return { kind: 'if' }
1222
+ cases.push(pts)
1223
+ }
1224
+ return total >= ALT_SWITCH_MIN_CASES ? { kind: 'switch', cases } : { kind: 'if' }
1225
+ }
1226
+
1227
+ export function emitShapeMatch(
1228
+ shape: ScanShape,
1229
+ start: string,
1230
+ mint: Mint,
1231
+ ind: string,
1232
+ firstChar?: string,
1233
+ ): ShapeMatch {
1234
+ if (shape.kind === 'lookahead') {
1235
+ const inner = emitShapeMatch(shape.inner, start, mint, ind, firstChar)
1236
+ const okV = mint('_ok')
1237
+ const endV = mint('_end')
1238
+ // `charInClass` folds the operand's OWN `[^…]` negation in; `guardFails`
1239
+ // then applies the lookahead's `!`/`=` polarity on top. Zero-width: `end`
1240
+ // is always `inner.end` (on success) or `start` (on failure) — never past
1241
+ // where `inner` itself stopped.
1242
+ const rawIn = classCond(`input.charCodeAt(${inner.end})`, shape.ranges)
1243
+ const charInClass = shape.classNegated ? `!(${rawIn})` : `(${rawIn})`
1244
+ const hasMatchingChar = `${inner.end} < input.length && ${charInClass}`
1245
+ const guardFails = shape.negative ? hasMatchingChar : `!(${hasMatchingChar})`
1246
+ return {
1247
+ setup: [
1248
+ ...inner.setup,
1249
+ `${ind}let ${okV} = ${inner.ok}`,
1250
+ `${ind}let ${endV} = ${inner.end}`,
1251
+ `${ind}if (${okV} && (${guardFails})) { ${okV} = false; ${endV} = ${start} }`,
1252
+ ],
1253
+ ok: okV,
1254
+ end: endV,
1255
+ }
1256
+ }
1257
+
1258
+ if (shape.kind === 'alt') {
1259
+ const okV = mint('_ok')
1260
+ const endV = mint('_end')
1261
+ const lines: string[] = [`${ind}let ${okV} = false`, `${ind}let ${endV} = ${start}`]
1262
+ // Fast path: an alternation of case-fold literals (CSS keyword lists like
1263
+ // `@media|@container|@supports`, `even|odd`) → a `switch` on the first char
1264
+ // that jumps straight to the matching group, tails compared with the bit-OR
1265
+ // fold. Measured ~2.4x over the ordered if-chain / ~1.4x over per-arm bit-OR.
1266
+ // Ordered-choice is preserved: arms are grouped by their FOLDED first code
1267
+ // point and, within a group, emitted in source order (first match wins);
1268
+ // arms in different groups can't both match a given first char.
1269
+ if (shape.arms.length >= 2 && shape.arms.every(a => a.kind === 'litFold' && a.open.length >= 1)) {
1270
+ const groups = new Map<number, number[][]>()
1271
+ for (const a of shape.arms) {
1272
+ const open = (a as Extract<ScanShape, { kind: 'litFold' }>).open
1273
+ const f = open[0]!
1274
+ const lower = f >= 65 && f <= 90 ? f + 32 : f
1275
+ ;(groups.get(lower) ?? groups.set(lower, []).get(lower)!).push(open)
1276
+ }
1277
+ lines.push(`${ind}if (${start} < input.length) switch (${codeAt(start, firstChar)}) {`)
1278
+ for (const [lower, arms] of groups) {
1279
+ const labels = lower >= 97 && lower <= 122 ? `case ${lower}: case ${lower - 32}:` : `case ${lower}:`
1280
+ lines.push(`${ind} ${labels} {`)
1281
+ for (const open of arms) {
1282
+ const tail = open.slice(1).map((cp, i) => foldEq(`input.charCodeAt(${start} + ${i + 1})`, cp))
1283
+ const cond = tail.length ? ` && ${tail.join(' && ')}` : ''
1284
+ lines.push(`${ind} if (${start} + ${open.length} <= input.length${cond}) { ${okV} = true; ${endV} = ${start} + ${open.length}; break }`)
1285
+ }
1286
+ lines.push(`${ind} break`)
1287
+ lines.push(`${ind} }`)
1288
+ }
1289
+ lines.push(`${ind}}`)
1290
+ return { setup: lines, ok: okV, end: endV }
1291
+ }
1292
+ if (shape.disjoint) {
1293
+ // Mutually exclusive first-sets: dispatch straight to the one matching
1294
+ // arm, no ordering/backtracking concerns (§7b-style disjoint dispatch). A
1295
+ // `switch` jump table when arms key off discrete first chars, else the
1296
+ // range-comparison `if/else if` chain (same plan as codegen's choice()).
1297
+ const plan = planAltSwitch(shape.firsts)
1298
+ if (plan.kind === 'switch') {
1299
+ lines.push(`${ind}if (${start} < input.length) switch (${codeAt(start, firstChar)}) {`)
1300
+ shape.arms.forEach((arm, k) => {
1301
+ const labels = plan.cases[k]!.map(cp => `case ${cp}:`).join(' ')
1302
+ const m = emitShapeMatch(arm, start, mint, `${ind} `, firstChar)
1303
+ lines.push(`${ind} ${labels} {`)
1304
+ lines.push(...m.setup)
1305
+ lines.push(`${ind} ${okV} = ${m.ok}`)
1306
+ lines.push(`${ind} ${endV} = ${m.ok} ? ${m.end} : ${start}`)
1307
+ lines.push(`${ind} break`)
1308
+ lines.push(`${ind} }`)
1309
+ })
1310
+ lines.push(`${ind}}`)
1311
+ } else {
1312
+ const c0 = codeAt(start, firstChar)
1313
+ shape.arms.forEach((arm, k) => {
1314
+ const fs = shape.firsts[k]! // non-null whenever `disjoint` is true
1315
+ const cond = fs.negated ? `!(${classCond(c0, fs.ranges)})` : `(${classCond(c0, fs.ranges)})`
1316
+ const m = emitShapeMatch(arm, start, mint, `${ind} `, firstChar)
1317
+ lines.push(`${ind}${k === 0 ? 'if' : 'else if'} (${start} < input.length && ${cond}) {`)
1318
+ lines.push(...m.setup)
1319
+ lines.push(`${ind} ${okV} = ${m.ok}`)
1320
+ lines.push(`${ind} ${endV} = ${m.ok} ? ${m.end} : ${start}`)
1321
+ lines.push(`${ind}}`)
1322
+ })
1323
+ }
1324
+ } else {
1325
+ // Overlapping first-sets: ordered choice — try each arm in turn and take
1326
+ // the first that succeeds. This is exactly regex `|`'s own semantics:
1327
+ // first alternative to match AT ALL wins on ITS OWN greedy length, never
1328
+ // compared against a later alternative's (possibly longer) match.
1329
+ const lbl = mint('_alt')
1330
+ lines.push(`${ind}${lbl}: {`)
1331
+ for (const arm of shape.arms) {
1332
+ const m = emitShapeMatch(arm, start, mint, `${ind} `, firstChar)
1333
+ lines.push(...m.setup)
1334
+ lines.push(`${ind} if (${m.ok}) { ${okV} = true; ${endV} = ${m.end}; break ${lbl} }`)
1335
+ }
1336
+ lines.push(`${ind}}`)
1337
+ }
1338
+ return { setup: lines, ok: okV, end: endV }
1339
+ }
1340
+
1341
+ if (shape.kind === 'chars') {
1342
+ const cur = mint('_e')
1343
+ return {
1344
+ setup: [
1345
+ `${ind}let ${cur} = ${start}`,
1346
+ `${ind}while (${cur} < input.length && (${classCond(`input.charCodeAt(${cur})`, shape.ranges)})) ${cur}++`,
1347
+ ],
1348
+ // `*` always matches (possibly empty); `+` needs at least one char.
1349
+ ok: shape.minOne ? `${cur} > ${start}` : 'true',
1350
+ end: cur,
1351
+ }
1352
+ }
1353
+
1354
+ if (shape.kind === 'ident') {
1355
+ const cur = mint('_e')
1356
+ return {
1357
+ setup: [
1358
+ `${ind}let ${cur} = ${start}`,
1359
+ `${ind}if (${start} < input.length && (${classCond(codeAt(start, firstChar), shape.head)})) {`,
1360
+ `${ind} ${cur} = ${start} + 1`,
1361
+ `${ind} while (${cur} < input.length && (${classCond(`input.charCodeAt(${cur})`, shape.tail)})) ${cur}++`,
1362
+ `${ind}}`,
1363
+ ],
1364
+ ok: `${cur} > ${start}`,
1365
+ end: cur,
1366
+ }
1367
+ }
1368
+
1369
+ if (shape.kind === 'seq') {
1370
+ const endV = mint('_e')
1371
+ const okV = mint('_ok')
1372
+ const runCond = (p: Extract<SeqPart, { part: 'run' }>, at: string) => {
1373
+ const c = classCond(`input.charCodeAt(${at})`, p.ranges)
1374
+ return p.negated ? `!(${c})` : `(${c})`
1375
+ }
1376
+ const lines = [`${ind}let ${endV} = ${start}`, `${ind}let ${okV} = false`, `${ind}do {`]
1377
+ let first = true
1378
+ for (const p of shape.parts) {
1379
+ if (p.part === 'lit') {
1380
+ const L = p.cps.length
1381
+ const cond = litCond(endV, p.cps, first ? firstChar : undefined)
1382
+ if (p.optional) {
1383
+ lines.push(`${ind} if (${endV} + ${L} <= input.length && (${cond})) ${endV} += ${L}`)
1384
+ } else {
1385
+ lines.push(`${ind} if (!(${endV} + ${L} <= input.length && (${cond}))) break`)
1386
+ lines.push(`${ind} ${endV} += ${L}`)
1387
+ }
1388
+ } else if (p.part === 'run' && p.max === Infinity) {
1389
+ // Unbounded run (`+`/`*`/`{n,}`): greedy scan, then enforce the minimum.
1390
+ const s = mint('_s')
1391
+ lines.push(`${ind} const ${s} = ${endV}`)
1392
+ lines.push(`${ind} while (${endV} < input.length && ${runCond(p, endV)}) ${endV}++`)
1393
+ if (p.min === 1) lines.push(`${ind} if (${endV} === ${s}) break`)
1394
+ else if (p.min > 1) lines.push(`${ind} if (${endV} - ${s} < ${p.min}) break`)
1395
+ } else if (p.part === 'run' && p.max === 1) {
1396
+ // Exactly one char: required (min 1) or optional (min 0).
1397
+ if (p.min === 1) {
1398
+ lines.push(`${ind} if (!(${endV} < input.length && ${runCond(p, endV)})) break`)
1399
+ lines.push(`${ind} ${endV}++`)
1400
+ } else {
1401
+ lines.push(`${ind} if (${endV} < input.length && ${runCond(p, endV)}) ${endV}++`)
1402
+ }
1403
+ } else if (p.part === 'run') {
1404
+ // Bounded counted run `[x]{min,max}` (§8c), max ≥ 2: consume up to `max`
1405
+ // occurrences, then require at least `min` (greedy — sound because
1406
+ // `seqIsUnambiguous` proved any wiggle is disjoint from what follows).
1407
+ const s = mint('_s')
1408
+ lines.push(`${ind} const ${s} = ${endV}`)
1409
+ lines.push(`${ind} while (${endV} - ${s} < ${p.max} && ${endV} < input.length && ${runCond(p, endV)}) ${endV}++`)
1410
+ if (p.min > 0) lines.push(`${ind} if (${endV} - ${s} < ${p.min}) break`)
1411
+ } else {
1412
+ // `group` (§8f): the group's own body is just another `ScanShape` —
1413
+ // recurse via `emitShapeMatch`, then splice its setup in as the body
1414
+ // of a required-once check, an optional take, or a repeat loop.
1415
+ const inner = emitShapeMatch(p.inner, endV, mint, `${ind} `, first ? firstChar : undefined)
1416
+ if (!p.unbounded) {
1417
+ lines.push(...inner.setup)
1418
+ if (p.min === 1) {
1419
+ lines.push(`${ind} if (!(${inner.ok})) break`)
1420
+ lines.push(`${ind} ${endV} = ${inner.end}`)
1421
+ } else {
1422
+ lines.push(`${ind} if (${inner.ok}) ${endV} = ${inner.end}`)
1423
+ }
1424
+ } else {
1425
+ const progV = mint('_prog')
1426
+ lines.push(`${ind} let ${progV} = false`)
1427
+ lines.push(`${ind} while (true) {`)
1428
+ lines.push(...inner.setup)
1429
+ lines.push(`${ind} if (!(${inner.ok})) break`)
1430
+ lines.push(`${ind} ${endV} = ${inner.end}`)
1431
+ lines.push(`${ind} ${progV} = true`)
1432
+ lines.push(`${ind} }`)
1433
+ if (p.min === 1) lines.push(`${ind} if (!${progV}) break`)
1434
+ }
1435
+ }
1436
+ first = false
1437
+ }
1438
+ lines.push(`${ind} ${okV} = true`)
1439
+ lines.push(`${ind}} while (false)`)
1440
+ lines.push(`${ind}if (!${okV}) ${endV} = ${start}`)
1441
+ return { setup: lines, ok: okV, end: endV }
1442
+ }
1443
+
1444
+ if (shape.kind === 'litFold') {
1445
+ const openLen = shape.open.length
1446
+ const endV = mint('_end')
1447
+ const checks = shape.open.map((cp, k) => {
1448
+ const cVar = k === 0 && firstChar ? firstChar : `input.charCodeAt(${start} + ${k})`
1449
+ return foldEq(cVar, cp)
1450
+ })
1451
+ return {
1452
+ setup: [
1453
+ `${ind}let ${endV} = ${start}`,
1454
+ `${ind}if (${start} + ${openLen} <= input.length && (${checks.join(' && ')})) ${endV} = ${start} + ${openLen}`,
1455
+ ],
1456
+ ok: `${endV} > ${start}`,
1457
+ end: endV,
1458
+ }
1459
+ }
1460
+
1461
+ if (shape.kind === 'until') {
1462
+ const j = mint('_j')
1463
+ const openLen = shape.open.length
1464
+ const openChk = openLen === 1
1465
+ ? `${codeAt(start, firstChar)} === ${shape.open[0]}`
1466
+ : litCond(start, shape.open, firstChar)
1467
+ // A SINGLE stop code point makes this scan a `memchr`, and V8 implements
1468
+ // `String.prototype.indexOf` with a SIMD one. Measured 4.3x over the char
1469
+ // loop on a 0.97 MiB corpus, 61 of 61 interleaved rounds
1470
+ // (scratchpad/native-primitive-bakeoff/micro-scanto.mjs, regime (a)).
1471
+ // Semantics are identical: the loop stops at the first stop char or at
1472
+ // `input.length`, which is exactly `indexOf`'s hit / not-found pair.
1473
+ // Multi-member stop sets stay on the loop — 3x indexOf + min measured at
1474
+ // parity (0.992), because the min-reduce rescans the tail and discards it.
1475
+ const stopCp =
1476
+ shape.stop.length === 1 && shape.stop[0]![0] === shape.stop[0]![1] ? shape.stop[0]![0] : null
1477
+ const scanLine =
1478
+ stopCp !== null && stopCp <= 0xffff && scanToIndexOfEnabled()
1479
+ ? // No temp is minted: a temp would renumber every later variable in the
1480
+ // function and show up as an artifact-size delta that is counter
1481
+ // renumbering rather than this change (the §9.1.4 / §9.2 effect).
1482
+ `${ind} ${j} = input.indexOf(${JSON.stringify(String.fromCharCode(stopCp))}, ${j})` +
1483
+ `; if (${j} < 0) ${j} = input.length`
1484
+ : `${ind} while (${j} < input.length && !(${classCond(`input.charCodeAt(${j})`, shape.stop)})) ${j}++`
1485
+ return {
1486
+ setup: [
1487
+ `${ind}let ${j} = ${start}`,
1488
+ `${ind}if (${start} + ${openLen} <= input.length && (${openChk})) {`,
1489
+ `${ind} ${j} = ${start} + ${openLen}`,
1490
+ scanLine,
1491
+ `${ind}}`,
1492
+ ],
1493
+ // An open-until-terminator always completes (stop char or EOF), so a matched
1494
+ // open literal is a full match; `end === start` iff the open didn't match.
1495
+ ok: `${j} > ${start}`,
1496
+ end: j,
1497
+ }
1498
+ }
1499
+
1500
+ if (shape.kind === 'string') {
1501
+ const okV = mint('_ok')
1502
+ const endV = mint('_end')
1503
+ const i = mint('_i')
1504
+ const c2 = mint('_c')
1505
+ const hard = stringHardStop(shape)
1506
+ const ltLines = shape.escLineTerm
1507
+ ? []
1508
+ : (() => {
1509
+ const c3 = mint('_c')
1510
+ return [
1511
+ `${ind} const ${c3} = input.charCodeAt(${i} + 1)`,
1512
+ `${ind} if (${LINE_TERMINATORS.map(t => `${c3} === ${t}`).join(' || ')}) break`,
1513
+ ]
1514
+ })()
1515
+ return {
1516
+ setup: [
1517
+ `${ind}let ${okV} = false`,
1518
+ `${ind}let ${endV} = ${start}`,
1519
+ `${ind}if (${start} < input.length && (${codeAt(start, firstChar)}) === ${shape.quote}) {`,
1520
+ `${ind} let ${i} = ${start} + 1`,
1521
+ `${ind} while (${i} < input.length) {`,
1522
+ `${ind} const ${c2} = input.charCodeAt(${i})`,
1523
+ `${ind} if (${c2} === ${shape.quote}) { ${okV} = true; ${endV} = ${i} + 1; break }`,
1524
+ `${ind} if (${c2} === ${BACKSLASH}) {`,
1525
+ `${ind} if (${i} + 1 >= input.length) break`,
1526
+ ...ltLines,
1527
+ `${ind} ${i} += 2`,
1528
+ `${ind} continue`,
1529
+ `${ind} }`,
1530
+ ...(hard.length ? [`${ind} if (${classCond(c2, hard)}) break`] : []),
1531
+ `${ind} ${i}++`,
1532
+ `${ind} }`,
1533
+ `${ind}}`,
1534
+ ],
1535
+ ok: okV,
1536
+ end: endV,
1537
+ }
1538
+ }
1539
+
1540
+ // delimited: <open>…<close>, requires the close literal (unclosed ⇒ no match).
1541
+ const j = mint('_j')
1542
+ const endV = mint('_end')
1543
+ const openLen = shape.open.length
1544
+ const closeLen = shape.close.length
1545
+ const openChk = openLen === 1
1546
+ ? `${codeAt(start, firstChar)} === ${shape.open[0]}`
1547
+ : litCond(start, shape.open, firstChar)
1548
+ const closeChk = litCond(j, shape.close)
1549
+ // Scanning to a fixed close literal IS `indexOf`, and V8's is SIMD. The
1550
+ // rewrite is exact: the loop stops at the first close literal at or after `j`,
1551
+ // or runs out of room and leaves the token unterminated (no match). `indexOf`
1552
+ // returns that same first index, or -1 for "never occurs" — mapped to
1553
+ // `input.length`, which makes the `j + closeLen <= input.length` guard fail
1554
+ // exactly where the loop's room check did. When it IS found the close literal
1555
+ // matches by construction, so the guard's re-comparison is dropped.
1556
+ const closeStr = String.fromCharCode(...shape.close)
1557
+ const scanLines = scanToIndexOfEnabled()
1558
+ ? [
1559
+ `${ind} ${j} = input.indexOf(${JSON.stringify(closeStr)}, ${j}); if (${j} < 0) ${j} = input.length`,
1560
+ `${ind} if (${j} + ${closeLen} <= input.length) ${endV} = ${j} + ${closeLen}`,
1561
+ ]
1562
+ : [
1563
+ `${ind} while (${j} + ${closeLen - 1} < input.length && !(${closeChk})) ${j}++`,
1564
+ `${ind} if (${j} + ${closeLen} <= input.length && (${closeChk})) ${endV} = ${j} + ${closeLen}`,
1565
+ ]
1566
+ return {
1567
+ setup: [
1568
+ `${ind}let ${j} = ${start}`,
1569
+ `${ind}let ${endV} = ${start}`,
1570
+ `${ind}if (${start} + ${openLen} <= input.length && (${openChk})) {`,
1571
+ `${ind} ${j} = ${start} + ${openLen}`,
1572
+ ...scanLines,
1573
+ `${ind}}`,
1574
+ ],
1575
+ ok: `${endV} > ${start}`,
1576
+ end: endV,
1577
+ }
1578
+ }