parseman 0.46.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.
- package/README.md +8 -3
- package/dist/analysis/choice-cost-render.d.ts +12 -4
- package/dist/analysis/choice-cost-render.d.ts.map +1 -1
- package/dist/analysis/choice-cost.d.ts +2 -2
- package/dist/analysis/choice-cost.d.ts.map +1 -1
- package/dist/analysis/commitment.d.ts +131 -0
- package/dist/analysis/commitment.d.ts.map +1 -0
- package/dist/analysis/diagnostics.cjs +9929 -7465
- package/dist/analysis/diagnostics.cjs.map +3 -4
- package/dist/analysis/diagnostics.d.ts +2 -2
- package/dist/analysis/diagnostics.js +9929 -7465
- package/dist/analysis/diagnostics.js.map +3 -4
- package/dist/analysis/duplication.d.ts.map +1 -1
- package/dist/analysis/fix.d.ts +3 -3
- package/dist/analysis/fix.d.ts.map +1 -1
- package/dist/analysis/format-number.d.ts +23 -0
- package/dist/analysis/format-number.d.ts.map +1 -0
- package/dist/analysis/gating.d.ts +39 -2
- package/dist/analysis/gating.d.ts.map +1 -1
- package/dist/analysis/rebuild.d.ts.map +1 -1
- package/dist/analysis/terminal.d.ts +5 -2
- package/dist/analysis/terminal.d.ts.map +1 -1
- package/dist/cli/index.cjs +10865 -8535
- package/dist/cli/index.cjs.map +3 -4
- package/dist/cli/index.js +10865 -8535
- package/dist/cli/index.js.map +3 -4
- package/dist/combinators/adjacency.d.ts +100 -0
- package/dist/combinators/adjacency.d.ts.map +1 -0
- package/dist/combinators/choice.d.ts.map +1 -1
- package/dist/combinators/completions.d.ts.map +1 -1
- package/dist/combinators/dispatch.d.ts +8 -1
- package/dist/combinators/dispatch.d.ts.map +1 -1
- package/dist/combinators/expect.d.ts.map +1 -1
- package/dist/combinators/first-set.d.ts.map +1 -1
- package/dist/combinators/gate.d.ts.map +1 -1
- package/dist/combinators/grammar.d.ts +22 -0
- package/dist/combinators/grammar.d.ts.map +1 -1
- package/dist/combinators/literal.d.ts +8 -0
- package/dist/combinators/literal.d.ts.map +1 -1
- package/dist/combinators/map.d.ts +0 -1
- package/dist/combinators/map.d.ts.map +1 -1
- package/dist/combinators/node.d.ts +13 -3
- package/dist/combinators/node.d.ts.map +1 -1
- package/dist/combinators/parser.d.ts +1 -1
- package/dist/combinators/parser.d.ts.map +1 -1
- package/dist/combinators/ref.d.ts.map +1 -1
- package/dist/combinators/regex.d.ts +1 -16
- package/dist/combinators/regex.d.ts.map +1 -1
- package/dist/combinators/repeat.d.ts +30 -2
- package/dist/combinators/repeat.d.ts.map +1 -1
- package/dist/combinators/scanTo.d.ts +71 -1
- package/dist/combinators/scanTo.d.ts.map +1 -1
- package/dist/combinators/sequence.d.ts.map +1 -1
- package/dist/combinators/token.d.ts.map +1 -1
- package/dist/combinators/trivia-boundary.d.ts +26 -0
- package/dist/combinators/trivia-boundary.d.ts.map +1 -0
- package/dist/combinators/trivia-skip.d.ts +40 -1
- package/dist/combinators/trivia-skip.d.ts.map +1 -1
- package/dist/combinators/withCtx.d.ts.map +1 -1
- package/dist/compiler/compile-linkable-table.d.ts +104 -0
- package/dist/compiler/compile-linkable-table.d.ts.map +1 -0
- package/dist/compiler/degradation.d.ts +0 -2
- package/dist/compiler/degradation.d.ts.map +1 -1
- package/dist/compiler/fields.d.ts +13 -0
- package/dist/compiler/fields.d.ts.map +1 -1
- package/dist/compiler/grammar-coverage-ids.d.ts +5 -5
- package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
- package/dist/compiler/ir-serialize.d.ts.map +1 -1
- package/dist/compiler/linker.d.ts +47 -53
- package/dist/compiler/linker.d.ts.map +1 -1
- package/dist/compiler/token-alphabet.d.ts +81 -0
- package/dist/compiler/token-alphabet.d.ts.map +1 -0
- package/dist/compiler/token-dispatch.d.ts +95 -0
- package/dist/compiler/token-dispatch.d.ts.map +1 -0
- package/dist/compiler/token-scanner.d.ts +53 -0
- package/dist/compiler/token-scanner.d.ts.map +1 -0
- package/dist/compiler/value-usage.d.ts.map +1 -1
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.d.ts.map +1 -1
- package/dist/cst/capture-buffer.d.ts +35 -0
- package/dist/cst/capture-buffer.d.ts.map +1 -1
- package/dist/cst/reflection-attach.d.ts +8 -0
- package/dist/cst/reflection-attach.d.ts.map +1 -0
- package/dist/cst/reflection-symbols.d.ts +10 -0
- package/dist/cst/reflection-symbols.d.ts.map +1 -0
- package/dist/cst/reflection.d.ts +2 -4
- package/dist/cst/reflection.d.ts.map +1 -1
- package/dist/cst/root-trivia-scope.d.ts +26 -0
- package/dist/cst/root-trivia-scope.d.ts.map +1 -0
- package/dist/cst/trivia-charscan.d.ts +18 -0
- package/dist/cst/trivia-charscan.d.ts.map +1 -0
- package/dist/cst/trivia-kinds.d.ts +19 -0
- package/dist/cst/trivia-kinds.d.ts.map +1 -1
- package/dist/functional/doc.d.ts.map +1 -1
- package/dist/functional/run-tabled.d.ts +12 -0
- package/dist/functional/run-tabled.d.ts.map +1 -0
- package/dist/functional/run.d.ts +25 -42
- package/dist/functional/run.d.ts.map +1 -1
- package/dist/grammar-metadata.d.ts +3 -0
- package/dist/grammar-metadata.d.ts.map +1 -0
- package/dist/index.cjs +9224 -7203
- package/dist/index.cjs.map +3 -4
- package/dist/index.d.ts +18 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9219 -7198
- package/dist/index.js.map +3 -4
- package/dist/language-service/index.cjs +311 -121
- package/dist/language-service/index.cjs.map +3 -4
- package/dist/language-service/index.js +311 -121
- package/dist/language-service/index.js.map +3 -4
- package/dist/oracle/index.cjs.map +0 -1
- package/dist/oracle/index.js.map +0 -1
- package/dist/parse-context.d.ts +49 -0
- package/dist/parse-context.d.ts.map +1 -0
- package/dist/plugin/evaluator.d.ts +40 -1
- package/dist/plugin/evaluator.d.ts.map +1 -1
- package/dist/plugin/index.cjs +11146 -9593
- package/dist/plugin/index.cjs.map +3 -4
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +11146 -9593
- package/dist/plugin/index.js.map +3 -4
- package/dist/regex/classes.d.ts +28 -0
- package/dist/regex/classes.d.ts.map +1 -1
- package/dist/regex/first-set.d.ts +14 -0
- package/dist/regex/first-set.d.ts.map +1 -1
- package/dist/run/index.cjs +136 -51
- package/dist/run/index.cjs.map +2 -3
- package/dist/run/index.js +136 -51
- package/dist/run/index.js.map +2 -3
- package/dist/spec/index.cjs +2898 -1623
- package/dist/spec/index.cjs.map +3 -4
- package/dist/spec/index.js +2898 -1623
- package/dist/spec/index.js.map +3 -4
- package/dist/spec/model.d.ts +8 -0
- package/dist/spec/model.d.ts.map +1 -1
- package/dist/table/assemble.d.ts +307 -0
- package/dist/table/assemble.d.ts.map +1 -0
- package/dist/table/cell.d.ts +42 -0
- package/dist/table/cell.d.ts.map +1 -0
- package/dist/table/child-slots.d.ts +30 -0
- package/dist/table/child-slots.d.ts.map +1 -0
- package/dist/table/compile-rule-map.d.ts +136 -0
- package/dist/table/compile-rule-map.d.ts.map +1 -0
- package/dist/table/compile.d.ts +56 -0
- package/dist/table/compile.d.ts.map +1 -0
- package/dist/table/duplication-hook.d.ts +32 -0
- package/dist/table/duplication-hook.d.ts.map +1 -0
- package/dist/table/emit-assembly.d.ts +139 -0
- package/dist/table/emit-assembly.d.ts.map +1 -0
- package/dist/table/emit.d.ts +113 -0
- package/dist/table/emit.d.ts.map +1 -0
- package/dist/table/encode-baseline.d.ts +16 -0
- package/dist/table/encode-baseline.d.ts.map +1 -0
- package/dist/table/encode.d.ts +71 -0
- package/dist/table/encode.d.ts.map +1 -0
- package/dist/table/exec-baseline.d.ts +9 -0
- package/dist/table/exec-baseline.d.ts.map +1 -0
- package/dist/table/exec.d.ts +72 -0
- package/dist/table/exec.d.ts.map +1 -0
- package/dist/table/fold.d.ts +25 -0
- package/dist/table/fold.d.ts.map +1 -0
- package/dist/table/index.cjs +11375 -0
- package/dist/table/index.cjs.map +6 -0
- package/dist/table/index.d.ts +100 -0
- package/dist/table/index.d.ts.map +1 -0
- package/dist/table/index.js +11352 -0
- package/dist/table/index.js.map +6 -0
- package/dist/table/inspect.d.ts +17 -0
- package/dist/table/inspect.d.ts.map +1 -0
- package/dist/table/ops.d.ts +446 -0
- package/dist/table/ops.d.ts.map +1 -0
- package/dist/table/program.d.ts +493 -0
- package/dist/table/program.d.ts.map +1 -0
- package/dist/table/run-support.d.ts +38 -0
- package/dist/table/run-support.d.ts.map +1 -0
- package/dist/{compiler/scannable-run.d.ts → table/scan-shapes.d.ts} +14 -13
- package/dist/table/scan-shapes.d.ts.map +1 -0
- package/dist/table/site-labels.d.ts +58 -0
- package/dist/table/site-labels.d.ts.map +1 -0
- package/dist/table/stamp.d.ts +70 -0
- package/dist/table/stamp.d.ts.map +1 -0
- package/dist/types.d.ts +80 -24
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +25 -2
- package/src/analysis/choice-cost-gate.ts +359 -0
- package/src/analysis/choice-cost-render.ts +293 -0
- package/src/analysis/choice-cost.ts +1008 -0
- package/src/analysis/commitment.ts +557 -0
- package/src/analysis/corpus.ts +130 -0
- package/src/analysis/diagnose-render.ts +625 -0
- package/src/analysis/diagnose.ts +317 -0
- package/src/analysis/diagnostics.ts +33 -0
- package/src/analysis/duplication.ts +1955 -0
- package/src/analysis/fix-render.ts +217 -0
- package/src/analysis/fix.ts +669 -0
- package/src/analysis/format-number.ts +31 -0
- package/src/analysis/gating.ts +792 -0
- package/src/analysis/grammar.ts +72 -0
- package/src/analysis/rebuild.ts +287 -0
- package/src/analysis/terminal.ts +336 -0
- package/src/cli/index.ts +413 -0
- package/src/combinators/adjacency.ts +171 -0
- package/src/combinators/attempt.ts +41 -0
- package/src/combinators/case-fold.ts +90 -0
- package/src/combinators/choice.ts +466 -0
- package/src/combinators/completions.ts +64 -0
- package/src/combinators/dispatch.ts +358 -0
- package/src/combinators/expect.ts +159 -0
- package/src/combinators/first-set.ts +322 -0
- package/src/combinators/gate.ts +50 -0
- package/src/combinators/grammar.ts +272 -0
- package/src/combinators/keywords.ts +145 -0
- package/src/combinators/literal.ts +102 -0
- package/src/combinators/map.ts +106 -0
- package/src/combinators/node.ts +345 -0
- package/src/combinators/not.ts +55 -0
- package/src/combinators/parser.ts +266 -0
- package/src/combinators/peek.ts +63 -0
- package/src/combinators/probe.ts +25 -0
- package/src/combinators/ref.ts +109 -0
- package/src/combinators/regex.ts +170 -0
- package/src/combinators/repeat.ts +561 -0
- package/src/combinators/scanTo.ts +393 -0
- package/src/combinators/sequence.ts +221 -0
- package/src/combinators/token.ts +120 -0
- package/src/combinators/trivia-boundary.ts +48 -0
- package/src/combinators/trivia-skip.ts +821 -0
- package/src/combinators/withCtx.ts +45 -0
- package/src/compiler/build-arity.ts +330 -0
- package/src/compiler/compile-linkable-table.ts +209 -0
- package/src/compiler/degradation.ts +266 -0
- package/src/compiler/fields.ts +191 -0
- package/src/compiler/grammar-coverage-ids.ts +129 -0
- package/src/compiler/ir-serialize.ts +615 -0
- package/src/compiler/linker.ts +954 -0
- package/src/compiler/token-alphabet.ts +209 -0
- package/src/compiler/token-dispatch.ts +353 -0
- package/src/compiler/token-scanner.ts +318 -0
- package/src/compiler/value-usage.ts +147 -0
- package/src/coverage.ts +383 -0
- package/src/cst/capture-buffer.ts +297 -0
- package/src/cst/host-mode.ts +69 -0
- package/src/cst/reflection-attach.ts +11 -0
- package/src/cst/reflection-symbols.ts +9 -0
- package/src/cst/reflection.ts +149 -0
- package/src/cst/relative-spans.ts +207 -0
- package/src/cst/root-trivia-scope.ts +30 -0
- package/src/cst/trivia-charscan.ts +312 -0
- package/src/cst/trivia-entries.ts +285 -0
- package/src/cst/trivia-index.ts +131 -0
- package/src/cst/trivia-kinds.ts +358 -0
- package/src/cst/types.ts +76 -0
- package/src/cst/walk.ts +101 -0
- package/src/functional/doc.ts +959 -0
- package/src/functional/run-tabled.ts +153 -0
- package/src/functional/run.ts +558 -0
- package/src/grammar-metadata.ts +2 -0
- package/src/index.ts +189 -0
- package/src/language-service/index.ts +221 -0
- package/src/line-index.ts +131 -0
- package/src/oracle/digest.ts +620 -0
- package/src/oracle/index.ts +55 -0
- package/src/parse-context.ts +91 -0
- package/src/plugin/direct-builder-static.ts +102 -0
- package/src/plugin/evaluator.ts +1505 -0
- package/src/plugin/free-identifiers.ts +338 -0
- package/src/plugin/index.ts +2281 -0
- package/src/plugin/reducer-resolver.ts +606 -0
- package/src/recovery/scan.ts +155 -0
- package/src/regex/classes.ts +183 -0
- package/src/regex/first-set.ts +316 -0
- package/src/run/index.ts +31 -0
- package/src/spec/ebnf.ts +128 -0
- package/src/spec/index.ts +54 -0
- package/src/spec/model.ts +636 -0
- package/src/spec/railroad-lib.ts +559 -0
- package/src/spec/railroad.ts +256 -0
- package/src/table/assemble.ts +2994 -0
- package/src/table/cell.ts +42 -0
- package/src/table/child-slots.ts +113 -0
- package/src/table/compile-rule-map.ts +336 -0
- package/src/table/compile.ts +244 -0
- package/src/table/duplication-hook.ts +89 -0
- package/src/table/emit-assembly.ts +1766 -0
- package/src/table/emit.ts +418 -0
- package/src/table/encode-baseline.ts +340 -0
- package/src/table/encode.ts +1569 -0
- package/src/table/exec-baseline.ts +537 -0
- package/src/table/exec.ts +1672 -0
- package/src/table/fold.ts +38 -0
- package/src/table/index.ts +108 -0
- package/src/table/inspect.ts +52 -0
- package/src/table/ops.ts +459 -0
- package/src/table/program.ts +855 -0
- package/src/table/run-support.ts +66 -0
- package/src/table/scan-shapes.ts +1578 -0
- package/src/table/site-labels.ts +228 -0
- package/src/table/stamp.ts +229 -0
- package/src/types.ts +563 -0
- package/src/version.ts +16 -0
- package/dist/compiler/codegen.d.ts +0 -334
- package/dist/compiler/codegen.d.ts.map +0 -1
- package/dist/compiler/inline-build.d.ts +0 -11
- package/dist/compiler/inline-build.d.ts.map +0 -1
- package/dist/compiler/inline-callback.d.ts +0 -21
- package/dist/compiler/inline-callback.d.ts.map +0 -1
- package/dist/compiler/line-index.d.ts +0 -2
- package/dist/compiler/line-index.d.ts.map +0 -1
- package/dist/compiler/module-hoist.d.ts +0 -106
- package/dist/compiler/module-hoist.d.ts.map +0 -1
- package/dist/compiler/scannable-run.d.ts.map +0 -1
- package/dist/compiler/scannable-terminal.d.ts +0 -23
- package/dist/compiler/scannable-terminal.d.ts.map +0 -1
- package/dist/compiler/trivia-fast-path.d.ts +0 -38
- package/dist/compiler/trivia-fast-path.d.ts.map +0 -1
package/src/cli/index.ts
ADDED
|
@@ -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
|
+
}
|