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.
- package/README.md +8 -3
- package/dist/analysis/choice-cost-gate.d.ts +158 -0
- package/dist/analysis/choice-cost-gate.d.ts.map +1 -0
- package/dist/analysis/choice-cost-render.d.ts +96 -0
- package/dist/analysis/choice-cost-render.d.ts.map +1 -0
- package/dist/analysis/choice-cost.d.ts +383 -0
- package/dist/analysis/choice-cost.d.ts.map +1 -0
- package/dist/analysis/commitment.d.ts +131 -0
- package/dist/analysis/commitment.d.ts.map +1 -0
- package/dist/analysis/corpus.d.ts +73 -0
- package/dist/analysis/corpus.d.ts.map +1 -0
- package/dist/analysis/diagnose-render.d.ts +65 -0
- package/dist/analysis/diagnose-render.d.ts.map +1 -0
- package/dist/analysis/diagnose.d.ts +15 -0
- package/dist/analysis/diagnose.d.ts.map +1 -1
- package/dist/analysis/diagnostics.cjs +13736 -0
- package/dist/analysis/diagnostics.cjs.map +6 -0
- package/dist/analysis/diagnostics.d.ts +27 -0
- package/dist/analysis/diagnostics.d.ts.map +1 -0
- package/dist/analysis/diagnostics.js +13713 -0
- package/dist/analysis/diagnostics.js.map +6 -0
- package/dist/analysis/duplication.d.ts.map +1 -1
- package/dist/analysis/fix-render.d.ts +43 -0
- package/dist/analysis/fix-render.d.ts.map +1 -0
- package/dist/analysis/fix.d.ts +172 -0
- package/dist/analysis/fix.d.ts.map +1 -0
- 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 +47 -2
- package/dist/analysis/gating.d.ts.map +1 -1
- package/dist/analysis/rebuild.d.ts +61 -0
- package/dist/analysis/rebuild.d.ts.map +1 -0
- package/dist/analysis/terminal.d.ts +145 -0
- package/dist/analysis/terminal.d.ts.map +1 -0
- package/dist/cli/index.cjs +14837 -0
- package/dist/cli/index.cjs.map +6 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +14814 -0
- package/dist/cli/index.js.map +6 -0
- 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/build-arity.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 +79 -47
- 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 +10499 -7478
- package/dist/index.cjs.map +3 -4
- package/dist/index.d.ts +24 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10499 -7478
- 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 +41 -2
- package/dist/plugin/evaluator.d.ts.map +1 -1
- package/dist/plugin/free-identifiers.d.ts +23 -0
- package/dist/plugin/free-identifiers.d.ts.map +1 -0
- package/dist/plugin/index.cjs +11381 -9442
- package/dist/plugin/index.cjs.map +3 -4
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +11381 -9442
- package/dist/plugin/index.js.map +3 -4
- package/dist/plugin/reducer-resolver.d.ts +17 -1
- package/dist/plugin/reducer-resolver.d.ts.map +1 -1
- 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 -1622
- package/dist/spec/index.cjs.map +3 -4
- package/dist/spec/index.js +2898 -1622
- 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 +41 -3
- 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/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
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The human layer over `GrammarDiagnosis`.
|
|
3
|
+
*
|
|
4
|
+
* Three layers, not two: `gating.ts`/`corpus.ts` produce data, `diagnose.ts` decides
|
|
5
|
+
* `ok` (the CI verdict), and this file makes either readable. Nothing here computes a
|
|
6
|
+
* finding and nothing here decides pass or fail. A gate consumes the object; a person
|
|
7
|
+
* reads this. If a number appears below that is not in the report, that is a bug here.
|
|
8
|
+
*
|
|
9
|
+
* GROUPED BY CAUSE, NOT BY SITE
|
|
10
|
+
* -----------------------------
|
|
11
|
+
* The first version grouped by site: thirteen findings, each carrying its own copy of
|
|
12
|
+
* the explanation, the `do` line and the `ok as-is?` line. Measured on
|
|
13
|
+
* `examples/css/parser.ts`, that was three distinct explanations rendered nine times
|
|
14
|
+
* across 146 lines. Density that comes from repetition is not detail — it is the same
|
|
15
|
+
* sentence again, and it buries the arm tables, which are the part a reader acts on.
|
|
16
|
+
*
|
|
17
|
+
* A cause is now stated ONCE, given a glyph, and followed by its sites. That also
|
|
18
|
+
* disposes of the question a glossary would have answered: the glyph is defined at the
|
|
19
|
+
* head of the only group it labels, so there is nothing to look up. Shipping a glossary
|
|
20
|
+
* AND the inline explanations would just re-create the duplication this removes.
|
|
21
|
+
*
|
|
22
|
+
* WHAT CARRIES MEANING
|
|
23
|
+
* --------------------
|
|
24
|
+
* colour — severity, and importance WITHIN a finding. Exactly one span per block is
|
|
25
|
+
* the brightest thing in it; provenance and accept keys recede.
|
|
26
|
+
* glyph — the cause class, so sites are scannable without reading prose.
|
|
27
|
+
* rules — group boundaries, so the report is blocks rather than a paragraph.
|
|
28
|
+
* columns — the arm table is aligned, so an ANY arm or an overlap is visible as a
|
|
29
|
+
* shape before it is read as text.
|
|
30
|
+
*
|
|
31
|
+
* All of that lives in the STYLED path. The plain path is the same content with the same
|
|
32
|
+
* padding and no escapes, so `docs/samples/` stays diffable and the two cannot drift.
|
|
33
|
+
*/
|
|
34
|
+
import { examinedNothing, type GrammarDiagnosis, type DiagnosisFinding } from './diagnose.ts'
|
|
35
|
+
import type { ChoiceCorpusCost } from './corpus.ts'
|
|
36
|
+
import {
|
|
37
|
+
DEFAULT_WIDTH, TONE, blank, codeFrame, groupDigits, pad, render, rule, t, wrap,
|
|
38
|
+
type Line, type RenderTarget, type Tone,
|
|
39
|
+
} from './terminal.ts'
|
|
40
|
+
|
|
41
|
+
export type DiagnoseRenderOptions = RenderTarget & {
|
|
42
|
+
/** Grammar label for the header. The CLI passes a cwd-relative path. */
|
|
43
|
+
name?: string
|
|
44
|
+
/** Sites to expand. Default 20; the report always holds all of them. */
|
|
45
|
+
limit?: number
|
|
46
|
+
/** Corpus cost per choice id, from `measureChoiceCost`. */
|
|
47
|
+
cost?: ReadonlyMap<string, ChoiceCorpusCost>
|
|
48
|
+
/** Per-arm first-set renderings per choice id, in arm order. */
|
|
49
|
+
armFirstSets?: ReadonlyMap<string, readonly string[]>
|
|
50
|
+
/** Per-arm leading-term labels per choice id, in arm order. */
|
|
51
|
+
armLabels?: ReadonlyMap<string, readonly string[]>
|
|
52
|
+
/** Absolute corpus root, used ONLY for the frame's terminal hyperlink. */
|
|
53
|
+
corpusRoot?: string
|
|
54
|
+
/**
|
|
55
|
+
* Finding ids for which `proposeFixes` PROVED a rewrite — applied it, recompiled, and
|
|
56
|
+
* re-parsed the corpus with identical output. Only these may carry the wrench. A
|
|
57
|
+
* candidate that was proposed and rejected must never appear here: offering a fix that
|
|
58
|
+
* does not exist would destroy the one guarantee the feature has.
|
|
59
|
+
*/
|
|
60
|
+
fixable?: ReadonlySet<string>
|
|
61
|
+
/** The command that would apply them, printed verbatim so nobody has to guess it. */
|
|
62
|
+
fixCommand?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export { groupDigits }
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Cause glyphs, in assignment order.
|
|
69
|
+
*
|
|
70
|
+
* Deliberately distinct in SHAPE, not just in colour: a report has to stay readable for
|
|
71
|
+
* a reader who cannot distinguish red from green, and in a log that has been stripped of
|
|
72
|
+
* colour entirely — which is exactly what `docs/samples/` is.
|
|
73
|
+
*/
|
|
74
|
+
const GLYPHS = ['◆', '▲', '●', '■', '◇', '△', '○', '□'] as const
|
|
75
|
+
|
|
76
|
+
/** A short label per finding code, used in a group headline. */
|
|
77
|
+
const CODE_LABEL: Record<string, { one: string; many: string }> = {
|
|
78
|
+
'ungated-choice': {
|
|
79
|
+
one: 'choice the parser cannot narrow down',
|
|
80
|
+
many: 'choices the parser cannot narrow down',
|
|
81
|
+
},
|
|
82
|
+
'anti-pattern': { one: 'arm that hides its first character', many: 'arms that hide their first character' },
|
|
83
|
+
unanalysable: { one: 'rule parseman could not examine at all', many: 'rules parseman could not examine at all' },
|
|
84
|
+
degraded: { one: 'place the compiler took a slower route', many: 'places the compiler took a slower route' },
|
|
85
|
+
'stale-accept': {
|
|
86
|
+
one: 'accept-list entry that no longer matches anything',
|
|
87
|
+
many: 'accept-list entries that no longer match anything',
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The cause an anti-pattern finding belongs to, keyed by KIND.
|
|
93
|
+
*
|
|
94
|
+
* The finding's own message names the specific word it found, which is right for the
|
|
95
|
+
* site line and wrong for the group: keying on the whole message split one cause into
|
|
96
|
+
* one group per site, which is the duplication this rendering exists to remove. The
|
|
97
|
+
* group says what the pattern is; the site says where and which word.
|
|
98
|
+
*/
|
|
99
|
+
const ANTI_KIND_BLURB: Record<string, string> = {
|
|
100
|
+
'keyword-regex':
|
|
101
|
+
'These arms match a fixed word using a regular expression. parseman cannot always tell '
|
|
102
|
+
+ 'from a regular expression which character it starts with, so the parser cannot skip '
|
|
103
|
+
+ 'the arm when that character rules it out.\n'
|
|
104
|
+
+ "To fix: write the word with word('…') or keywords([…]). Same match, same compiled "
|
|
105
|
+
+ 'character scan, but parseman then knows the first character.',
|
|
106
|
+
'double-not':
|
|
107
|
+
'These arms use not(not(X)) — a hand-written way of saying "X must come next, but do not '
|
|
108
|
+
+ 'consume it". It hides the first character, so the arm can never be skipped, and among '
|
|
109
|
+
+ 'arms that share a first character it can pick the wrong one.\n'
|
|
110
|
+
+ 'To fix: write peek(X). Same check, and parseman still knows the first character.',
|
|
111
|
+
'leading-not':
|
|
112
|
+
'These arms begin with not(...), which matches no text and so reveals no first character. '
|
|
113
|
+
+ 'The parser cannot skip the arm and enters it at every position.\n'
|
|
114
|
+
+ 'To fix: put the term that actually consumes text first, and keep not(...) after it as a '
|
|
115
|
+
+ 'trailing boundary check.',
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A first set, said in words.
|
|
120
|
+
*
|
|
121
|
+
* `'#','*','-'-'.'` is how the model writes it and is unreadable to anyone who has not
|
|
122
|
+
* met the model. What a reader needs is the sentence: this arm can only begin with these
|
|
123
|
+
* characters, therefore the parser can skip it everywhere else.
|
|
124
|
+
*/
|
|
125
|
+
function startsWithPhrase(fs: string): string {
|
|
126
|
+
if (fs === 'ANY') return 'can start with any character'
|
|
127
|
+
if (fs === '(empty)') return 'matches nothing'
|
|
128
|
+
const parts = fs.split(',')
|
|
129
|
+
const pretty = parts.map(x => x.replace(/'/g, '')).join(' ')
|
|
130
|
+
if (parts.length === 1) return `starts with "${pretty}"`
|
|
131
|
+
if (parts.length > 4) return `starts with ${groupDigits(parts.length)} char ranges`
|
|
132
|
+
return `starts with one of ${pretty.length > 20 ? `${pretty.slice(0, 19)}…` : pretty}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The fallback explanation for a code whose findings carry no `fix:` of their own. */
|
|
136
|
+
const CODE_BLURB: Record<string, string> = {
|
|
137
|
+
'ungated-choice': 'The parser cannot tell from the next character which alternative to try, so it '
|
|
138
|
+
+ 'tries them in order and undoes the ones that do not match.',
|
|
139
|
+
'anti-pattern': 'The arm is written in a way that hides which character it starts with, so the parser '
|
|
140
|
+
+ 'cannot skip it when it cannot match.',
|
|
141
|
+
unanalysable: 'parseman could not read these rules at all, so it has NO opinion about them — good or '
|
|
142
|
+
+ 'bad. Anything reported elsewhere is therefore incomplete.',
|
|
143
|
+
degraded: 'The compiler could not use its fast route here and fell back to a slower one that produces '
|
|
144
|
+
+ 'the same result.',
|
|
145
|
+
'stale-accept': 'This entry in your accept list no longer matches any finding — the grammar was fixed '
|
|
146
|
+
+ 'and the line was left behind. Delete it.',
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type CauseGroup = {
|
|
150
|
+
glyph: string
|
|
151
|
+
/** The one-per-cause explanation. */
|
|
152
|
+
text: string
|
|
153
|
+
code: string
|
|
154
|
+
severity: 'blocking' | 'advisory'
|
|
155
|
+
findings: DiagnosisFinding[]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Every `fix:` a finding carries, in order. The first is its primary cause. */
|
|
159
|
+
function suggestionsOf(f: DiagnosisFinding): string[] {
|
|
160
|
+
const out: string[] = []
|
|
161
|
+
for (const d of f.details) {
|
|
162
|
+
const i = d.indexOf('\nfix: ')
|
|
163
|
+
if (i === -1) continue
|
|
164
|
+
const s = d.slice(i + 6)
|
|
165
|
+
if (!out.includes(s)) out.push(s)
|
|
166
|
+
}
|
|
167
|
+
return out
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Group findings by primary cause.
|
|
172
|
+
*
|
|
173
|
+
* A finding with two causes is listed ONCE, under its first, with the others flagged
|
|
174
|
+
* inline. Listing it under both would print its arm table twice, which is the
|
|
175
|
+
* duplication this grouping exists to remove.
|
|
176
|
+
*/
|
|
177
|
+
function groupByCause(findings: readonly DiagnosisFinding[]): CauseGroup[] {
|
|
178
|
+
const groups: CauseGroup[] = []
|
|
179
|
+
const byKey = new Map<string, CauseGroup>()
|
|
180
|
+
for (const f of findings) {
|
|
181
|
+
// An anti-pattern's message names the specific word found at THIS site, which is
|
|
182
|
+
// right for the site line and wrong for the group: keying on it split one pattern
|
|
183
|
+
// into one group per site — the exact duplication this grouping removes. Key on the
|
|
184
|
+
// KIND, and let the site line carry the word.
|
|
185
|
+
const kind = f.code === 'anti-pattern' ? /^\[([^\]]+)\]/.exec(f.message)?.[1] : undefined
|
|
186
|
+
const text = suggestionsOf(f)[0]
|
|
187
|
+
?? (kind === undefined ? undefined : ANTI_KIND_BLURB[kind])
|
|
188
|
+
?? (f.code === 'anti-pattern' ? f.message.replace(/^\[[^\]]+\]\s*/, '') : undefined)
|
|
189
|
+
?? CODE_BLURB[f.code]
|
|
190
|
+
?? f.message
|
|
191
|
+
// JSON, not a delimiter character: a raw separator inside a template literal made
|
|
192
|
+
// this file binary to git once already (see 9dab15d), and a printable one can still
|
|
193
|
+
// collide with the text it separates.
|
|
194
|
+
const key = JSON.stringify([f.code, kind ?? text])
|
|
195
|
+
let g = byKey.get(key)
|
|
196
|
+
if (g === undefined) {
|
|
197
|
+
g = { glyph: '', text, code: f.code, severity: f.severity, findings: [] }
|
|
198
|
+
byKey.set(key, g)
|
|
199
|
+
groups.push(g)
|
|
200
|
+
}
|
|
201
|
+
g.findings.push(f)
|
|
202
|
+
}
|
|
203
|
+
groups.forEach((g, i) => { g.glyph = GLYPHS[i] ?? String.fromCharCode(65 + i) })
|
|
204
|
+
return groups
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The lines a diagnosis renders to — data, so a caller can compose them into a larger
|
|
208
|
+
* report without going through a string. */
|
|
209
|
+
export function diagnosisLines(d: GrammarDiagnosis, opts: DiagnoseRenderOptions = {}): Line[] {
|
|
210
|
+
const limit = opts.limit ?? 20
|
|
211
|
+
const name = opts.name ?? 'grammar'
|
|
212
|
+
const width = opts.width ?? DEFAULT_WIDTH
|
|
213
|
+
const s = d.summary
|
|
214
|
+
const out: Line[] = []
|
|
215
|
+
|
|
216
|
+
// SUCCESS IS TWO LINES. It is the rendering people see most, and the one usually
|
|
217
|
+
// neglected into a wall of zero-valued tables.
|
|
218
|
+
if (d.ok && s.unanalysable === 0) {
|
|
219
|
+
const parts = [`${groupDigits(s.gated)}/${groupDigits(s.totalChoices)} choices gate on first char`]
|
|
220
|
+
if (s.recoverable > 0) parts.push(`${groupDigits(s.recoverable)} recoverable`)
|
|
221
|
+
if (s.accepted > 0) parts.push(`${groupDigits(s.accepted)} accepted`)
|
|
222
|
+
if (s.deferred > 0) parts.push(`${groupDigits(s.deferred)} deferred to the fusing artifact`)
|
|
223
|
+
if (s.staleAccepts > 0) parts.push(`${groupDigits(s.staleAccepts)} stale accept line(s) to prune`)
|
|
224
|
+
out.push([t('✓ ', TONE.good), t(name, TONE.strong), t(' — nothing to fix', TONE.good)])
|
|
225
|
+
out.push([t(` ${parts.join(' · ')}`, TONE.quiet)])
|
|
226
|
+
return out
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// NOTHING WAS EXAMINED. Rendered as its own report, not as the finding report with a
|
|
230
|
+
// warning bolted on. The finding report's every sentence — "N problems", "underlying
|
|
231
|
+
// cause", "fixing one fixes every choice listed under it", "none of this is a
|
|
232
|
+
// correctness bug" — is a claim about defects parseman FOUND, and there are none:
|
|
233
|
+
// there is one blocking finding per rule it could not read. Printed over an opaque
|
|
234
|
+
// artifact that produced `176 problems, 176 failing the check, 1 cause`, which reads
|
|
235
|
+
// as 176 discovered defects and means the tool inspected zero choices.
|
|
236
|
+
if (examinedNothing(d)) return unexaminedLines(d, name, width, limit)
|
|
237
|
+
|
|
238
|
+
const groups = groupByCause(d.findings)
|
|
239
|
+
const blocking = d.findings.filter(f => f.severity === 'blocking')
|
|
240
|
+
/** Findings that are things parseman FOUND — never the rules it could not read. */
|
|
241
|
+
const problems = d.findings.length - s.unanalysable
|
|
242
|
+
|
|
243
|
+
out.push([
|
|
244
|
+
t('✗ ', TONE.loud),
|
|
245
|
+
t(name, TONE.strong),
|
|
246
|
+
t(' — ', TONE.quiet),
|
|
247
|
+
// `findings` and `totalChoices` count different things — an anti-pattern is a
|
|
248
|
+
// finding about an ARM, not a choice — so they must not be phrased as a ratio. They
|
|
249
|
+
// were, and a two-arm grammar reported "3 of 1 choices".
|
|
250
|
+
//
|
|
251
|
+
// `unanalysable` is excluded from the problem count on purpose: it is not something
|
|
252
|
+
// parseman FOUND in the grammar, it is a rule parseman could not look at. Counting
|
|
253
|
+
// the two together is what let a report that inspected nothing print a confident
|
|
254
|
+
// problem tally. It is stated separately, on the PARTIAL line below.
|
|
255
|
+
t(`${groupDigits(problems)} problem${problems === 1 ? '' : 's'} `
|
|
256
|
+
+ `in ${groupDigits(s.totalChoices)} choice${s.totalChoices === 1 ? '' : 's'}`, TONE.loud),
|
|
257
|
+
])
|
|
258
|
+
out.push([
|
|
259
|
+
t(' '),
|
|
260
|
+
t(`${groupDigits(groups.length)} underlying cause${groups.length === 1 ? '' : 's'}`, TONE.strong),
|
|
261
|
+
t(groups.length < d.findings.length ? '; fixing one fixes every choice listed under it.' : '.', TONE.quiet),
|
|
262
|
+
])
|
|
263
|
+
for (const l of wrap(
|
|
264
|
+
(s.gated > 0
|
|
265
|
+
? `${groupDigits(s.gated)} other choice${s.gated === 1 ? '' : 's'} already `
|
|
266
|
+
+ `${s.gated === 1 ? 'picks' : 'pick'} the right `
|
|
267
|
+
+ 'alternative straight from the next character. '
|
|
268
|
+
: '')
|
|
269
|
+
+ 'None of this is a correctness bug — the grammar parses the same either way; it is work '
|
|
270
|
+
+ 'the parser does and did not need to.',
|
|
271
|
+
width - 2, ' ')) out.push([t(l, TONE.faint)])
|
|
272
|
+
// Unanalysable is called out at the top as well as in its group: "no findings" over a
|
|
273
|
+
// grammar that was never walked is precisely the failure being reported.
|
|
274
|
+
if (s.unanalysable > 0) {
|
|
275
|
+
out.push([t(` PARTIAL — ${groupDigits(s.unanalysable)} rule(s) could not be examined; `
|
|
276
|
+
+ 'an empty list below does NOT mean the grammar is clean.', TONE.warn)])
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ONE definition of the only term of art that survives, placed immediately before the
|
|
280
|
+
// first table that uses it. A reader meets `arm` here and nowhere earlier.
|
|
281
|
+
let defined = false
|
|
282
|
+
|
|
283
|
+
let shown = 0
|
|
284
|
+
for (const g of groups) {
|
|
285
|
+
const tone: Tone = g.severity === 'blocking' ? TONE.bad : TONE.warn
|
|
286
|
+
out.push(blank())
|
|
287
|
+
out.push(rule(width, TONE.frame))
|
|
288
|
+
const fixableHere = g.findings.filter(f => opts.fixable?.has(f.id) === true).length
|
|
289
|
+
out.push([
|
|
290
|
+
t(` ${g.glyph} `, tone),
|
|
291
|
+
t(`${groupDigits(g.findings.length)} `
|
|
292
|
+
+ `${g.findings.length === 1 ? CODE_LABEL[g.code]?.one ?? g.code : CODE_LABEL[g.code]?.many ?? g.code}`,
|
|
293
|
+
TONE.strong),
|
|
294
|
+
t(g.severity === 'blocking' ? ' fails the check' : ' worth knowing', TONE.faint),
|
|
295
|
+
])
|
|
296
|
+
for (const l of wrap(g.text, width - 6, '')) out.push([t(' '), t(l, TONE.quiet)])
|
|
297
|
+
if (!defined) {
|
|
298
|
+
defined = true
|
|
299
|
+
out.push(blank())
|
|
300
|
+
out.push([t(' Each numbered line below is one alternative of a choice — an "arm" — in the',
|
|
301
|
+
TONE.faint)])
|
|
302
|
+
out.push([t(' order the parser tries them.', TONE.faint)])
|
|
303
|
+
}
|
|
304
|
+
out.push(blank())
|
|
305
|
+
|
|
306
|
+
// ONE site per cause is expanded — its full arm ordering, and the corpus frame that
|
|
307
|
+
// illustrates the cause. The rest are a table: the same shape repeated thirteen
|
|
308
|
+
// times is what made the first version unreadable, and a reader who needs another
|
|
309
|
+
// site's ordering asks for it by id. `--limit` still governs how many are listed.
|
|
310
|
+
// +2 so the longest cell in a column still has a gap after it. Without it the
|
|
311
|
+
// widest row runs its term straight into the annotation, which is exactly the kind
|
|
312
|
+
// of ragged table this pass exists to remove.
|
|
313
|
+
const idW = Math.min(30, Math.max(...g.findings.map(f => f.id.length))) + 2
|
|
314
|
+
const headW = Math.min(34, Math.max(...g.findings.map(f => headlineOf(f, opts).term.length))) + 2
|
|
315
|
+
let expanded = false
|
|
316
|
+
for (const f of g.findings) {
|
|
317
|
+
if (shown >= limit) break
|
|
318
|
+
shown++
|
|
319
|
+
// Expand only when there IS an ordering to show. An anti-pattern finding names one
|
|
320
|
+
// arm and has no table, so an "expanded" version of it is just a lonelier line.
|
|
321
|
+
if (!expanded && opts.armFirstSets?.get(f.id) !== undefined) {
|
|
322
|
+
expanded = true
|
|
323
|
+
out.push(...siteLines(f, g, opts, true).lines)
|
|
324
|
+
if (g.findings.length > 1) out.push(blank())
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
const h = headlineOf(f, opts)
|
|
328
|
+
out.push([
|
|
329
|
+
t(` ${g.glyph} `, tone),
|
|
330
|
+
t(f.id, TONE.ident, idW),
|
|
331
|
+
t(h.arm, TONE.faint, 8),
|
|
332
|
+
t(h.term, TONE.strong, headW),
|
|
333
|
+
t(h.note, h.loud ? TONE.loud : TONE.faint),
|
|
334
|
+
// LAST, always: the wrench is an emoji and may occupy two terminal columns, so
|
|
335
|
+
// nothing may be aligned after it.
|
|
336
|
+
t(opts.fixable?.has(f.id) === true ? ' 🔧 fixable' : '', TONE.good),
|
|
337
|
+
])
|
|
338
|
+
}
|
|
339
|
+
if (shown >= limit) break
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (shown < d.findings.length) {
|
|
343
|
+
out.push(blank())
|
|
344
|
+
out.push([t(` … ${groupDigits(d.findings.length - shown)} more site(s) — `, TONE.quiet),
|
|
345
|
+
t(`--limit ${d.findings.length}`, TONE.strong),
|
|
346
|
+
t(' shows them, --json holds them all', TONE.quiet)])
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ONE accept line, carrying the whole set. A reader who wants to accept these wants a
|
|
350
|
+
// single pasteable thing, not one line per site saying the same thing differently.
|
|
351
|
+
if (!d.ok && d.acceptSnapshot.length > 0) {
|
|
352
|
+
out.push(blank())
|
|
353
|
+
out.push(rule(width, TONE.frame))
|
|
354
|
+
const snap = `{ accept: [${d.acceptSnapshot.map(i => `'${i}'`).join(', ')}] }`
|
|
355
|
+
out.push([t(' Meant to be this way? Pass this and they stop being reported:', TONE.quiet)])
|
|
356
|
+
for (const l of wrap(snap, width - 3, '')) out.push([t(' '), t(l, TONE.faint)])
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// THE FOOTER, in a linter's shape and for a linter's reason: after a hundred lines the
|
|
360
|
+
// header has scrolled away, and the last thing on screen is what a person actually
|
|
361
|
+
// reads. It carries the tally, the exit code IN WORDS (the number is for the gate, the
|
|
362
|
+
// sentence is for the human) and — only when a rewrite has actually been proved — the
|
|
363
|
+
// exact command that applies it.
|
|
364
|
+
out.push(blank())
|
|
365
|
+
const fixCount = opts.fixable?.size ?? 0
|
|
366
|
+
const parts = [
|
|
367
|
+
`${groupDigits(problems)} problem${problems === 1 ? '' : 's'}`,
|
|
368
|
+
`${groupDigits(blocking.length - s.unanalysable)} failing the check`,
|
|
369
|
+
`${groupDigits(groups.length)} cause${groups.length === 1 ? '' : 's'}`,
|
|
370
|
+
// Never folded into the tally above. A rule that could not be read is a hole in the
|
|
371
|
+
// measurement, and a footer is the last thing a reader sees.
|
|
372
|
+
...(s.unanalysable > 0 ? [`${groupDigits(s.unanalysable)} rule(s) NOT examined`] : []),
|
|
373
|
+
]
|
|
374
|
+
out.push([
|
|
375
|
+
t('✗ ', TONE.loud),
|
|
376
|
+
t(parts.join(', '), TONE.strong),
|
|
377
|
+
t(` · exiting 1 (problems found)`, TONE.faint),
|
|
378
|
+
])
|
|
379
|
+
if (fixCount > 0 && opts.fixCommand !== undefined) {
|
|
380
|
+
out.push([t(' 🔧 ', TONE.good),
|
|
381
|
+
t(`${groupDigits(fixCount)} of them can be fixed automatically. Run:`, TONE.good)])
|
|
382
|
+
for (const l of wrap(opts.fixCommand, width - 6, '')) out.push([t(' '), t(l, TONE.strong)])
|
|
383
|
+
for (const l of wrap('Each change is applied, the parser rebuilt and your files parsed again '
|
|
384
|
+
+ 'before it is offered, so nothing is suggested that has not been checked.',
|
|
385
|
+
width - 6, '')) out.push([t(' '), t(l, TONE.faint)])
|
|
386
|
+
}
|
|
387
|
+
return out
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** What each `Unanalysable.kind` means and what the reader can do about it. Stated once
|
|
391
|
+
* per kind, at the head of the group it labels — the same rule the cause groups follow. */
|
|
392
|
+
const KIND_BLURB: Record<string, string> = {
|
|
393
|
+
'fused-rule':
|
|
394
|
+
'These rules are compiled functions. Fusion lowers every rule to executable code and '
|
|
395
|
+
+ 'discards the combinator graph, so there is nothing left to read.\n'
|
|
396
|
+
+ 'To analyse them: point this at the grammar SOURCE, or at a grammar value that still '
|
|
397
|
+
+ 'carries its IR (a rules() map, or a compose() result — those keep their pieces).',
|
|
398
|
+
'opaque-artifact':
|
|
399
|
+
'These rules come from a precompiled artifact that carries rule functions rather than '
|
|
400
|
+
+ 're-lowerable IR, so its choices cannot be examined.\n'
|
|
401
|
+
+ 'To analyse them: recompile the contributing grammar so it carries IR — the macro '
|
|
402
|
+
+ 'emits IR by default.',
|
|
403
|
+
'not-a-combinator':
|
|
404
|
+
'The value handed to parseman was not a grammar it knows how to walk.\n'
|
|
405
|
+
+ 'To analyse it: pass a combinator, a rules() map, a compose() result, or name the '
|
|
406
|
+
+ 'export that holds one with --export.',
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The report for a run that examined NOTHING.
|
|
411
|
+
*
|
|
412
|
+
* Deliberately not the finding report with a banner on top. Everything that report says
|
|
413
|
+
* is a claim about defects found — the tally, the "underlying cause", the "fixing one
|
|
414
|
+
* fixes every choice listed under it", the "none of this is a correctness bug". None of
|
|
415
|
+
* it is true here, and printed together they read as a confident audit of a grammar the
|
|
416
|
+
* tool never opened.
|
|
417
|
+
*/
|
|
418
|
+
function unexaminedLines(
|
|
419
|
+
d: GrammarDiagnosis, name: string, width: number, limit: number,
|
|
420
|
+
): Line[] {
|
|
421
|
+
const out: Line[] = []
|
|
422
|
+
const items = d.gating.unanalysable
|
|
423
|
+
out.push([t('✗ ', TONE.loud), t(name, TONE.strong), t(' — ', TONE.quiet),
|
|
424
|
+
t('COULD NOT ANALYSE', TONE.loud)])
|
|
425
|
+
out.push([t(` 0 choices examined · ${groupDigits(items.length)} `
|
|
426
|
+
+ `rule${items.length === 1 ? '' : 's'} unreadable`, TONE.warn)])
|
|
427
|
+
for (const l of wrap(
|
|
428
|
+
'parseman has NO verdict about this grammar — not "clean", not "problems". Every line '
|
|
429
|
+
+ 'below is a rule it could not read, not a defect it found.',
|
|
430
|
+
width - 2, ' ')) out.push([t(l, TONE.faint)])
|
|
431
|
+
|
|
432
|
+
// Grouped by KIND: the kind is what a reader acts on, and one artifact usually
|
|
433
|
+
// contributes hundreds of rules for one reason.
|
|
434
|
+
const byKind = new Map<string, typeof items[number][]>()
|
|
435
|
+
for (const u of items) {
|
|
436
|
+
const g = byKind.get(u.kind)
|
|
437
|
+
if (g === undefined) byKind.set(u.kind, [u])
|
|
438
|
+
else g.push(u)
|
|
439
|
+
}
|
|
440
|
+
let shown = 0
|
|
441
|
+
for (const [kind, group] of byKind) {
|
|
442
|
+
out.push(blank())
|
|
443
|
+
out.push(rule(width, TONE.frame))
|
|
444
|
+
out.push([
|
|
445
|
+
t(' ■ ', TONE.warn),
|
|
446
|
+
t(`${groupDigits(group.length)} rule${group.length === 1 ? '' : 's'} could not be read`, TONE.strong),
|
|
447
|
+
t(` [${kind}]`, TONE.faint),
|
|
448
|
+
])
|
|
449
|
+
for (const l of wrap(KIND_BLURB[kind] ?? group[0]!.reason, width - 6, '')) {
|
|
450
|
+
out.push([t(' '), t(l, TONE.quiet)])
|
|
451
|
+
}
|
|
452
|
+
out.push(blank())
|
|
453
|
+
for (const u of group) {
|
|
454
|
+
if (shown >= limit) break
|
|
455
|
+
shown++
|
|
456
|
+
out.push([t(' '), t(u.rule, TONE.ident)])
|
|
457
|
+
}
|
|
458
|
+
if (shown >= limit) break
|
|
459
|
+
}
|
|
460
|
+
if (shown < items.length) {
|
|
461
|
+
out.push(blank())
|
|
462
|
+
out.push([t(` … ${groupDigits(items.length - shown)} more — `, TONE.quiet),
|
|
463
|
+
t(`--limit ${items.length}`, TONE.strong), t(' shows them, --json holds them all', TONE.quiet)])
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
out.push(blank())
|
|
467
|
+
// The exit code IN WORDS, and it is 2 — not 1. 1 means "measured, and it failed";
|
|
468
|
+
// this run did not measure. See the CLI's exit-code contract.
|
|
469
|
+
out.push([t('✗ ', TONE.loud),
|
|
470
|
+
t(`could not analyse — 0 of ${groupDigits(items.length)} rule(s) examined`, TONE.strong),
|
|
471
|
+
t(' · exiting 2 (analysis did not run)', TONE.faint)])
|
|
472
|
+
return out
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function renderDiagnosis(d: GrammarDiagnosis, opts: DiagnoseRenderOptions = {}): string {
|
|
476
|
+
return render(diagnosisLines(d, opts), opts)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The one arm a site is about, for the compact table rows.
|
|
481
|
+
*
|
|
482
|
+
* A reader scanning a group needs the site's NAME and the term that poisons it; the full
|
|
483
|
+
* ordering only matters once they start editing that rule, and the expanded exemplar
|
|
484
|
+
* above shows what that ordering looks like for this cause.
|
|
485
|
+
*/
|
|
486
|
+
function headlineOf(f: DiagnosisFinding, opts: DiagnoseRenderOptions): {
|
|
487
|
+
arm: string; term: string; note: string; loud: boolean
|
|
488
|
+
} {
|
|
489
|
+
const sets = opts.armFirstSets?.get(f.id)
|
|
490
|
+
const labels = opts.armLabels?.get(f.id)
|
|
491
|
+
const cost = opts.cost?.get(f.id)
|
|
492
|
+
const anyIdx = sets?.findIndex(x => x === 'ANY') ?? -1
|
|
493
|
+
if (anyIdx >= 0) {
|
|
494
|
+
const c = cost?.arms[anyIdx]
|
|
495
|
+
return {
|
|
496
|
+
arm: `arm ${anyIdx}`,
|
|
497
|
+
term: (labels?.[anyIdx] ?? '').slice(0, 34),
|
|
498
|
+
note: c === undefined
|
|
499
|
+
? 'any character — never skippable'
|
|
500
|
+
: `same — tried at all ${groupDigits(c.positions)}`,
|
|
501
|
+
loud: true,
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const overlap = f.details.map(d => d.split('\n')[0]!).find(x => x.includes('overlap on'))
|
|
505
|
+
if (overlap !== undefined) {
|
|
506
|
+
const m = /arm\[(\d+)\] ∩ arm\[(\d+)\] overlap on (.*)$/.exec(overlap)
|
|
507
|
+
if (m !== null) {
|
|
508
|
+
return { arm: `arm ${m[1]}`, term: `and arm ${m[2]}`, note: `can both start with ${m[3]}`, loud: false }
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const m = /^(.*)#arm(\d+)$/.exec(f.id)
|
|
512
|
+
if (m !== null) {
|
|
513
|
+
// The specific thing found at THIS site — the word, from the finding's own message.
|
|
514
|
+
const word = /`([^`]+)`/.exec(f.message)?.[1]
|
|
515
|
+
return { arm: `arm ${m[2]}`, term: `of ${m[1]}`, note: word === undefined ? '' : `matches \`${word}\``, loud: false }
|
|
516
|
+
}
|
|
517
|
+
return { arm: '', term: '', note: f.message.split('\n')[0]!.slice(0, 40), loud: false }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** One site under its cause: the arm ordering, and nothing the group already said. */
|
|
521
|
+
function siteLines(
|
|
522
|
+
f: DiagnosisFinding,
|
|
523
|
+
g: CauseGroup,
|
|
524
|
+
opts: DiagnoseRenderOptions,
|
|
525
|
+
wantFrame: boolean,
|
|
526
|
+
): { lines: Line[]; framed: boolean } {
|
|
527
|
+
const out: Line[] = []
|
|
528
|
+
const cost = opts.cost?.get(f.id)
|
|
529
|
+
const sets = opts.armFirstSets?.get(f.id)
|
|
530
|
+
const labels = opts.armLabels?.get(f.id)
|
|
531
|
+
let framed = false
|
|
532
|
+
|
|
533
|
+
out.push([
|
|
534
|
+
t(` ${g.glyph} `, f.severity === 'blocking' ? TONE.bad : TONE.warn),
|
|
535
|
+
t(f.id, TONE.ident),
|
|
536
|
+
t(cost === undefined
|
|
537
|
+
? ''
|
|
538
|
+
: ` — reached at ${groupDigits(cost.positions)} places in your corpus`,
|
|
539
|
+
TONE.faint),
|
|
540
|
+
t(suggestionsOf(f).length > 1 ? ' (+ another cause)' : '', TONE.warn),
|
|
541
|
+
t(opts.fixable?.has(f.id) === true ? ' 🔧 fixable' : '', TONE.good),
|
|
542
|
+
])
|
|
543
|
+
|
|
544
|
+
// WORLD 1 — the grammar site: the ordering, one column per meaning. This is the part
|
|
545
|
+
// worth keeping per site; everything that used to surround it was the repetition.
|
|
546
|
+
if (sets !== undefined) {
|
|
547
|
+
const lw = Math.min(26, Math.max(8, ...(labels ?? ['']).map(l => l.length)))
|
|
548
|
+
sets.forEach((fs, i) => {
|
|
549
|
+
const armCost = cost?.arms[i]
|
|
550
|
+
const any = fs === 'ANY'
|
|
551
|
+
// Every column says what it MEANS. `'('` and `ANY` were the model's vocabulary,
|
|
552
|
+
// not a sentence: a reader had to already know what a first-set was to read them.
|
|
553
|
+
const starts = startsWithPhrase(fs)
|
|
554
|
+
const note = armCost === undefined
|
|
555
|
+
? ''
|
|
556
|
+
: any
|
|
557
|
+
? `→ tried at all ${groupDigits(cost!.positions)}`
|
|
558
|
+
: `→ could match at ${groupDigits(armCost.positions)}`
|
|
559
|
+
out.push([
|
|
560
|
+
t(' ', undefined),
|
|
561
|
+
t(`arm ${pad(String(i), 2)}`, any ? TONE.bad : TONE.faint, 8),
|
|
562
|
+
t(' '),
|
|
563
|
+
t((labels?.[i] ?? '').slice(0, lw), any ? TONE.strong : TONE.quiet, lw),
|
|
564
|
+
t(' '),
|
|
565
|
+
t(starts, any ? TONE.loud : TONE.quiet, 29),
|
|
566
|
+
t(note, any ? TONE.loud : TONE.faint),
|
|
567
|
+
])
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
// THE CONSEQUENCE, spelled out. The table above is an observation; this is why the
|
|
571
|
+
// reader should care, and it is the entire point of the tool.
|
|
572
|
+
if (sets !== undefined && cost !== undefined) {
|
|
573
|
+
const anyIdx = sets.findIndex(x => x === 'ANY')
|
|
574
|
+
if (anyIdx >= 0) {
|
|
575
|
+
for (const l of wrap(
|
|
576
|
+
`Because arm ${anyIdx} can begin with any character, no single-character test can rule it `
|
|
577
|
+
+ `out. At all ${groupDigits(cost.positions)} of those places the parser has to enter it — set `
|
|
578
|
+
+ 'up, try, undo — instead of skipping it for nothing.',
|
|
579
|
+
(opts.width ?? DEFAULT_WIDTH) - 7, '')) out.push([t(' '), t(l, TONE.quiet)])
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Overlaps name a PAIR, which the arm table alone does not show.
|
|
584
|
+
for (const detail of f.details) {
|
|
585
|
+
const first = detail.split('\n')[0]!
|
|
586
|
+
if (first.includes('overlap on')) {
|
|
587
|
+
const m = /arm\[(\d+)\] ∩ arm\[(\d+)\] overlap on (.*)$/.exec(first)
|
|
588
|
+
const text = m === null
|
|
589
|
+
? first
|
|
590
|
+
: `arm ${m[1]} and arm ${m[2]} can both start with ${m[3]}, so that character cannot tell `
|
|
591
|
+
+ 'the parser which to try'
|
|
592
|
+
for (const l of wrap(text, (opts.width ?? DEFAULT_WIDTH) - 7, '')) out.push([t(' '), t(l, TONE.warn)])
|
|
593
|
+
}
|
|
594
|
+
else if (sets === undefined && !detail.includes('\nfix: ')) {
|
|
595
|
+
for (const l of wrap(first, (opts.width ?? DEFAULT_WIDTH) - 6, '')) out.push([t(' '), t(l)])
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// WORLD 2 — the input that pays for it, drawn once per cause. Point at a place a REAL
|
|
600
|
+
// arm wants, not at byte 0: an ANY arm's "first site" is always the first character of
|
|
601
|
+
// the corpus, which tells the reader nothing.
|
|
602
|
+
if (wantFrame && cost !== undefined) {
|
|
603
|
+
const broad = cost.arms.find(a => a.any)
|
|
604
|
+
const concrete = cost.arms.find(a => !a.any && a.firstSite !== undefined)
|
|
605
|
+
const site = (concrete ?? cost.arms.find(a => a.firstSite !== undefined))?.firstSite
|
|
606
|
+
if (site !== undefined) {
|
|
607
|
+
out.push(...codeFrame({
|
|
608
|
+
path: site.sample,
|
|
609
|
+
fullPath: opts.corpusRoot === undefined ? site.sample : `${opts.corpusRoot}/${site.sample}`,
|
|
610
|
+
line: site.line,
|
|
611
|
+
column: site.column,
|
|
612
|
+
lineText: site.lineText.replace(/\t/g, ' '),
|
|
613
|
+
message: broad === undefined
|
|
614
|
+
? 'one of those places in your corpus'
|
|
615
|
+
: `one of the ${groupDigits(cost.positions)} places, in your own input`,
|
|
616
|
+
shortMessage: broad !== undefined && concrete !== undefined
|
|
617
|
+
? `arm ${concrete.index} matches here; arm ${broad.index} is entered first anyway`
|
|
618
|
+
: 'the first place in your corpus this choice is reached',
|
|
619
|
+
type: 'warning',
|
|
620
|
+
}, opts, ' '))
|
|
621
|
+
framed = true
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return { lines: out, framed }
|
|
625
|
+
}
|