parseman 0.46.0 → 0.47.1

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 (316) hide show
  1. package/README.md +8 -3
  2. package/dist/analysis/choice-cost-render.d.ts +12 -4
  3. package/dist/analysis/choice-cost-render.d.ts.map +1 -1
  4. package/dist/analysis/choice-cost.d.ts +2 -2
  5. package/dist/analysis/choice-cost.d.ts.map +1 -1
  6. package/dist/analysis/commitment.d.ts +131 -0
  7. package/dist/analysis/commitment.d.ts.map +1 -0
  8. package/dist/analysis/diagnostics.cjs +10339 -7787
  9. package/dist/analysis/diagnostics.cjs.map +3 -4
  10. package/dist/analysis/diagnostics.d.ts +2 -2
  11. package/dist/analysis/diagnostics.js +10339 -7787
  12. package/dist/analysis/diagnostics.js.map +3 -4
  13. package/dist/analysis/duplication.d.ts.map +1 -1
  14. package/dist/analysis/fix.d.ts +3 -3
  15. package/dist/analysis/fix.d.ts.map +1 -1
  16. package/dist/analysis/format-number.d.ts +23 -0
  17. package/dist/analysis/format-number.d.ts.map +1 -0
  18. package/dist/analysis/gating.d.ts +39 -2
  19. package/dist/analysis/gating.d.ts.map +1 -1
  20. package/dist/analysis/rebuild.d.ts.map +1 -1
  21. package/dist/analysis/terminal.d.ts +5 -2
  22. package/dist/analysis/terminal.d.ts.map +1 -1
  23. package/dist/cli/index.cjs +10924 -8506
  24. package/dist/cli/index.cjs.map +3 -4
  25. package/dist/cli/index.js +10924 -8506
  26. package/dist/cli/index.js.map +3 -4
  27. package/dist/combinators/adjacency.d.ts +100 -0
  28. package/dist/combinators/adjacency.d.ts.map +1 -0
  29. package/dist/combinators/choice.d.ts.map +1 -1
  30. package/dist/combinators/completions.d.ts.map +1 -1
  31. package/dist/combinators/dispatch.d.ts +8 -1
  32. package/dist/combinators/dispatch.d.ts.map +1 -1
  33. package/dist/combinators/expect.d.ts.map +1 -1
  34. package/dist/combinators/first-set.d.ts.map +1 -1
  35. package/dist/combinators/gate.d.ts.map +1 -1
  36. package/dist/combinators/grammar.d.ts +22 -0
  37. package/dist/combinators/grammar.d.ts.map +1 -1
  38. package/dist/combinators/literal.d.ts +8 -0
  39. package/dist/combinators/literal.d.ts.map +1 -1
  40. package/dist/combinators/map.d.ts +0 -1
  41. package/dist/combinators/map.d.ts.map +1 -1
  42. package/dist/combinators/node.d.ts +13 -3
  43. package/dist/combinators/node.d.ts.map +1 -1
  44. package/dist/combinators/parser.d.ts +1 -1
  45. package/dist/combinators/parser.d.ts.map +1 -1
  46. package/dist/combinators/ref.d.ts.map +1 -1
  47. package/dist/combinators/regex.d.ts +1 -16
  48. package/dist/combinators/regex.d.ts.map +1 -1
  49. package/dist/combinators/repeat.d.ts +30 -2
  50. package/dist/combinators/repeat.d.ts.map +1 -1
  51. package/dist/combinators/scanTo.d.ts +71 -1
  52. package/dist/combinators/scanTo.d.ts.map +1 -1
  53. package/dist/combinators/sequence.d.ts.map +1 -1
  54. package/dist/combinators/token.d.ts.map +1 -1
  55. package/dist/combinators/trivia-boundary.d.ts +26 -0
  56. package/dist/combinators/trivia-boundary.d.ts.map +1 -0
  57. package/dist/combinators/trivia-skip.d.ts +45 -1
  58. package/dist/combinators/trivia-skip.d.ts.map +1 -1
  59. package/dist/combinators/withCtx.d.ts.map +1 -1
  60. package/dist/compiler/compile-linkable-table.d.ts +104 -0
  61. package/dist/compiler/compile-linkable-table.d.ts.map +1 -0
  62. package/dist/compiler/degradation.d.ts +0 -2
  63. package/dist/compiler/degradation.d.ts.map +1 -1
  64. package/dist/compiler/fields.d.ts +13 -0
  65. package/dist/compiler/fields.d.ts.map +1 -1
  66. package/dist/compiler/grammar-coverage-ids.d.ts +5 -5
  67. package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
  68. package/dist/compiler/ir-serialize.d.ts.map +1 -1
  69. package/dist/compiler/linker.d.ts +47 -53
  70. package/dist/compiler/linker.d.ts.map +1 -1
  71. package/dist/compiler/token-alphabet.d.ts +81 -0
  72. package/dist/compiler/token-alphabet.d.ts.map +1 -0
  73. package/dist/compiler/token-dispatch.d.ts +95 -0
  74. package/dist/compiler/token-dispatch.d.ts.map +1 -0
  75. package/dist/compiler/token-scanner.d.ts +53 -0
  76. package/dist/compiler/token-scanner.d.ts.map +1 -0
  77. package/dist/compiler/value-usage.d.ts.map +1 -1
  78. package/dist/coverage.d.ts +1 -1
  79. package/dist/coverage.d.ts.map +1 -1
  80. package/dist/cst/capture-buffer.d.ts +35 -0
  81. package/dist/cst/capture-buffer.d.ts.map +1 -1
  82. package/dist/cst/reflection-attach.d.ts +8 -0
  83. package/dist/cst/reflection-attach.d.ts.map +1 -0
  84. package/dist/cst/reflection-symbols.d.ts +10 -0
  85. package/dist/cst/reflection-symbols.d.ts.map +1 -0
  86. package/dist/cst/reflection.d.ts +2 -4
  87. package/dist/cst/reflection.d.ts.map +1 -1
  88. package/dist/cst/root-trivia-scope.d.ts +26 -0
  89. package/dist/cst/root-trivia-scope.d.ts.map +1 -0
  90. package/dist/cst/trivia-charscan.d.ts +18 -0
  91. package/dist/cst/trivia-charscan.d.ts.map +1 -0
  92. package/dist/cst/trivia-kinds.d.ts +19 -0
  93. package/dist/cst/trivia-kinds.d.ts.map +1 -1
  94. package/dist/functional/doc.d.ts.map +1 -1
  95. package/dist/functional/run-tabled.d.ts +12 -0
  96. package/dist/functional/run-tabled.d.ts.map +1 -0
  97. package/dist/functional/run.d.ts +25 -42
  98. package/dist/functional/run.d.ts.map +1 -1
  99. package/dist/grammar-metadata.d.ts +3 -0
  100. package/dist/grammar-metadata.d.ts.map +1 -0
  101. package/dist/index.cjs +9290 -7181
  102. package/dist/index.cjs.map +3 -4
  103. package/dist/index.d.ts +18 -9
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +9300 -7191
  106. package/dist/index.js.map +3 -4
  107. package/dist/language-service/index.cjs +313 -121
  108. package/dist/language-service/index.cjs.map +3 -4
  109. package/dist/language-service/index.js +313 -121
  110. package/dist/language-service/index.js.map +3 -4
  111. package/dist/oracle/index.cjs.map +0 -1
  112. package/dist/oracle/index.js.map +0 -1
  113. package/dist/parse-context.d.ts +49 -0
  114. package/dist/parse-context.d.ts.map +1 -0
  115. package/dist/plugin/evaluator.d.ts +40 -1
  116. package/dist/plugin/evaluator.d.ts.map +1 -1
  117. package/dist/plugin/index.cjs +11175 -9534
  118. package/dist/plugin/index.cjs.map +3 -4
  119. package/dist/plugin/index.d.ts.map +1 -1
  120. package/dist/plugin/index.js +11175 -9534
  121. package/dist/plugin/index.js.map +3 -4
  122. package/dist/regex/classes.d.ts +28 -0
  123. package/dist/regex/classes.d.ts.map +1 -1
  124. package/dist/regex/first-set.d.ts +14 -0
  125. package/dist/regex/first-set.d.ts.map +1 -1
  126. package/dist/run/index.cjs +136 -51
  127. package/dist/run/index.cjs.map +2 -3
  128. package/dist/run/index.js +136 -51
  129. package/dist/run/index.js.map +2 -3
  130. package/dist/spec/index.cjs +2934 -1611
  131. package/dist/spec/index.cjs.map +3 -4
  132. package/dist/spec/index.js +2934 -1611
  133. package/dist/spec/index.js.map +3 -4
  134. package/dist/spec/model.d.ts +8 -0
  135. package/dist/spec/model.d.ts.map +1 -1
  136. package/dist/table/assemble.d.ts +307 -0
  137. package/dist/table/assemble.d.ts.map +1 -0
  138. package/dist/table/cell.d.ts +42 -0
  139. package/dist/table/cell.d.ts.map +1 -0
  140. package/dist/table/child-slots.d.ts +30 -0
  141. package/dist/table/child-slots.d.ts.map +1 -0
  142. package/dist/table/compile-rule-map.d.ts +136 -0
  143. package/dist/table/compile-rule-map.d.ts.map +1 -0
  144. package/dist/table/compile.d.ts +56 -0
  145. package/dist/table/compile.d.ts.map +1 -0
  146. package/dist/table/duplication-hook.d.ts +32 -0
  147. package/dist/table/duplication-hook.d.ts.map +1 -0
  148. package/dist/table/emit-assembly.d.ts +139 -0
  149. package/dist/table/emit-assembly.d.ts.map +1 -0
  150. package/dist/table/emit.d.ts +113 -0
  151. package/dist/table/emit.d.ts.map +1 -0
  152. package/dist/table/encode-baseline.d.ts +16 -0
  153. package/dist/table/encode-baseline.d.ts.map +1 -0
  154. package/dist/table/encode.d.ts +71 -0
  155. package/dist/table/encode.d.ts.map +1 -0
  156. package/dist/table/exec-baseline.d.ts +9 -0
  157. package/dist/table/exec-baseline.d.ts.map +1 -0
  158. package/dist/table/exec.d.ts +72 -0
  159. package/dist/table/exec.d.ts.map +1 -0
  160. package/dist/table/fold.d.ts +25 -0
  161. package/dist/table/fold.d.ts.map +1 -0
  162. package/dist/table/index.cjs +11463 -0
  163. package/dist/table/index.cjs.map +6 -0
  164. package/dist/table/index.d.ts +100 -0
  165. package/dist/table/index.d.ts.map +1 -0
  166. package/dist/table/index.js +11440 -0
  167. package/dist/table/index.js.map +6 -0
  168. package/dist/table/inspect.d.ts +17 -0
  169. package/dist/table/inspect.d.ts.map +1 -0
  170. package/dist/table/ops.d.ts +453 -0
  171. package/dist/table/ops.d.ts.map +1 -0
  172. package/dist/table/program.d.ts +496 -0
  173. package/dist/table/program.d.ts.map +1 -0
  174. package/dist/table/run-support.d.ts +38 -0
  175. package/dist/table/run-support.d.ts.map +1 -0
  176. package/dist/{compiler/scannable-run.d.ts → table/scan-shapes.d.ts} +14 -13
  177. package/dist/table/scan-shapes.d.ts.map +1 -0
  178. package/dist/table/site-labels.d.ts +58 -0
  179. package/dist/table/site-labels.d.ts.map +1 -0
  180. package/dist/table/stamp.d.ts +70 -0
  181. package/dist/table/stamp.d.ts.map +1 -0
  182. package/dist/types.d.ts +80 -24
  183. package/dist/types.d.ts.map +1 -1
  184. package/dist/version.d.ts +1 -1
  185. package/package.json +26 -2
  186. package/src/analysis/choice-cost-gate.ts +359 -0
  187. package/src/analysis/choice-cost-render.ts +293 -0
  188. package/src/analysis/choice-cost.ts +1008 -0
  189. package/src/analysis/commitment.ts +557 -0
  190. package/src/analysis/corpus.ts +130 -0
  191. package/src/analysis/diagnose-render.ts +625 -0
  192. package/src/analysis/diagnose.ts +317 -0
  193. package/src/analysis/diagnostics.ts +33 -0
  194. package/src/analysis/duplication.ts +1955 -0
  195. package/src/analysis/fix-render.ts +217 -0
  196. package/src/analysis/fix.ts +669 -0
  197. package/src/analysis/format-number.ts +31 -0
  198. package/src/analysis/gating.ts +792 -0
  199. package/src/analysis/grammar.ts +72 -0
  200. package/src/analysis/rebuild.ts +287 -0
  201. package/src/analysis/terminal.ts +336 -0
  202. package/src/cli/index.ts +413 -0
  203. package/src/combinators/adjacency.ts +171 -0
  204. package/src/combinators/attempt.ts +41 -0
  205. package/src/combinators/case-fold.ts +90 -0
  206. package/src/combinators/choice.ts +466 -0
  207. package/src/combinators/completions.ts +64 -0
  208. package/src/combinators/dispatch.ts +358 -0
  209. package/src/combinators/expect.ts +159 -0
  210. package/src/combinators/first-set.ts +348 -0
  211. package/src/combinators/gate.ts +50 -0
  212. package/src/combinators/grammar.ts +272 -0
  213. package/src/combinators/keywords.ts +145 -0
  214. package/src/combinators/literal.ts +102 -0
  215. package/src/combinators/map.ts +106 -0
  216. package/src/combinators/node.ts +345 -0
  217. package/src/combinators/not.ts +55 -0
  218. package/src/combinators/parser.ts +266 -0
  219. package/src/combinators/peek.ts +63 -0
  220. package/src/combinators/probe.ts +25 -0
  221. package/src/combinators/ref.ts +109 -0
  222. package/src/combinators/regex.ts +170 -0
  223. package/src/combinators/repeat.ts +561 -0
  224. package/src/combinators/scanTo.ts +393 -0
  225. package/src/combinators/sequence.ts +237 -0
  226. package/src/combinators/token.ts +120 -0
  227. package/src/combinators/trivia-boundary.ts +48 -0
  228. package/src/combinators/trivia-skip.ts +852 -0
  229. package/src/combinators/withCtx.ts +45 -0
  230. package/src/compiler/build-arity.ts +330 -0
  231. package/src/compiler/compile-linkable-table.ts +209 -0
  232. package/src/compiler/degradation.ts +266 -0
  233. package/src/compiler/fields.ts +191 -0
  234. package/src/compiler/grammar-coverage-ids.ts +129 -0
  235. package/src/compiler/ir-serialize.ts +615 -0
  236. package/src/compiler/linker.ts +954 -0
  237. package/src/compiler/token-alphabet.ts +209 -0
  238. package/src/compiler/token-dispatch.ts +353 -0
  239. package/src/compiler/token-scanner.ts +318 -0
  240. package/src/compiler/value-usage.ts +147 -0
  241. package/src/coverage.ts +383 -0
  242. package/src/cst/capture-buffer.ts +297 -0
  243. package/src/cst/host-mode.ts +69 -0
  244. package/src/cst/reflection-attach.ts +11 -0
  245. package/src/cst/reflection-symbols.ts +9 -0
  246. package/src/cst/reflection.ts +149 -0
  247. package/src/cst/relative-spans.ts +207 -0
  248. package/src/cst/root-trivia-scope.ts +30 -0
  249. package/src/cst/trivia-charscan.ts +312 -0
  250. package/src/cst/trivia-entries.ts +285 -0
  251. package/src/cst/trivia-index.ts +131 -0
  252. package/src/cst/trivia-kinds.ts +358 -0
  253. package/src/cst/types.ts +76 -0
  254. package/src/cst/walk.ts +101 -0
  255. package/src/functional/doc.ts +959 -0
  256. package/src/functional/run-tabled.ts +153 -0
  257. package/src/functional/run.ts +558 -0
  258. package/src/grammar-metadata.ts +2 -0
  259. package/src/index.ts +189 -0
  260. package/src/language-service/index.ts +221 -0
  261. package/src/line-index.ts +131 -0
  262. package/src/oracle/digest.ts +620 -0
  263. package/src/oracle/index.ts +55 -0
  264. package/src/parse-context.ts +91 -0
  265. package/src/plugin/direct-builder-static.ts +102 -0
  266. package/src/plugin/evaluator.ts +1505 -0
  267. package/src/plugin/free-identifiers.ts +338 -0
  268. package/src/plugin/index.ts +2281 -0
  269. package/src/plugin/reducer-resolver.ts +606 -0
  270. package/src/recovery/scan.ts +155 -0
  271. package/src/regex/classes.ts +183 -0
  272. package/src/regex/first-set.ts +316 -0
  273. package/src/run/index.ts +31 -0
  274. package/src/spec/ebnf.ts +128 -0
  275. package/src/spec/index.ts +54 -0
  276. package/src/spec/model.ts +636 -0
  277. package/src/spec/railroad-lib.ts +559 -0
  278. package/src/spec/railroad.ts +256 -0
  279. package/src/table/assemble.ts +3006 -0
  280. package/src/table/cell.ts +42 -0
  281. package/src/table/child-slots.ts +113 -0
  282. package/src/table/compile-rule-map.ts +336 -0
  283. package/src/table/compile.ts +244 -0
  284. package/src/table/duplication-hook.ts +89 -0
  285. package/src/table/emit-assembly.ts +1777 -0
  286. package/src/table/emit.ts +429 -0
  287. package/src/table/encode-baseline.ts +340 -0
  288. package/src/table/encode.ts +1569 -0
  289. package/src/table/exec-baseline.ts +537 -0
  290. package/src/table/exec.ts +1680 -0
  291. package/src/table/fold.ts +38 -0
  292. package/src/table/index.ts +108 -0
  293. package/src/table/inspect.ts +52 -0
  294. package/src/table/ops.ts +467 -0
  295. package/src/table/program.ts +876 -0
  296. package/src/table/run-support.ts +66 -0
  297. package/src/table/scan-shapes.ts +1578 -0
  298. package/src/table/site-labels.ts +228 -0
  299. package/src/table/stamp.ts +229 -0
  300. package/src/types.ts +563 -0
  301. package/src/version.ts +16 -0
  302. package/dist/compiler/codegen.d.ts +0 -334
  303. package/dist/compiler/codegen.d.ts.map +0 -1
  304. package/dist/compiler/inline-build.d.ts +0 -11
  305. package/dist/compiler/inline-build.d.ts.map +0 -1
  306. package/dist/compiler/inline-callback.d.ts +0 -21
  307. package/dist/compiler/inline-callback.d.ts.map +0 -1
  308. package/dist/compiler/line-index.d.ts +0 -2
  309. package/dist/compiler/line-index.d.ts.map +0 -1
  310. package/dist/compiler/module-hoist.d.ts +0 -106
  311. package/dist/compiler/module-hoist.d.ts.map +0 -1
  312. package/dist/compiler/scannable-run.d.ts.map +0 -1
  313. package/dist/compiler/scannable-terminal.d.ts +0 -23
  314. package/dist/compiler/scannable-terminal.d.ts.map +0 -1
  315. package/dist/compiler/trivia-fast-path.d.ts +0 -38
  316. package/dist/compiler/trivia-fast-path.d.ts.map +0 -1
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The case-fold CLASS of a code point under ECMAScript's NON-Unicode `/i`
3
+ * matching — the exact relation `keywords({ caseInsensitive: true })` compiles to.
4
+ *
5
+ * A first-set drives O(1) `choice` dispatch, so it MUST be a superset of every
6
+ * character the combinator can really start with. For a case-insensitive matcher
7
+ * that means: for every input char X that the matcher would accept as the first
8
+ * char, X is in the set. Under `/i` WITHOUT `u`, two chars match iff they share a
9
+ * canonical form, and that relation is NOT simply `{c, toUpperCase(c),
10
+ * toLowerCase(c)}` — 67 BMP code points sit in classes those three miss:
11
+ *
12
+ * σ (U+03C3) ↔ Σ (U+03A3) ↔ ς (U+03C2) final sigma is in the class but is
13
+ * neither the upper- nor lowercase of σ
14
+ * µ (U+00B5) ↔ μ (U+03BC) ↔ Μ (U+039C) micro sign folds into Greek mu
15
+ * DŽ (U+01C4) ↔ Dž (U+01C5) ↔ dž (U+01C6) titlecase digraphs (also LJLjljNJNjnjDZDzdz)
16
+ * ͅ (U+0345) ↔ ι (U+03B9) ↔ ι (U+1FBE) combining iota subscript
17
+ *
18
+ * so widening a first-set by upper/lower alone is UNSOUND for them.
19
+ *
20
+ * The canonical form below is ECMAScript's `Canonicalize` for non-Unicode mode:
21
+ * uppercase the char; if that is not exactly one char, or if it would cross the
22
+ * ASCII boundary INTO Basic Latin, keep the original. That last clause is why
23
+ * `/s/i` does not match `ſ` (U+017F) and `/k/i` does not match `K` (U+212A) —
24
+ * cross-boundary folds are refused in BOTH directions, which is what keeps an
25
+ * ASCII first-set tight and sound for the common all-ASCII keyword set.
26
+ *
27
+ * The classes are derived from the RUNNING engine's own `toUpperCase`, not a
28
+ * frozen table, so they cannot drift from the regex engine that will do the
29
+ * matching. Verified exhaustively against real `/i` behaviour across the BMP:
30
+ * 1141 multi-member classes, 2307 members, zero disagreements in either
31
+ * direction (see `test/unit/case-fold.test.ts`).
32
+ */
33
+
34
+ /** ECMAScript `Canonicalize` for non-Unicode `/i`. */
35
+ function canonical(ch: string): string {
36
+ const u = ch.toUpperCase()
37
+ // A fold that expands (`ß` → `SS`) is not a single-char fold: no canonicalization.
38
+ if (u.length !== 1) return ch
39
+ // Refuse a fold that would move a non-ASCII char INTO Basic Latin (`ſ` → `S`).
40
+ if (ch.codePointAt(0)! >= 128 && u.codePointAt(0)! < 128) return ch
41
+ return u
42
+ }
43
+
44
+ /**
45
+ * Lazily-built reverse index over the non-ASCII BMP, keyed by canonical form.
46
+ * Only classes with more than one member are kept (a singleton class needs no
47
+ * widening), which is ~2.3k entries rather than 65k. The ~12ms scan is paid ONCE
48
+ * per process and only when a non-ASCII case-insensitive first char actually
49
+ * appears — the all-ASCII path below never touches it.
50
+ */
51
+ let foldIndex: Map<number, readonly number[]> | null = null
52
+
53
+ function buildFoldIndex(): Map<number, readonly number[]> {
54
+ const byCanonical = new Map<string, number[]>()
55
+ for (let cp = 0; cp < 0x10000; cp++) {
56
+ // Lone surrogates have no case and never canonicalize with anything.
57
+ if (cp >= 0xd800 && cp <= 0xdfff) continue
58
+ const key = canonical(String.fromCharCode(cp))
59
+ const bucket = byCanonical.get(key)
60
+ if (bucket) bucket.push(cp)
61
+ else byCanonical.set(key, [cp])
62
+ }
63
+ const index = new Map<number, readonly number[]>()
64
+ for (const members of byCanonical.values()) {
65
+ if (members.length < 2) continue
66
+ for (const cp of members) index.set(cp, members)
67
+ }
68
+ return index
69
+ }
70
+
71
+ /**
72
+ * Every code point that a non-Unicode `/i` matcher treats as equal to `cp`,
73
+ * INCLUDING `cp` itself. Union these into a first-set to keep case-insensitive
74
+ * dispatch sound.
75
+ */
76
+ export function caseFoldVariants(cp: number): readonly number[] {
77
+ // Basic Latin: the fold class is exactly the a↔A twin, no index needed. (The
78
+ // cross-boundary refusal above is what guarantees nothing outside ASCII joins
79
+ // an ASCII char's class, so this fast path is exact, not an approximation.)
80
+ if (cp < 128) {
81
+ if (cp >= 0x41 && cp <= 0x5a) return [cp, cp + 32]
82
+ if (cp >= 0x61 && cp <= 0x7a) return [cp, cp - 32]
83
+ return [cp]
84
+ }
85
+ // Above the BMP there is nothing to fold: in non-Unicode mode the pattern is
86
+ // matched as UTF-16 code units, and no surrogate has a case mapping.
87
+ if (cp > 0xffff) return [cp]
88
+ foldIndex ??= buildFoldIndex()
89
+ return foldIndex.get(cp) ?? [cp]
90
+ }
@@ -0,0 +1,466 @@
1
+ import type {
2
+ Combinator, ParseContext, ParseResult, ParserMeta, FirstSet,
3
+ AutoNotCheck, CharRange, ChoiceStrategy, GatedArm,
4
+ } from '../types.ts'
5
+ import { union, intersects, matchesEmpty } from './first-set.ts'
6
+ import { deriveExpected } from './expect.ts'
7
+ import { rollbackTrivia, saveTriviaMark } from './trivia-skip.ts'
8
+
9
+ type ArmParser<T> = T extends GatedArm<infer U> ? Combinator<U> : T extends Combinator<infer U> ? Combinator<U> : never
10
+ type UnionArms<T extends (Combinator<unknown> | GatedArm<unknown>)[]> = {
11
+ [K in keyof T]: ArmParser<T[K]>
12
+ }[number] extends Combinator<infer U> ? U : never
13
+
14
+ export function choice<T extends [Combinator<unknown> | GatedArm<unknown>, ...(Combinator<unknown> | GatedArm<unknown>)[]]>(
15
+ ...args: T
16
+ ): Combinator<UnionArms<T>> {
17
+ // Unwrap gated arms into (parser, gate) pairs
18
+ const parsers = args.map(a => ('gate' in a ? a.combinator : a)) as Combinator<unknown>[]
19
+ const gates = args.map(a => ('gate' in a ? a.gate : null)) as (((state: unknown) => boolean) | null)[]
20
+
21
+ const hasGates = gates.some(g => g !== null)
22
+
23
+ // O(1) first-char dispatch is sound when arms are pairwise-disjoint in their
24
+ // first char AND no arm can match empty. Non-nullability is what makes a GATED
25
+ // arm keep its dispatch slot: a gated arm is normally *skipped* so a later arm
26
+ // can retry the same position — but if every arm is non-nullable and first-sets
27
+ // are disjoint, no later arm can match this first char, so "skip the gate then
28
+ // retry" is exactly "dispatch to this arm, check its gate, and fail the choice
29
+ // if the gate is false". Every OTHER first char dispatches as before and never
30
+ // touches the gate. A nullable arm matches at ANY position (zero-width), so
31
+ // first-char dispatch can't represent it → such choices stay on firstMatch.
32
+ // (Requiring non-nullability universally also tightens the ungated path, which
33
+ // was already unsound for a nullable-but-first-set-disjoint arm; in practice no
34
+ // ungated disjoint choice has a nullable arm, so codegen output is unchanged.)
35
+ const disjoint = areDisjoint(parsers.map(p => p._meta.firstSet))
36
+ && parsers.every(p => !matchesEmpty(p))
37
+
38
+ let combined: FirstSet = { kind: 'empty' }
39
+ for (const p of parsers) combined = union(combined, p._meta.firstSet)
40
+
41
+ const meta: ParserMeta = {
42
+ firstSet: combined,
43
+ canMatchNewline: parsers.some(p => p._meta.canMatchNewline),
44
+ isTrivia: false,
45
+ disjoint,
46
+ }
47
+
48
+ // A disjoint choice dispatches by first char (gated arms check their gate inside
49
+ // the dispatched branch); a NON-disjoint gated choice falls to firstMatch — the
50
+ // greedy/longest-first strategies are incompatible with per-arm predicates.
51
+ const strategy = (disjoint || hasGates) ? null : detectStrategy(parsers)
52
+ // `sharedPrefix` is a `firstMatch` specialization — the interpreter runs it via
53
+ // the firstMatch loop below, and codegen falls back to firstMatch under
54
+ // coverage/recovery/linkable — so it needs the same auto-not table computed.
55
+ const autoNot = (!disjoint && !hasGates && (strategy?.tag === 'firstMatch' || strategy?.tag === 'sharedPrefix'))
56
+ ? computeAutoNot(parsers)
57
+ : parsers.map(() => null)
58
+
59
+ // Runtime state for each strategy (built once, reused on every parse call):
60
+ let greedyLitMap: Map<string, number> | null = null
61
+ let sortedParsers: Combinator<unknown>[] | null = null
62
+ const asciiDispatch = disjoint ? buildAsciiDispatch(parsers) : null
63
+
64
+ if (strategy?.tag === 'greedyClassify') {
65
+ greedyLitMap = new Map()
66
+ for (let i = 0; i < parsers.length; i++) {
67
+ if (i === strategy.superIndex) continue
68
+ const litVal = getCoreLiteralValue(parsers[i]!)
69
+ if (litVal !== null) greedyLitMap.set(litVal, i)
70
+ }
71
+ } else if (strategy?.tag === 'literalsLongestFirst') {
72
+ sortedParsers = strategy.sortedIndices.map(i => parsers[i]!)
73
+ }
74
+
75
+ return {
76
+ _tag: 'choice',
77
+ _meta: meta,
78
+ _def: {
79
+ tag: 'choice',
80
+ parsers,
81
+ gates,
82
+ disjoint,
83
+ strategy: strategy ?? { tag: 'firstMatch' },
84
+ autoNot,
85
+ },
86
+ parse(input: string, pos: number, ctx: ParseContext): ParseResult<UnionArms<T>> {
87
+ const expected: string[] = []
88
+
89
+ // ── Disjoint: O(1) first-char dispatch (arms may be gated) ────────────
90
+ //
91
+ // EOF IS A DISPATCH MISS, not a reason to leave the disjoint path. Every arm
92
+ // of a disjoint choice is non-nullable (that is a precondition of `disjoint`
93
+ // above), so at EOF no arm can match and the answer is the same "nothing
94
+ // could have started here" the in-bounds miss below gives. Falling through to
95
+ // firstMatch instead made this position the ONE place a gated-off arm was
96
+ // dropped from the report — firstMatch `continue`s past it and contributes
97
+ // nothing — while the in-bounds miss runs `parsers.flatMap` ignoring gates and
98
+ // DOES name it. Same gate state, same "no arm can match", two different
99
+ // answers; codegen and both table drivers only ever gave the union. Accept and
100
+ // reject are untouched: every arm fails at EOF on either path.
101
+ if (disjoint) {
102
+ const code = pos < input.length ? input.codePointAt(pos)! : -1
103
+ let idx = code >= 0 && code < 128 ? asciiDispatch![code]! : -1
104
+ if (idx < 0 && code >= 0) {
105
+ for (let i = 0; i < parsers.length; i++) {
106
+ if (inFirstSet(code, parsers[i]!._meta.firstSet)) { idx = i; break }
107
+ }
108
+ }
109
+ if (idx >= 0) {
110
+ const gate = gates[idx]
111
+ if (gate && !gate(ctx.state)) {
112
+ // Gate blocks this arm. Disjointness + non-nullable arms guarantee no
113
+ // OTHER arm can match this first char, so skip-and-retry is exactly
114
+ // fail-the-choice — we must not fall through to another arm.
115
+ return { ok: false, expected: deriveExpected(parsers[idx]!), span: { start: pos, end: pos } }
116
+ }
117
+ const result = parsers[idx]!.parse(input, pos, ctx)
118
+ if (result.ok) return result as ParseResult<UnionArms<T>>
119
+ expected.push(...result.expected)
120
+ if (result.committed) return { ok: false, expected, span: { start: pos, end: pos }, committed: true }
121
+ return { ok: false, expected, span: { start: pos, end: pos } }
122
+ }
123
+ return {
124
+ ok: false,
125
+ expected: parsers.flatMap(p => {
126
+ const r = p.parse(input, pos, ctx)
127
+ return r.ok ? [] : r.expected
128
+ }),
129
+ span: { start: pos, end: pos },
130
+ }
131
+ }
132
+
133
+ // ── greedyClassify: run one regex, classify by string equality ─────────
134
+ // One parse call total. No backtracking.
135
+ if (strategy?.tag === 'greedyClassify') {
136
+ const superResult = parsers[strategy.superIndex]!.parse(input, pos, ctx)
137
+ if (!superResult.ok) return superResult as ParseResult<UnionArms<T>>
138
+
139
+ const end = superResult.span.end
140
+ const litIdx = greedyLitMap!.get(input.slice(pos, end))
141
+ if (litIdx !== undefined) {
142
+ const litVal = getCoreLiteralValue(parsers[litIdx]!)!
143
+ const value = applyTransforms(parsers[litIdx]!, litVal, { start: pos, end })
144
+ return { ok: true, value: value as UnionArms<T>, span: { start: pos, end } }
145
+ }
146
+ return superResult as ParseResult<UnionArms<T>>
147
+ }
148
+
149
+ // ── literalsLongestFirst: sorted descending by length, no backtracking ─
150
+ if (strategy?.tag === 'literalsLongestFirst') {
151
+ for (const p of sortedParsers!) {
152
+ const r = p.parse(input, pos, ctx)
153
+ if (r.ok) return r as ParseResult<UnionArms<T>>
154
+ expected.push(...r.expected)
155
+ }
156
+ return { ok: false, expected, span: { start: pos, end: pos } }
157
+ }
158
+
159
+ // ── firstMatch (+ gated arms): try each arm in order, skipping gated-off arms ──
160
+ for (let i = 0; i < parsers.length; i++) {
161
+ if (gates[i] && !gates[i]!(ctx.state)) continue // gate blocks this arm
162
+ // Save leaf-array lengths so a failed/rejected arm can be rolled back.
163
+ const mark = saveTriviaMark(ctx)
164
+ const result = parsers[i]!.parse(input, pos, ctx)
165
+ if (!result.ok) {
166
+ rollbackTrivia(ctx, mark)
167
+ expected.push(...result.expected)
168
+ if (result.committed) return { ok: false, expected, span: result.span, committed: true }
169
+ continue
170
+ }
171
+ const checks = autoNot[i]
172
+ if (checks && autoNotFires(input, result.span.end, checks)) {
173
+ rollbackTrivia(ctx, mark)
174
+ continue
175
+ }
176
+ return result as ParseResult<UnionArms<T>>
177
+ }
178
+ return { ok: false, expected, span: { start: pos, end: pos } }
179
+ },
180
+ }
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Strategy detection
185
+ // ---------------------------------------------------------------------------
186
+
187
+ function detectStrategy(parsers: Combinator<unknown>[]): ChoiceStrategy {
188
+ // greedyClassify: exactly one regex arm whose regex matches every literal arm's
189
+ // value exactly (and can potentially match more). All other arms must be literals.
190
+ const regexIndices: number[] = []
191
+ const literalIndices: number[] = []
192
+ for (let i = 0; i < parsers.length; i++) {
193
+ if (getCoreRegexDef(parsers[i]!) !== null) regexIndices.push(i)
194
+ else if (getCoreLiteralValue(parsers[i]!) !== null) literalIndices.push(i)
195
+ }
196
+
197
+ if (
198
+ regexIndices.length === 1 &&
199
+ literalIndices.length === parsers.length - 1 &&
200
+ literalIndices.length > 0
201
+ ) {
202
+ const superIndex = regexIndices[0]!
203
+ const regexDef = getCoreRegexDef(parsers[superIndex]!)!
204
+ const flags = 'y' + regexDef.flags.replace(/[gy]/g, '')
205
+ const re = new RegExp(regexDef.source, flags)
206
+ const allSubsumed = literalIndices.every(i => {
207
+ const litVal = getCoreLiteralValue(parsers[i]!)!
208
+ re.lastIndex = 0
209
+ const m = re.exec(litVal)
210
+ return m !== null && m[0] === litVal
211
+ })
212
+ if (allSubsumed) return { tag: 'greedyClassify', superIndex }
213
+ }
214
+
215
+ // literalsLongestFirst: every arm is a literal — try longest first, no backtracking
216
+ if (parsers.length === literalIndices.length) {
217
+ const sortedIndices = [...literalIndices].sort((a, b) =>
218
+ getCoreLiteralValue(parsers[b]!)!.length - getCoreLiteralValue(parsers[a]!)!.length
219
+ )
220
+ return { tag: 'literalsLongestFirst', sortedIndices }
221
+ }
222
+
223
+ // sharedPrefix: EVERY arm is a bare sequence beginning with the SAME concrete
224
+ // leading literal/regex — the compiler parses that left factor once. Detected
225
+ // last (its shape is disjoint from the literal-only strategies above).
226
+ const shared = detectSharedPrefix(parsers)
227
+ if (shared !== null) return shared
228
+
229
+ return { tag: 'firstMatch' }
230
+ }
231
+
232
+ /**
233
+ * A stable structural key for a bare leading terminal (concrete literal/regex),
234
+ * or `null` when the term is NOT a shareable concrete prefix. Deliberately
235
+ * conservative: only an UNWRAPPED literal (case-sensitive) or regex qualifies —
236
+ * a transform/label/ref/node wrapper changes the value/capture shape and must NOT
237
+ * be left-factored (correctness first; the strategy is opt-in like greedyClassify).
238
+ */
239
+ function bareLeadingTermKey(term: Combinator<unknown>): string | null {
240
+ const d = term._def
241
+ if (d.tag === 'literal' && !d.caseInsensitive) return `L:${d.value}`
242
+ // `JSON.stringify` of the pair, not a delimiter join. `source` is arbitrary regex text
243
+ // and can contain any character, so a collision here would judge two structurally
244
+ // DIFFERENT regexes identical and left-factor arms that must not share a prefix — a
245
+ // correctness bug in the generated parser, not a missed optimization. A JSON array is
246
+ // injective by construction, so no argument about which delimiter cannot occur in a
247
+ // regex source is needed. It is also printable: this was a RAW 0x00 byte, which made
248
+ // the whole file binary to `git diff` and invisible to `grep -rn` (see
249
+ // `scripts/check-control-bytes.mjs`). Runs once per choice when its strategy is chosen,
250
+ // never per match.
251
+ if (d.tag === 'regex') return `R:${JSON.stringify([d.source, d.flags])}`
252
+ // Two lead shapes look eligible and are deliberately NOT. Both were tried and
253
+ // measured; see test/unit/routed-fallback.test.ts, which pins the exclusions.
254
+ //
255
+ // `routed()` — a bare routed() IS a single-leaf terminal (it reads the
256
+ // dispatch-consumed token, pushes exactly ONE leaf, skips no trivia, runs no
257
+ // sub-parse), so it is replay-safe and admitting it here is correct. It is
258
+ // excluded because it does not PAY. The strategy trades a duplicated lead scan for
259
+ // a prescan plus a prefix-matched flag; routed()'s emission is a context read and
260
+ // one comparison, so there is nothing worth factoring out. Measured on
261
+ // `dispatch(name, when(k, choice(sequence(routed(), tail_i)...)))`, emitted bytes
262
+ // with the strategy MINUS without it: +242 (2 arms), +260 (4), +296 (8) — it costs
263
+ // more at every width and never crosses over. The same measurement on the
264
+ // regex-lead shape the strategy was built for: -468 (2 arms), -1597 (4), -3846 (8).
265
+ // A routed lead also shares only the ROUTED token; when the expensive shared work
266
+ // is the next term (a prelude), that term is what needs factoring, not this one.
267
+ //
268
+ // `routed(fallback)` — not a leaf at all: on the fallback path it runs a whole
269
+ // sub-parse, so replaying it as a once-recognized leaf would be wrong, and two
270
+ // routed() with DIFFERENT fallbacks are not interchangeable to begin with.
271
+ //
272
+ // A `lazy` ref lead — excluded for correctness, not for want of a key. The
273
+ // once-only prescan runs with `ctx.capturing = false`, which suppresses capture
274
+ // only for code emitted HERE (emitLeafCapture, codegen.ts). A ref compiles to a
275
+ // call into a function body generated under its OWN ctx (emitLazy), so its
276
+ // captures and trivia writes would happen during the prescan and then AGAIN in
277
+ // each arm. Replaying a ref needs recorded-and-spliced capture state, not the
278
+ // variable reuse a terminal replay gets away with.
279
+ return null
280
+ }
281
+
282
+ /**
283
+ * Detect the pure single-group shared-prefix shape: at least two arms, EVERY arm
284
+ * a bare `sequence` with ≥2 terms, and every arm's FIRST term the same concrete
285
+ * literal/regex (identical source/flags or literal string). Returns the plan with
286
+ * a representative prefix combinator (the first arm's leading term — all members'
287
+ * leading terms are structurally identical, so any one emits identical code),
288
+ * else `null`. Mixed / multi-group / wrapped shapes are conservatively skipped and
289
+ * fall through to `firstMatch`.
290
+ */
291
+ function detectSharedPrefix(parsers: Combinator<unknown>[]): ChoiceStrategy | null {
292
+ if (parsers.length < 2) return null
293
+ let key: string | null = null
294
+ let prefix: Combinator<unknown> | null = null
295
+ const members: number[] = []
296
+ for (let i = 0; i < parsers.length; i++) {
297
+ const term0 = leadingTermOfArm(parsers[i]!)
298
+ if (term0 === null) return null
299
+ const k = bareLeadingTermKey(term0)
300
+ if (k === null) return null
301
+ if (key === null) { key = k; prefix = term0 }
302
+ else if (k !== key) return null
303
+ members.push(i)
304
+ }
305
+ if (prefix === null || members.length < 2) return null
306
+ return { tag: 'sharedPrefix', prefix, members }
307
+ }
308
+
309
+ /**
310
+ * Peel wrappers that neither consume input nor skip trivia before their inner
311
+ * sequence -- `node`, `parser`/`grammar`, `transform`, `label` -- down to the core
312
+ * `sequence` and return its FIRST term (the candidate shared prefix), or null when
313
+ * the arm is not a wrapped-or-bare sequence with >=2 terms. Because none of these
314
+ * wrappers advance the position before the sequence's first term, the term is parsed
315
+ * at the arm's entry position in every arm, so the once-recognized prefix can be
316
+ * replayed there. `attempt`/`optional`/`many`/`choice`/etc. are NOT peeled, so such
317
+ * arms are conservatively excluded.
318
+ */
319
+ export function leadingTermOfArm(arm: Combinator<unknown>): Combinator<unknown> | null {
320
+ let d = arm._def
321
+ for (;;) {
322
+ if (d.tag === 'node' || d.tag === 'grammar' || d.tag === 'transform' || d.tag === 'label') {
323
+ d = (d as { parser: Combinator<unknown> }).parser._def
324
+ continue
325
+ }
326
+ break
327
+ }
328
+ if (d.tag !== 'sequence' || d.parsers.length < 2) return null
329
+ return d.parsers[0]!
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Auto-not analysis (firstMatch fallback only)
334
+ // ---------------------------------------------------------------------------
335
+
336
+ function computeAutoNot(parsers: Combinator<unknown>[]): (AutoNotCheck[] | null)[] {
337
+ return parsers.map((p, i) => {
338
+ const litVal = getCoreLiteralValue(p)
339
+ if (litVal === null) return null
340
+
341
+ const checks: AutoNotCheck[] = []
342
+ for (let j = i + 1; j < parsers.length; j++) {
343
+ const other = parsers[j]!
344
+ const otherLit = getCoreLiteralValue(other)
345
+ if (otherLit !== null && otherLit.startsWith(litVal) && otherLit.length > litVal.length) {
346
+ checks.push({ kind: 'startsWith', value: otherLit.slice(litVal.length) })
347
+ continue
348
+ }
349
+ const regexDef = getCoreRegexDef(other)
350
+ if (regexDef !== null) {
351
+ const contSet = continuationFirstSet(litVal, regexDef.source, regexDef.flags)
352
+ if (contSet !== null) checks.push({ kind: 'firstSet', set: contSet })
353
+ }
354
+ }
355
+ return checks.length > 0 ? checks : null
356
+ })
357
+ }
358
+
359
+ function autoNotFires(input: string, end: number, checks: AutoNotCheck[]): boolean {
360
+ for (const check of checks) {
361
+ if (check.kind === 'firstSet') {
362
+ const code = end < input.length ? (input.codePointAt(end) ?? -1) : -1
363
+ if (inFirstSet(code, check.set)) return true
364
+ } else {
365
+ if (input.startsWith(check.value, end)) return true
366
+ }
367
+ }
368
+ return false
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Helpers: unwrap transform/sequence wrappers
373
+ // ---------------------------------------------------------------------------
374
+
375
+ /** Walk transform wrappers to find an inner literal's string value. */
376
+ export function getCoreLiteralValue(p: Combinator<unknown>): string | null {
377
+ const def = p._def
378
+ if (def.tag === 'literal' && !def.caseInsensitive) return def.value
379
+ if (def.tag === 'transform') return getCoreLiteralValue(def.parser)
380
+ return null
381
+ }
382
+
383
+ /** Walk transform wrappers to find an inner regex's source/flags. */
384
+ export function getCoreRegexDef(p: Combinator<unknown>): { source: string; flags: string } | null {
385
+ const def = p._def
386
+ if (def.tag === 'regex') return { source: def.source, flags: def.flags }
387
+ if (def.tag === 'transform') return getCoreRegexDef(def.parser)
388
+ if (def.tag === 'label') return getCoreRegexDef(def.parser)
389
+ return null
390
+ }
391
+
392
+ /**
393
+ * Apply a parser's transform chain to an already-known value, without re-parsing.
394
+ * Used by greedyClassify to avoid a second parse call for the winning literal arm.
395
+ */
396
+ function applyTransforms(p: Combinator<unknown>, value: unknown, span: { start: number; end: number }): unknown {
397
+ const def = p._def
398
+ if (def.tag === 'transform') {
399
+ const inner = applyTransforms(def.parser, value, span)
400
+ return def.fn(inner, span)
401
+ }
402
+ return value
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Continuation first-set (for firstMatch auto-not analysis)
407
+ // ---------------------------------------------------------------------------
408
+
409
+ function continuationFirstSet(lit: string, source: string, flags: string): FirstSet | null {
410
+ const re = new RegExp(source, 'y' + flags.replace(/[gy]/g, ''))
411
+ re.lastIndex = 0
412
+ const base = re.exec(lit)
413
+ if (!base || base[0] !== lit) return null
414
+
415
+ const contCodes: number[] = []
416
+ for (let code = 1; code < 128; code++) {
417
+ re.lastIndex = 0
418
+ const m = re.exec(lit + String.fromCharCode(code))
419
+ if (m && m[0].length > lit.length) contCodes.push(code)
420
+ }
421
+ if (contCodes.length === 0) return null
422
+ return codesToFirstSet(contCodes)
423
+ }
424
+
425
+ function codesToFirstSet(codes: number[]): FirstSet {
426
+ codes.sort((a, b) => a - b)
427
+ const ranges: CharRange[] = []
428
+ let lo = codes[0]!, hi = codes[0]!
429
+ for (let i = 1; i < codes.length; i++) {
430
+ if (codes[i] === hi + 1) { hi = codes[i]! }
431
+ else { ranges.push({ lo, hi }); lo = hi = codes[i]! }
432
+ }
433
+ ranges.push({ lo, hi })
434
+ return { kind: 'ranges', ranges }
435
+ }
436
+
437
+ function inFirstSet(code: number, fs: FirstSet): boolean {
438
+ if (fs.kind === 'any') return true
439
+ if (fs.kind === 'empty') return false
440
+ for (const r of fs.ranges) if (code >= r.lo && code <= r.hi) return true
441
+ return false
442
+ }
443
+
444
+ function areDisjoint(sets: FirstSet[]): boolean {
445
+ if (sets.some(s => s.kind === 'any')) return false
446
+ for (let i = 0; i < sets.length; i++)
447
+ for (let j = i + 1; j < sets.length; j++)
448
+ if (intersects(sets[i]!, sets[j]!)) return false
449
+ return true
450
+ }
451
+
452
+ // ASCII first-char → arm INDEX (not the parser) so a gated arm's gate can be
453
+ // looked up (gates[idx]) at dispatch time. -1 means "no arm keys off this char".
454
+ function buildAsciiDispatch(parsers: Combinator<unknown>[]): number[] {
455
+ const table = Array<number>(128).fill(-1)
456
+ for (let i = 0; i < parsers.length; i++) {
457
+ const fs = parsers[i]!._meta.firstSet
458
+ if (fs.kind !== 'ranges') continue
459
+ for (const { lo, hi } of fs.ranges) {
460
+ for (let code = Math.max(0, lo); code <= Math.min(127, hi); code++) {
461
+ table[code] = i
462
+ }
463
+ }
464
+ }
465
+ return table
466
+ }
@@ -0,0 +1,64 @@
1
+ import type { Combinator, ParseFail, ParseContext, ParseResult } from '../types.ts'
2
+ import { REC } from '../recovery/scan.ts'
3
+ import { createParseContext } from '../parse-context.ts'
4
+
5
+ /** Anything `completionsAt` can probe: an interpreter combinator OR a `compile()`d
6
+ * grammar. A grammar compiled with `{ recovery: true }` records the probe on its fast
7
+ * path, so completions work on the published compiled artifact — no interpreter needed. */
8
+ type CompletionsTarget =
9
+ | Combinator<unknown>
10
+ | { parseWithContext(input: string, ctx: ParseContext, pos?: number): ParseResult<unknown> }
11
+
12
+ /**
13
+ * Returns the set of expected tokens at the given cursor offset.
14
+ * Useful for implementing IDE completions.
15
+ *
16
+ * Runs the parser on input truncated at `offset` with a probe that tracks the
17
+ * highest-position failure seen, even when sepBy/many backtrack past the cursor.
18
+ * Returns the expected tokens from that deepest failure.
19
+ *
20
+ * Works on the interpreter combinator OR a `compile(g, { recovery: true })` grammar
21
+ * (the probe is recorded on the compiled fast path). Returns an empty array when the
22
+ * input up to `offset` parses completely with no failures at or before the cursor.
23
+ *
24
+ * The returned strings use the same labels as ParseFail.expected:
25
+ * quoted literals like `"\"{\""` and regex patterns like `"/[0-9]+/"`.
26
+ */
27
+ export function completionsAt(
28
+ target: CompletionsTarget,
29
+ input: string,
30
+ offset: number,
31
+ options: { tolerant?: boolean } = {},
32
+ ): string[] {
33
+ const probe: { offset: number; best: ParseFail | null } = { offset, best: null }
34
+ // In tolerant mode, list recovery keeps the enclosing node parsing past a bad
35
+ // element so the failure at the cursor is actually recorded — the completion set
36
+ // is otherwise empty when a permissive top rule "succeeds" with an unconsumed tail.
37
+ const ctx = createParseContext()
38
+ ctx._probe = probe
39
+ if (options.tolerant) {
40
+ ctx._tolerant = true
41
+ ctx._rec = REC
42
+ }
43
+ const sliced = input.slice(0, offset)
44
+ const result = 'parseWithContext' in target
45
+ ? target.parseWithContext(sliced, ctx, 0)
46
+ : target.parse(sliced, 0, ctx)
47
+
48
+ // If the parser consumed everything up to offset successfully, there is nothing
49
+ // to complete — the input is already valid at this position. In tolerant mode the
50
+ // parse "succeeds" (list recovery swallowed the incomplete element at the cursor),
51
+ // so the meaningful expectation lives in the deepest failure the probe recorded
52
+ // while recovering: return that instead of an empty set.
53
+ if (result.ok) return options.tolerant ? (probe.best?.expected ?? []) : []
54
+
55
+ // Use whichever failure (probe or top-level) sits at the deeper position.
56
+ const best = deeperFail(probe.best, result)
57
+ return best?.expected ?? []
58
+ }
59
+
60
+ function deeperFail(a: ParseFail | null, b: ParseFail | null): ParseFail | null {
61
+ if (!a) return b
62
+ if (!b) return a
63
+ return a.span.start >= b.span.start ? a : b
64
+ }