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,413 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `parseman` — the diagnostics CLI.
4
+ *
5
+ * WHY A COMMAND AND NOT A FUNCTION
6
+ * --------------------------------
7
+ * 0.45 collapsed three functions a caller had to choose between into one
8
+ * `diagnoseGrammar()`. That removed the wrong choice but not the real defect: it is
9
+ * still something you must know exists, import, and write a script around. rustc and
10
+ * clippy are commands. `parseman diagnose examples/css/parser.ts` is found by typing
11
+ * `parseman --help`; a library export is found by reading the source.
12
+ *
13
+ * FOUR LAYERS, AND THIS IS THE FOURTH
14
+ * -----------------------------------
15
+ * Analysis produces data (`analysis/gating.ts`, `analysis/corpus.ts`), policy decides
16
+ * pass/fail (`analysis/diagnose.ts` — `ok`), rendering makes it readable
17
+ * (`analysis/*-render.ts`). This file is argument parsing and process wiring, and
18
+ * nothing else. It must not become where logic accretes: if a rule about what counts as
19
+ * a finding ever appears below, it is in the wrong file.
20
+ *
21
+ * EXIT CODES
22
+ * ----------
23
+ * 0 analysed, no blocking finding
24
+ * 1 analysed, blocking findings (the established `process.exit(d.ok ? 0 : 1)` contract)
25
+ * 2 COULD NOT ANALYSE — bad usage, unloadable grammar, unreadable corpus, I/O failure
26
+ *
27
+ * 2 is not a nicety. This repo's own history has the counter-example: `coverage.ts`
28
+ * reporting 100% covered over zero analysable input. A tool that cannot measure must not
29
+ * exit 0.
30
+ *
31
+ * STREAMS
32
+ * -------
33
+ * Output goes through the streams this process already owns. `--json` with no path
34
+ * writes the document to stdout and sends the human rendering to stderr, so stdout stays
35
+ * a single parseable document; `--json=<path>` writes the file and reports a real I/O
36
+ * failure with its path and reason. It never opens a second file description on stdout —
37
+ * `writeFileSync('/dev/stdout')` races the async writes `console.log` has already queued,
38
+ * which is exactly how 0.45's size probe exited 1 while printing a correct table.
39
+ *
40
+ * The compiler is reachable from here, and deliberately not from anywhere a library
41
+ * consumer imports: this is a separate bin entry with its own bundle, so a browser build
42
+ * of `parseman` costs nothing for its existence.
43
+ */
44
+ import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'
45
+ import { resolve, relative, join, extname } from 'node:path'
46
+ import { pathToFileURL } from 'node:url'
47
+ import { register } from 'node:module'
48
+ import { diagnoseGrammar, examinedNothing, type GrammarDiagnosis } from '../analysis/diagnose.ts'
49
+ import { renderDiagnosis } from '../analysis/diagnose-render.ts'
50
+ import { DEFAULT_WIDTH } from '../analysis/terminal.ts'
51
+ import { firstSetToString, choiceArms, peelToLeading } from '../analysis/gating.ts'
52
+ import { armFirstSets, measureChoiceCost, type ChoiceCorpusCost, type CorpusSample } from '../analysis/corpus.ts'
53
+ import { proposeFixes, applyFixEdits } from '../analysis/fix.ts'
54
+ import { renderFixReport } from '../analysis/fix-render.ts'
55
+ import type { Combinator, ParserDef } from '../types.ts'
56
+
57
+ const USAGE = `parseman — grammar diagnostics
58
+
59
+ parseman diagnose <grammar> [options] analyse a grammar; exit 1 on a blocking finding
60
+ parseman fix <grammar> [options] propose VERIFIED rewrites; preview by default
61
+
62
+ grammar
63
+ A module path. The default export is used, or --export <name>. A .ts module needs
64
+ \`tsx\` installed (parseman registers it automatically when it is resolvable).
65
+
66
+ options
67
+ --corpus <path> File or directory of sample inputs. On \`diagnose\` it adds the
68
+ measured second world; on \`fix\` it is REQUIRED — a rewrite with
69
+ no corpus cannot be verified and is never offered.
70
+ --ext <list> Corpus extensions to accept (default: every file). e.g. --ext .css
71
+ --export <name> Named export to analyse instead of the default export.
72
+ --accept <ids> Comma-separated choice ids accepted as intentionally ungated.
73
+ --source <path> Grammar source to locate edits in (default: the grammar path).
74
+ --apply \`fix\` only. Write the verified edits. Off by default.
75
+ --json[=<path>] Machine-readable report. With no path it goes to stdout and the
76
+ human rendering goes to stderr.
77
+ --limit <n> Findings to expand (default 20).
78
+ --width <n> Render width. Default: the terminal's when colouring, else 80.
79
+ --no-links Do not emit clickable file links (OSC-8). Some terminals show
80
+ the escape sequence as visible junk instead of a link.
81
+ --color/--no-color Force colour on/off. Default: on only when stdout is a TTY.
82
+ -h, --help This.
83
+
84
+ exit codes
85
+ 0 clean · 1 blocking findings · 2 could not analyse`
86
+
87
+ type Args = {
88
+ command: string | undefined
89
+ positional: string[]
90
+ flags: Map<string, string | true>
91
+ }
92
+
93
+ function parseArgs(argv: readonly string[]): Args {
94
+ const flags = new Map<string, string | true>()
95
+ const positional: string[] = []
96
+ for (let i = 0; i < argv.length; i++) {
97
+ const a = argv[i]!
98
+ if (a === '-h') { flags.set('help', true); continue }
99
+ if (!a.startsWith('--')) { positional.push(a); continue }
100
+ const eq = a.indexOf('=')
101
+ if (eq !== -1) { flags.set(a.slice(2, eq), a.slice(eq + 1)); continue }
102
+ const name = a.slice(2)
103
+ // Value-taking flags consume the next argument; boolean flags do not.
104
+ if (['corpus', 'export', 'accept', 'source', 'limit', 'ext', 'width'].includes(name) && i + 1 < argv.length && !argv[i + 1]!.startsWith('--')) {
105
+ flags.set(name, argv[++i]!)
106
+ }
107
+ else flags.set(name, true)
108
+ }
109
+ return { command: positional[0], positional: positional.slice(1), flags }
110
+ }
111
+
112
+ class CliError extends Error {}
113
+
114
+ /** Register the TS loader only when it is needed AND resolvable, so a JS-only user
115
+ * never pays for it and a TS user gets a sentence rather than a module-not-found. */
116
+ async function registerTsIfNeeded(path: string): Promise<void> {
117
+ if (!['.ts', '.mts', '.cts', '.tsx'].includes(extname(path))) return
118
+ try {
119
+ const api = await import('tsx/esm/api') as { register(): unknown }
120
+ api.register()
121
+ }
122
+ catch {
123
+ throw new CliError(
124
+ `${path} is TypeScript, and the \`tsx\` loader is not resolvable from here.\n`
125
+ + 'Install it (`pnpm add -D tsx`), or point at a built .js module.',
126
+ )
127
+ }
128
+ }
129
+
130
+ /**
131
+ * A module hook that drops `with { type: 'macro' }` from an import.
132
+ *
133
+ * A grammar written for the macro imports parseman as `import { … } from 'parseman'
134
+ * with { type: 'macro' }`. Node's default loader rejects any `type` it does not know, so
135
+ * `parseman diagnose examples/css/parser.ts` died with `TypeError: Import attribute "type" with
136
+ * value "macro" is not supported` on EVERY macro-authored grammar — that is, on the
137
+ * grammars this command exists to serve.
138
+ *
139
+ * Dropping the attribute degrades the macro import to a plain runtime import, which is
140
+ * exactly the shape the analysis wants: `rules()`/`composeLeaf()` then EXECUTE and
141
+ * produce real combinators instead of compiled functions. It is also what the macro's
142
+ * own vite plugin does with the attribute, and what the only working workaround (a
143
+ * vite server with the plugin left out) achieved the long way round.
144
+ *
145
+ * The attribute is dropped for `type: 'macro'` ONLY. `type: 'json'` and friends still
146
+ * reach the default loader and still mean what they mean.
147
+ *
148
+ * Source is not rewritten: the attribute is removed from the load/resolve CONTEXT, so
149
+ * nothing depends on parsing the importer's text.
150
+ */
151
+ const MACRO_ATTR_HOOK = `
152
+ const strip = (context) => {
153
+ const attrs = context && context.importAttributes
154
+ if (!attrs || attrs.type !== 'macro') return context
155
+ const rest = { ...attrs }
156
+ delete rest.type
157
+ return { ...context, importAttributes: rest }
158
+ }
159
+ export async function resolve(specifier, context, nextResolve) {
160
+ return nextResolve(specifier, strip(context))
161
+ }
162
+ export async function load(url, context, nextLoad) {
163
+ return nextLoad(url, strip(context))
164
+ }
165
+ `
166
+ let macroAttrRegistered = false
167
+ function registerMacroAttributeHook(): void {
168
+ if (macroAttrRegistered) return
169
+ macroAttrRegistered = true
170
+ register(`data:text/javascript,${encodeURIComponent(MACRO_ATTR_HOOK)}`)
171
+ }
172
+
173
+ async function loadGrammar(path: string, exportName: string | undefined): Promise<unknown> {
174
+ const abs = resolve(path)
175
+ try { statSync(abs) }
176
+ catch { throw new CliError(`no such grammar module: ${path}`) }
177
+ await registerTsIfNeeded(abs)
178
+ // AFTER the TS loader, so this hook is outermost and `nextLoad` still runs it.
179
+ registerMacroAttributeHook()
180
+ let mod: Record<string, unknown>
181
+ try { mod = await import(pathToFileURL(abs).href) as Record<string, unknown> }
182
+ catch (e) {
183
+ throw new CliError(`could not load ${path}\n ${e instanceof Error ? `${e.name}: ${e.message}` : String(e)}`)
184
+ }
185
+ if (exportName !== undefined) {
186
+ if (!(exportName in mod)) {
187
+ throw new CliError(`${path} has no export \`${exportName}\`. It exports: ${Object.keys(mod).join(', ') || '(nothing)'}`)
188
+ }
189
+ return mod[exportName]
190
+ }
191
+ if ('default' in mod) return mod.default
192
+ const names = Object.keys(mod)
193
+ if (names.length === 1) return mod[names[0]!]
194
+ throw new CliError(
195
+ `${path} has no default export, and ${names.length} named exports to choose between.\n`
196
+ + ` Pick one with --export: ${names.join(', ')}`,
197
+ )
198
+ }
199
+
200
+ function readCorpus(root: string | undefined, extFilter: string | undefined): CorpusSample[] {
201
+ if (root === undefined) return []
202
+ const abs = resolve(root)
203
+ const exts = extFilter?.split(',').map(e => e.trim()).filter(e => e !== '')
204
+ const files: string[] = []
205
+ const walk = (p: string): void => {
206
+ let st: ReturnType<typeof statSync>
207
+ try { st = statSync(p) }
208
+ catch (e) { throw new CliError(`cannot read corpus path ${p}: ${e instanceof Error ? e.message : String(e)}`) }
209
+ if (st.isDirectory()) { for (const e of readdirSync(p).sort()) walk(join(p, e)); return }
210
+ if (exts !== undefined && !exts.includes(extname(p))) return
211
+ files.push(p)
212
+ }
213
+ walk(abs)
214
+ if (files.length === 0) throw new CliError(`corpus ${root} contains no files${exts ? ` matching ${exts.join(',')}` : ''}`)
215
+ // Names are cwd-relative: an absolute path in a report breaks diffability.
216
+ return files.map(f => ({ name: relative(process.cwd(), f), text: readFileSync(f, 'utf8') }))
217
+ }
218
+
219
+ /** The entry combinator to analyse. A `rules()` map or `compose()` result has no single
220
+ * root to parse with, so `fix` needs the rule the caller parses with, named explicitly. */
221
+ function asCombinator(g: unknown): Combinator<unknown> | null {
222
+ return typeof g === 'object' && g !== null && '_def' in (g as object) ? g as Combinator<unknown> : null
223
+ }
224
+
225
+ const leadLabel = (raw: Combinator<unknown>): string => {
226
+ const arm = peelToLeading(raw)
227
+ const d = arm._def as ParserDef
228
+ switch (d.tag) {
229
+ case 'literal': return `literal('${d.value}')`
230
+ case 'regex': return `regex(/${d.source}/)`
231
+ case 'keywords': return d.words.length === 1 ? `word('${d.words[0]}')` : `keywords([${d.words.length}])`
232
+ case 'lazy': {
233
+ const own = (arm as { _ruleName?: string })._ruleName
234
+ if (own !== undefined) return own
235
+ // An unnamed ref is not a useful label; resolve it once and describe what it
236
+ // actually leads with, which is the thing the reader is looking for.
237
+ try {
238
+ const t = d.thunk()
239
+ return (t as { _ruleName?: string })._ruleName ?? `→ ${leadLabel(t)}`
240
+ }
241
+ catch { return 'ref(unbound)' }
242
+ }
243
+ default: return d.tag
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Quote a value for a POSIX shell, and ONLY when it needs it.
249
+ *
250
+ * A suggested command is only a suggestion if it can be pasted, and a path or an export
251
+ * name holding a space, a quote or a glob character silently becomes two arguments (or a
252
+ * different file) without this. Unquoted is kept for the ordinary case so the common
253
+ * rendering stays the one a reader would have typed; anything else takes single quotes,
254
+ * with an embedded `'` closed, escaped and reopened.
255
+ */
256
+ function sh(v: string): string {
257
+ return /^[\w@%+=:,./-]+$/.test(v) ? v : `'${v.replaceAll("'", `'\\''`)}'`
258
+ }
259
+
260
+ function writeJson(target: string | true, doc: unknown): void {
261
+ const text = `${JSON.stringify(doc, null, 2)}\n`
262
+ if (target === true) { process.stdout.write(text); return }
263
+ try { writeFileSync(target, text) }
264
+ catch (e) { throw new CliError(`could not write ${target}: ${e instanceof Error ? e.message : String(e)}`) }
265
+ }
266
+
267
+ async function main(argv: readonly string[]): Promise<number> {
268
+ const args = parseArgs(argv)
269
+ if (args.flags.has('help') || args.command === undefined || args.command === 'help') {
270
+ process.stdout.write(`${USAGE}\n`)
271
+ return args.command === undefined && !args.flags.has('help') ? 2 : 0
272
+ }
273
+ const color = args.flags.has('color') ? args.flags.get('color') !== 'false'
274
+ : args.flags.has('no-color') ? false
275
+ : process.stdout.isTTY === true && process.env.NO_COLOR === undefined
276
+ // Pinned off-TTY. A rendering whose width follows the terminal it happened to be piped
277
+ // from is not diffable, and `docs/samples/` is exactly such a capture.
278
+ const widthFlag = args.flags.get('width')
279
+ const width = typeof widthFlag === 'string' && Number(widthFlag) > 0
280
+ ? Number(widthFlag)
281
+ : (color && typeof process.stdout.columns === 'number' && process.stdout.columns > 0
282
+ ? process.stdout.columns
283
+ : DEFAULT_WIDTH)
284
+ const links = !args.flags.has('no-links')
285
+ const json = args.flags.get('json')
286
+ // JSON on stdout means stdout is a document; the human rendering moves to stderr so it
287
+ // stays one. Both go through streams this process already owns.
288
+ const human = (s: string): void => { (json === true ? process.stderr : process.stdout).write(`${s}\n`) }
289
+
290
+ const grammarPath = args.positional[0]
291
+ if (grammarPath === undefined) throw new CliError(`\`parseman ${args.command}\` needs a grammar module path.\n\n${USAGE}`)
292
+ const exportName = args.flags.get('export')
293
+ const grammar = await loadGrammar(grammarPath, typeof exportName === 'string' ? exportName : undefined)
294
+ const label = relative(process.cwd(), resolve(grammarPath)) || grammarPath
295
+ const acceptFlag = args.flags.get('accept')
296
+ const accept = typeof acceptFlag === 'string' ? acceptFlag.split(',').map(s => s.trim()).filter(s => s !== '') : undefined
297
+ const corpusFlag = args.flags.get('corpus')
298
+ const extFlag = args.flags.get('ext')
299
+ const corpus = readCorpus(typeof corpusFlag === 'string' ? corpusFlag : undefined, typeof extFlag === 'string' ? extFlag : undefined)
300
+
301
+ if (args.command === 'diagnose') {
302
+ const d: GrammarDiagnosis = diagnoseGrammar(grammar as Parameters<typeof diagnoseGrammar>[0], accept === undefined ? {} : { accept })
303
+ const cost = new Map<string, ChoiceCorpusCost>()
304
+ const sets = new Map<string, readonly string[]>()
305
+ const labels = new Map<string, readonly string[]>()
306
+ for (const c of d.gating.choices) {
307
+ const a = choiceArms(c)
308
+ if (a === undefined) continue
309
+ const fs = armFirstSets(a)
310
+ sets.set(c.id, fs.map(x => firstSetToString(x.firstSet)))
311
+ labels.set(c.id, a.map(x => leadLabel(x)))
312
+ if (corpus.length > 0) cost.set(c.id, measureChoiceCost(c, corpus, fs))
313
+ }
314
+ // The wrench may ONLY appear where a rewrite has actually been PROVED — applied,
315
+ // recompiled, corpus re-parsed, output identical. Without a corpus there is nothing
316
+ // to prove it against, so nothing is marked. Claiming fixability parseman has not
317
+ // demonstrated would destroy the one guarantee `fix` has.
318
+ const fixable = new Set<string>()
319
+ const root = asCombinator(grammar)
320
+ let fixCommand: string | undefined
321
+ if (root !== null && corpus.length > 0) {
322
+ const probe = proposeFixes(root, { corpus, ...(accept === undefined ? {} : { accept }) })
323
+ for (const f of probe.verified) fixable.add(f.id)
324
+ if (fixable.size > 0) {
325
+ // Every option that changed what was VERIFIED has to appear here, or the command
326
+ // reproduces a different run than the one that earned the wrench: `--ext` and
327
+ // `--corpus` decide the corpus, `--accept` decides the candidate set, `--export`
328
+ // decides the root. Values are shell-quoted so a path or an export name holding a
329
+ // space stays one argument when pasted.
330
+ const opt = (name: string, v: unknown): string => typeof v === 'string' ? ` --${name} ${sh(v)}` : ''
331
+ // A cwd-relative path can begin with `-` (`./--grammar.mjs` relativises to
332
+ // `--grammar.mjs`), and the pasted command would then read its own grammar path as
333
+ // an option. Quoting does not help — the shell strips the quotes and `parseArgs`
334
+ // still sees a leading `-`. `./` makes it a path again.
335
+ const commandPath = label.startsWith('-') ? `./${label}` : label
336
+ fixCommand = `parseman fix ${sh(commandPath)}`
337
+ + opt('export', exportName) + opt('corpus', corpusFlag)
338
+ + opt('ext', extFlag) + opt('accept', acceptFlag)
339
+ }
340
+ }
341
+ const limitFlag = args.flags.get('limit')
342
+ // `Number('abc')` is NaN, and the renderer's `shown >= limit` is then always false —
343
+ // the flag would be silently ignored and every site expanded. Say so instead.
344
+ const limit = typeof limitFlag === 'string' ? Number(limitFlag) : undefined
345
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 0)) {
346
+ throw new CliError(`\`--limit\` needs a non-negative integer; got \`${String(limitFlag)}\`.`)
347
+ }
348
+ human(renderDiagnosis(d, {
349
+ color, width, links, name: label, cost, armFirstSets: sets, armLabels: labels,
350
+ fixable,
351
+ ...(fixCommand === undefined ? {} : { fixCommand }),
352
+ // Sample names are already cwd-relative, so the hyperlink root is the cwd —
353
+ // joining the corpus dir again produced `fixtures/css/fixtures/css/decls.css`.
354
+ ...(typeof corpusFlag === 'string' ? { corpusRoot: process.cwd() } : {}),
355
+ ...(limit === undefined ? {} : { limit }),
356
+ }))
357
+ if (json !== undefined) writeJson(json, d)
358
+ // 2, not 1: a run that walked no choice did not MEASURE this grammar, and `1` is the
359
+ // "measured, and it failed" code a gate reads as a real finding. Over a fully fused
360
+ // artifact every rule becomes a blocking finding, so `ok` is false for a reason that
361
+ // has nothing to do with the grammar's quality. See the exit-code contract above.
362
+ return examinedNothing(d) ? 2 : d.ok ? 0 : 1
363
+ }
364
+
365
+ if (args.command === 'fix') {
366
+ const root = asCombinator(grammar)
367
+ if (root === null) {
368
+ throw new CliError(
369
+ `${label} exported a rule map or a composed grammar, which has no single root to parse with.\n`
370
+ + ' `fix` verifies by re-parsing, so it needs the rule you parse with: --export <RuleName>.',
371
+ )
372
+ }
373
+ const sourceFlag = args.flags.get('source')
374
+ const sourcePath = typeof sourceFlag === 'string' ? sourceFlag : grammarPath
375
+ let source: { path: string; text: string } | undefined
376
+ try { source = { path: relative(process.cwd(), resolve(sourcePath)), text: readFileSync(resolve(sourcePath), 'utf8') } }
377
+ catch (e) { throw new CliError(`could not read grammar source ${sourcePath}: ${e instanceof Error ? e.message : String(e)}`) }
378
+ const report = proposeFixes(root, {
379
+ corpus,
380
+ ...(source === undefined ? {} : { source }),
381
+ ...(accept === undefined ? {} : { accept }),
382
+ })
383
+ const apply = args.flags.get('apply') === true
384
+ let applied = 0
385
+ if (apply && report.verified.length > 0) {
386
+ const { text, applied: n } = applyFixEdits(source.text, report.verified)
387
+ applied = n
388
+ if (n > 0) {
389
+ try { writeFileSync(resolve(sourcePath), text) }
390
+ catch (e) { throw new CliError(`could not write ${sourcePath}: ${e instanceof Error ? e.message : String(e)}`) }
391
+ }
392
+ }
393
+ human(renderFixReport(report, {
394
+ color, width, links, name: label, sourceRoot: resolve(sourcePath),
395
+ // The COUNT, not a flag. `applyFixEdits` skips an edit whose span moved, so a
396
+ // boolean here reported every verified change as written whenever any one was.
397
+ applied: apply ? applied : 0,
398
+ }))
399
+ if (apply) human(` ${applied} edit(s) written to ${source.path}`)
400
+ if (json !== undefined) writeJson(json, report)
401
+ // Fails closed: a loop that could not run is not a pass.
402
+ return report.ok ? 0 : 2
403
+ }
404
+
405
+ throw new CliError(`unknown command \`${args.command}\`.\n\n${USAGE}`)
406
+ }
407
+
408
+ main(process.argv.slice(2))
409
+ .then((code) => { process.exitCode = code })
410
+ .catch((e) => {
411
+ process.stderr.write(`${e instanceof CliError ? e.message : e instanceof Error ? `${e.name}: ${e.message}` : String(e)}\n`)
412
+ process.exitCode = 2
413
+ })
@@ -0,0 +1,171 @@
1
+ import type { Combinator, ParseContext, ParseFail, ParserMeta } from '../types.ts'
2
+ import { analyzeLabeledTrivia, resolveAdjacencyKindMask, triviaKindMaskAt } from '../cst/trivia-kinds.ts'
3
+ import { probeTriviaEnd } from './trivia-skip.ts'
4
+
5
+ /**
6
+ * ADJACENCY assertions — `adjacent()` and `notAdjacent()`.
7
+ *
8
+ * These are the authoring surface for `docs/design/derived-tokenization.md` §4,
9
+ * "Adjacency is a bit set at scan time". §4's fact is: *was there nothing between
10
+ * the previous token and this one?* The trivia skip already answers it at every
11
+ * sequence boundary and throws the answer away. `noTrivia` is today's POSITIVE
12
+ * spelling of that fact and has no negative twin, so a production that needs
13
+ * "these two are SEPARATED" has had to disable trivia and re-spell whitespace as a
14
+ * regex — which re-implements the dialect's trivia table inside an expression
15
+ * production, and drifts from it.
16
+ *
17
+ * The question is about the GAP, never about what a separator looks like:
18
+ *
19
+ * 1 - 2 the `-` is not adjacent to the `2` → subtraction
20
+ * 1 -2 the `-` IS adjacent to the `2` → signed number in a list
21
+ *
22
+ * and a third case, `1`, a block comment, `-`, a block comment, `2`: not adjacent,
23
+ * but the separation is a comment rather than whitespace. That case is why `kinds`
24
+ * exists. css-values-4 §10.1 requires REAL whitespace
25
+ * around `+`/`-` in `calc()`, because a comment vanishes at tokenisation:
26
+ * `notAdjacent({ kinds: ['whitespace'] })` states that and nothing else.
27
+ *
28
+ * LOWERING. Both are marker tags, recognised by `sequence()` at term `i` and lowered
29
+ * at that boundary into a test of the trivia scan the boundary already performs
30
+ * (`scanEnd > cur`). That is deliberate on two counts: it costs nothing at all where
31
+ * no assertion is written (no ctx field, no extra branch in the ordinary boundary),
32
+ * and it sidesteps `sequence`'s trivia REWIND — a self-contained zero-width
33
+ * combinator sitting at index `i` matches zero-width by construction, so the
34
+ * `result.span.end > scanEnd` test would roll the gap it just asserted back out.
35
+ * When derived tokenization lands, the same tag lowers to a `tight` bit test on the
36
+ * token instead; the assertion an author writes does not change.
37
+ */
38
+
39
+ export type AdjacencyDef = { tag: 'adjacency'; polarity: 'adjacent' | 'notAdjacent'; kinds?: readonly string[] }
40
+
41
+ /** The adjacency spec of `p`, or null when `p` is an ordinary combinator. */
42
+ export function adjacencyOf(p: Combinator<unknown>): AdjacencyDef | null {
43
+ return p._def.tag === 'adjacency' ? p._def : null
44
+ }
45
+
46
+ /** Failure label, matched byte-for-byte by the compiled path. */
47
+ export function adjacencyExpected(def: AdjacencyDef): string {
48
+ if (def.polarity === 'adjacent') return 'adjacent'
49
+ return def.kinds === undefined ? 'notAdjacent' : `notAdjacent(${def.kinds.join('|')})`
50
+ }
51
+
52
+ /**
53
+ * Evaluate one adjacency assertion at a sequence boundary. `cur` is the position
54
+ * BEFORE the ambient trivia scan. No capture, no cursor movement, no ctx writes.
55
+ */
56
+ export function holdsAdjacency(input: string, cur: number, ctx: ParseContext, def: AdjacencyDef): boolean {
57
+ return adjacencyHolds(input, cur, ctx, def.polarity === 'notAdjacent', def.kinds)
58
+ }
59
+
60
+ /**
61
+ * The test itself, over the two things a def actually carries.
62
+ *
63
+ * Split from `holdsAdjacency` so the TABLE drivers can reach it: both decode a
64
+ * polarity word and a const-pool slot out of the instruction stream and have no
65
+ * `AdjacencyDef` object to hand — and manufacturing one per assertion, on a
66
+ * boundary test that is otherwise allocation-free, would be an allocation the
67
+ * interpreter does not pay. One implementation, three engines.
68
+ */
69
+ export function adjacencyHolds(
70
+ input: string, cur: number, ctx: ParseContext, negated: boolean, kinds: readonly string[] | undefined,
71
+ ): boolean {
72
+ const end = probeTriviaEnd(input, cur, ctx)
73
+ if (!negated) return end === cur
74
+ if (end === cur) return false
75
+ if (kinds === undefined) return true
76
+ const want = resolveAdjacencyKindMask(ctx.trivia, kinds)
77
+ const spec = analyzeLabeledTrivia(ctx.trivia!)!
78
+ return (triviaKindMaskAt(input, cur, spec, ctx.state) & want) !== 0
79
+ }
80
+
81
+ export function adjacencyFail(pos: number, def: AdjacencyDef): ParseFail {
82
+ return { ok: false, expected: [adjacencyExpected(def)], span: { start: pos, end: pos } }
83
+ }
84
+
85
+ const NO_STANDALONE_PARSE = 'adjacency assertions are boundary tests: use adjacent()/notAdjacent() as a '
86
+ + 'NON-FIRST term of a sequence(), where the preceding term defines the gap being asserted.'
87
+
88
+ /**
89
+ * The error a marker in a position with no boundary raises — the same sentence
90
+ * from every engine.
91
+ *
92
+ * The table drivers need it for the same reason the combinator does: an
93
+ * assertion reached anywhere but a sequence term has no gap to test, and
94
+ * silently answering "no trivia here" would make `notAdjacent()` a guaranteed
95
+ * failure and `adjacent()` a no-op, both invisible.
96
+ */
97
+ export function adjacencyMisuse(polarity: 'adjacent' | 'notAdjacent'): TypeError {
98
+ return new TypeError(`${polarity}(): ${NO_STANDALONE_PARSE}`)
99
+ }
100
+
101
+ function adjacencyCombinator(def: AdjacencyDef): Combinator<null> {
102
+ const meta: ParserMeta = {
103
+ // Zero-width and never leading (sequence() rejects index 0), so it contributes
104
+ // no first char. `isZeroWidthAssertion` drops it from a sequence's first-set
105
+ // rather than letting `any` poison the enclosing choice's dispatch.
106
+ firstSet: { kind: 'any' },
107
+ canMatchNewline: false,
108
+ isTrivia: false,
109
+ }
110
+ return {
111
+ _tag: 'adjacency',
112
+ _meta: meta,
113
+ _def: def,
114
+ parse(): never {
115
+ // Reached only when the marker sits somewhere that has no boundary to test
116
+ // (a bare choice arm, a node()'s whole body, a repeat item). Silently
117
+ // answering "no trivia here" would make `notAdjacent()` a guaranteed failure
118
+ // and `adjacent()` a no-op — both invisible. Say so instead.
119
+ throw adjacencyMisuse(def.polarity)
120
+ },
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Zero-width assertion: the previous term and this position are ADJACENT — no
126
+ * ambient trivia sat between them.
127
+ *
128
+ * The first-class spelling of what `noTrivia(...)` expresses today by clearing the
129
+ * trivia table around a glued run. Use it when only the JOIN is significant and the
130
+ * terms themselves are ordinary:
131
+ *
132
+ * sequence(number, adjacent(), unit) // `10px`, never `10 px`
133
+ */
134
+ export function adjacent(): Combinator<null> {
135
+ return adjacencyCombinator({ tag: 'adjacency', polarity: 'adjacent' })
136
+ }
137
+
138
+ /**
139
+ * Zero-width assertion: the previous term and this position are NOT adjacent —
140
+ * ambient trivia sat between them.
141
+ *
142
+ * `kinds` narrows it to trivia CATEGORIES declared by `classifiedTrivia({...})`, so
143
+ * the assertion can demand a category that survives tokenisation:
144
+ *
145
+ * // css-values-4 10.1: `a + b` yes; a comment in place of the space, no.
146
+ * sequence(operand, notAdjacent({ kinds: ['whitespace'] }), plusOrMinus,
147
+ * notAdjacent({ kinds: ['whitespace'] }), operand)
148
+ *
149
+ * A `kinds` list is checked against the ACTIVE trivia table the first time the
150
+ * assertion is reached (and at compile time for the compiled path). Unlabeled
151
+ * trivia, or a kind name the table does not declare, is a hard `TypeError` — never
152
+ * a silently-empty filter.
153
+ */
154
+ export function notAdjacent(options?: { kinds?: readonly string[] }): Combinator<null> {
155
+ const kinds = options?.kinds
156
+ if (kinds !== undefined) {
157
+ if (!Array.isArray(kinds) || kinds.length === 0) {
158
+ throw new TypeError('notAdjacent({ kinds }): kinds must be a non-empty array of trivia category names.')
159
+ }
160
+ for (const k of kinds) {
161
+ if (typeof k !== 'string' || k.length === 0) {
162
+ throw new TypeError(`notAdjacent({ kinds }): every kind must be a non-empty string, got ${JSON.stringify(k)}.`)
163
+ }
164
+ }
165
+ }
166
+ return adjacencyCombinator(
167
+ kinds === undefined
168
+ ? { tag: 'adjacency', polarity: 'notAdjacent' }
169
+ : { tag: 'adjacency', polarity: 'notAdjacent', kinds: [...kinds] },
170
+ )
171
+ }
@@ -0,0 +1,41 @@
1
+ import type { Combinator, ParseContext, ParseResult } from '../types.ts'
2
+ import { rollbackCstCapture, saveCstMark } from '../cst/capture-buffer.ts'
3
+ import { matchesEmpty, startsFirstSet } from './first-set.ts'
4
+ import { deriveExpected } from './expect.ts'
5
+
6
+ /**
7
+ * Transactional ordered-choice arm. On failure it restores Parseman's
8
+ * structural capture/trivia/error sinks and reports failure at its entry point;
9
+ * user-owned `ctx.state` is intentionally not cloned or mutated by Parseman.
10
+ */
11
+ export function attempt<T>(parser: Combinator<T>): Combinator<T> {
12
+ // First-set fail-fast (mirrors emitAttempt's codegen guard): a non-nullable inner
13
+ // whose first set can't start here can only fail, so reject BEFORE taking the six
14
+ // transaction rollback marks, re-anchoring at `pos` and reporting the same static
15
+ // `expected` the inner start-fail would. Skipped under a completions probe /
16
+ // tolerant recovery, where the swallowed failure still feeds the probe.
17
+ const guardable = parser._meta.firstSet.kind !== 'any' && !matchesEmpty(parser)
18
+ let failExpected: string[] | undefined
19
+ return {
20
+ _tag: 'attempt', _meta: parser._meta, _def: { tag: 'attempt', parser },
21
+ parse(input: string, pos: number, ctx: ParseContext): ParseResult<T> {
22
+ if (guardable && ctx._probe === undefined && !ctx._tolerant && !startsFirstSet(parser, input, pos)) {
23
+ if (failExpected === undefined) {
24
+ const e = deriveExpected(parser)
25
+ failExpected = e.length > 0 ? e : [parser._tag]
26
+ }
27
+ return { ok: false, expected: failExpected, span: { start: pos, end: pos } }
28
+ }
29
+ const mark = saveCstMark(ctx)
30
+ const log = ctx._triviaLog?.length
31
+ const rootLog = ctx._rootTriviaLog?.length
32
+ const result = parser.parse(input, pos, ctx)
33
+ if (result.ok) return result
34
+ rollbackCstCapture(ctx, mark)
35
+ if (log !== undefined && ctx._triviaLog && ctx._triviaLog.length !== log) ctx._triviaLog.length = log
36
+ if (rootLog !== undefined && ctx._rootTriviaLog && ctx._rootTriviaLog.length !== rootLog) ctx._rootTriviaLog.length = rootLog
37
+ if (result.committed) return result
38
+ return { ok: false, expected: result.expected, span: { start: pos, end: pos } }
39
+ },
40
+ }
41
+ }