parseman 0.36.0 → 0.38.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 +34 -19
- package/dist/analysis/duplication.d.ts +421 -0
- package/dist/analysis/duplication.d.ts.map +1 -0
- package/dist/analysis/gating.d.ts +25 -0
- package/dist/analysis/gating.d.ts.map +1 -1
- package/dist/analysis/grammar.d.ts +12 -0
- package/dist/analysis/grammar.d.ts.map +1 -0
- package/dist/combinators/keywords.d.ts +9 -8
- package/dist/combinators/keywords.d.ts.map +1 -1
- package/dist/combinators/parser.d.ts +26 -0
- package/dist/combinators/parser.d.ts.map +1 -1
- package/dist/compiler/build-arity.d.ts +12 -0
- package/dist/compiler/build-arity.d.ts.map +1 -1
- package/dist/compiler/codegen.d.ts +43 -1
- package/dist/compiler/codegen.d.ts.map +1 -1
- package/dist/compiler/linker.d.ts +64 -7
- package/dist/compiler/linker.d.ts.map +1 -1
- package/dist/cst/host-mode.d.ts +43 -0
- package/dist/cst/host-mode.d.ts.map +1 -0
- package/dist/functional/doc.d.ts.map +1 -1
- package/dist/functional/run.d.ts.map +1 -1
- package/dist/index.cjs +1657 -64
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1657 -64
- package/dist/index.js.map +4 -4
- package/dist/language-service/index.cjs +37 -1
- package/dist/language-service/index.cjs.map +3 -3
- package/dist/language-service/index.js +37 -1
- package/dist/language-service/index.js.map +3 -3
- package/dist/oracle/corpus.d.ts +32 -0
- package/dist/oracle/corpus.d.ts.map +1 -0
- package/dist/oracle/digest.d.ts +21 -0
- package/dist/oracle/digest.d.ts.map +1 -0
- package/dist/oracle/identity.d.ts +117 -0
- package/dist/oracle/identity.d.ts.map +1 -0
- package/dist/oracle/index.cjs +423 -0
- package/dist/oracle/index.cjs.map +7 -0
- package/dist/oracle/index.d.ts +37 -0
- package/dist/oracle/index.d.ts.map +1 -0
- package/dist/oracle/index.js +400 -0
- package/dist/oracle/index.js.map +7 -0
- package/dist/plugin/evaluator.d.ts +2 -1
- package/dist/plugin/evaluator.d.ts.map +1 -1
- package/dist/plugin/index.cjs +1696 -85
- package/dist/plugin/index.cjs.map +4 -4
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +1696 -85
- package/dist/plugin/index.js.map +4 -4
- package/dist/run/index.cjs +23 -0
- package/dist/run/index.cjs.map +3 -3
- package/dist/run/index.js +23 -0
- package/dist/run/index.js.map +3 -3
- package/dist/spec/index.cjs +3001 -60
- package/dist/spec/index.cjs.map +4 -4
- package/dist/spec/index.js +3001 -60
- package/dist/spec/index.js.map +4 -4
- package/dist/spec/model.d.ts.map +1 -1
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +10 -3
package/dist/index.cjs
CHANGED
|
@@ -26,8 +26,12 @@ __export(index_exports, {
|
|
|
26
26
|
absoluteSpanCST: () => absoluteSpanCST,
|
|
27
27
|
absolutize: () => absolutize,
|
|
28
28
|
absolutizeCST: () => absolutizeCST,
|
|
29
|
+
alternationGroups: () => alternationGroups,
|
|
30
|
+
analyzeDuplication: () => analyzeDuplication,
|
|
31
|
+
analyzeDuplicationRules: () => analyzeDuplicationRules,
|
|
29
32
|
analyzeGating: () => analyzeGating,
|
|
30
33
|
analyzeGatingRules: () => analyzeGatingRules,
|
|
34
|
+
analyzeGrammarGating: () => analyzeGrammarGating,
|
|
31
35
|
annotateSpan: () => annotateSpan,
|
|
32
36
|
applyEdit: () => applyEdit,
|
|
33
37
|
attempt: () => attempt,
|
|
@@ -36,6 +40,7 @@ __export(index_exports, {
|
|
|
36
40
|
buildLineIndex: () => buildLineIndex,
|
|
37
41
|
buildOffsetIndex: () => buildOffsetIndex,
|
|
38
42
|
buildTriviaIndex: () => buildTriviaIndex,
|
|
43
|
+
charClassMembers: () => charClassMembers,
|
|
39
44
|
choice: () => choice,
|
|
40
45
|
collectLeafSlots: () => collectLeafSlots,
|
|
41
46
|
commentsIn: () => commentsIn,
|
|
@@ -50,9 +55,12 @@ __export(index_exports, {
|
|
|
50
55
|
createGrammarTraceSink: () => createGrammarTraceSink,
|
|
51
56
|
createVisitor: () => createVisitor,
|
|
52
57
|
cstBuildHost: () => cstBuildHost,
|
|
58
|
+
duplicationFindingCount: () => duplicationFindingCount,
|
|
53
59
|
expect: () => expect,
|
|
60
|
+
extractCharClasses: () => extractCharClasses,
|
|
54
61
|
field: () => field,
|
|
55
62
|
firstSetToString: () => firstSetToString,
|
|
63
|
+
formatDuplicationFindings: () => formatDuplicationFindings,
|
|
56
64
|
formatGatingWarnings: () => formatGatingWarnings,
|
|
57
65
|
gapIsSignificant: () => gapIsSignificant,
|
|
58
66
|
gapText: () => gapText,
|
|
@@ -62,6 +70,8 @@ __export(index_exports, {
|
|
|
62
70
|
indentMixed: () => indentMixed,
|
|
63
71
|
indentWidth: () => indentWidth,
|
|
64
72
|
isParseError: () => isParseError,
|
|
73
|
+
keywordAlternationHazards: () => keywordAlternationHazards,
|
|
74
|
+
keywordRegexShape: () => keywordRegexShape,
|
|
65
75
|
keywords: () => keywords,
|
|
66
76
|
label: () => label,
|
|
67
77
|
leaf: () => leaf,
|
|
@@ -92,6 +102,7 @@ __export(index_exports, {
|
|
|
92
102
|
sepBy: () => sepBy,
|
|
93
103
|
sequence: () => sequence,
|
|
94
104
|
shiftAbsolute: () => shiftAbsolute,
|
|
105
|
+
siteToString: () => siteToString,
|
|
95
106
|
skip: () => skip,
|
|
96
107
|
token: () => token,
|
|
97
108
|
transform: () => transform,
|
|
@@ -1025,8 +1036,10 @@ function word(str, boundaryOrOpts, opts) {
|
|
|
1025
1036
|
const rest = typeof boundaryOrOpts === "object" && boundaryOrOpts !== null ? boundaryOrOpts : opts;
|
|
1026
1037
|
return keywords([str], { ...rest, boundary });
|
|
1027
1038
|
}
|
|
1028
|
-
function makeWord(
|
|
1029
|
-
|
|
1039
|
+
function makeWord(boundaryOrOpts = "_0-9A-Za-z", opts) {
|
|
1040
|
+
const boundary = typeof boundaryOrOpts === "string" ? boundaryOrOpts : "_0-9A-Za-z";
|
|
1041
|
+
const rest = typeof boundaryOrOpts === "object" && boundaryOrOpts !== null ? boundaryOrOpts : opts;
|
|
1042
|
+
return (str) => keywords([str], { ...rest, boundary });
|
|
1030
1043
|
}
|
|
1031
1044
|
function escapeRe(s) {
|
|
1032
1045
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -2463,6 +2476,16 @@ function tagRule(r, key) {
|
|
|
2463
2476
|
r._ruleName = key;
|
|
2464
2477
|
if (r._def.tag === "node" && r._def.type === void 0) r._def.type = key;
|
|
2465
2478
|
}
|
|
2479
|
+
function isDefinableRef(v2) {
|
|
2480
|
+
return !!v2 && typeof v2 === "object" && "_def" in v2 && v2._def.tag === "lazy" && typeof v2.define === "function";
|
|
2481
|
+
}
|
|
2482
|
+
function ruleNameOf(r) {
|
|
2483
|
+
return r._ruleName;
|
|
2484
|
+
}
|
|
2485
|
+
function isNamedRuleRefForAnotherRule(r, key) {
|
|
2486
|
+
const name = r._def.tag === "lazy" ? ruleNameOf(r) : void 0;
|
|
2487
|
+
return name !== void 0 && name !== key;
|
|
2488
|
+
}
|
|
2466
2489
|
function rules(a, b) {
|
|
2467
2490
|
const factory = typeof a === "function" ? a : b;
|
|
2468
2491
|
const options = typeof a === "function" ? b : a;
|
|
@@ -2483,11 +2506,21 @@ function rules(a, b) {
|
|
|
2483
2506
|
for (const key of Object.keys(definitions)) {
|
|
2484
2507
|
const placeholder = cache[key];
|
|
2485
2508
|
const parser2 = definitions[key];
|
|
2486
|
-
if (placeholder
|
|
2487
|
-
|
|
2509
|
+
if (placeholder === parser2) {
|
|
2510
|
+
throw new Error(`rules(): rule "${key}" cannot be a direct alias to itself`);
|
|
2511
|
+
}
|
|
2512
|
+
if (isDefinableRef(placeholder)) {
|
|
2513
|
+
if (!isNamedRuleRefForAnotherRule(parser2, key)) tagRule(parser2, key);
|
|
2488
2514
|
placeholder.define(parser2);
|
|
2489
2515
|
placeholder._meta.firstSet = parser2._meta.firstSet;
|
|
2490
2516
|
placeholder._meta.canMatchNewline = parser2._meta.canMatchNewline;
|
|
2517
|
+
} else if (isNamedRuleRefForAnotherRule(parser2, key)) {
|
|
2518
|
+
const alias = ref();
|
|
2519
|
+
tagRule(alias, key);
|
|
2520
|
+
alias.define(parser2);
|
|
2521
|
+
alias._meta.firstSet = parser2._meta.firstSet;
|
|
2522
|
+
alias._meta.canMatchNewline = parser2._meta.canMatchNewline;
|
|
2523
|
+
cache[key] = alias;
|
|
2491
2524
|
} else {
|
|
2492
2525
|
tagRule(parser2, key);
|
|
2493
2526
|
cache[key] = parser2;
|
|
@@ -2505,6 +2538,12 @@ function rules(a, b) {
|
|
|
2505
2538
|
if (rule && !rule._meta.isTrivia) rule._meta.grammarScanSkip = options.scanSkip;
|
|
2506
2539
|
}
|
|
2507
2540
|
}
|
|
2541
|
+
if (options?.hostMode === "cst") {
|
|
2542
|
+
for (const key of Object.keys(definitions)) {
|
|
2543
|
+
const rule = cache[key];
|
|
2544
|
+
if (rule && !rule._meta.isTrivia) rule._meta.grammarHostMode = "cst";
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2508
2547
|
for (const key of Object.keys(definitions)) {
|
|
2509
2548
|
markUnusedValues(cache[key]);
|
|
2510
2549
|
}
|
|
@@ -2563,6 +2602,9 @@ function peek(combinator) {
|
|
|
2563
2602
|
}
|
|
2564
2603
|
|
|
2565
2604
|
// src/compiler/build-arity.ts
|
|
2605
|
+
function cstOutputHost(build) {
|
|
2606
|
+
return build?._parsemanCstOutput === true;
|
|
2607
|
+
}
|
|
2566
2608
|
var PARAM_LIST_RE = (
|
|
2567
2609
|
// (params) => ... | function name?(params) ... | single-ident arrow `x => ...`
|
|
2568
2610
|
/^(?:function\b[^(]*\(([^)]*)\)|\(([^)]*)\)\s*=>|([A-Za-z_$][\w$]*)\s*=>)/
|
|
@@ -2812,7 +2854,8 @@ function node(typeOrCombinator, combinatorOrBuild, buildOrOpts, maybeOpts) {
|
|
|
2812
2854
|
const def = unwrap || collapse || captureTrivia || trailingTrivia ? { ...baseDef, ...unwrap ? { unwrap: true } : {}, ...collapse ? { collapse: true } : {}, ...captureTrivia ? { captureTrivia: true } : {}, ...trailingTrivia ? { trailingTrivia: true } : {} } : baseDef;
|
|
2813
2855
|
const capturesTrivia = captureTrivia || trailingTrivia || (build ? buildReadsTrivia(def) : true);
|
|
2814
2856
|
const clonesState = build ? buildReadsState(def) : true;
|
|
2815
|
-
const
|
|
2857
|
+
const hasOwnFields = parserHasOwnFields(combinator);
|
|
2858
|
+
const capturesFields = hasOwnFields && (build ? buildReadsFields(def) : true);
|
|
2816
2859
|
const guardable = combinator._meta.firstSet.kind !== "any" && !matchesEmpty(combinator);
|
|
2817
2860
|
let failExpected;
|
|
2818
2861
|
return {
|
|
@@ -2827,28 +2870,32 @@ function node(typeOrCombinator, combinatorOrBuild, buildOrOpts, maybeOpts) {
|
|
|
2827
2870
|
}
|
|
2828
2871
|
return { ok: false, expected: failExpected, span: { start: pos, end: pos } };
|
|
2829
2872
|
}
|
|
2873
|
+
const hostCst = build !== void 0 && cstOutputHost(ctx.build);
|
|
2874
|
+
const effTrivia = capturesTrivia || hostCst;
|
|
2875
|
+
const effFields = capturesFields || hostCst && hasOwnFields;
|
|
2830
2876
|
const saved = beginCstNodeCapture(ctx);
|
|
2831
2877
|
const savedFields = ctx._fields;
|
|
2832
|
-
ctx._fields =
|
|
2878
|
+
ctx._fields = effFields ? [] : void 0;
|
|
2833
2879
|
const savedMask = ctx._triviaCaptureMask;
|
|
2834
2880
|
if (build === void 0 && ctx.build?._parsemanTriviaKinds !== void 0 && def.type !== void 0) {
|
|
2835
2881
|
ctx._triviaCaptureMask = ctx.build._parsemanTriviaKinds(def.type);
|
|
2836
2882
|
}
|
|
2837
|
-
if (!
|
|
2883
|
+
if (!effTrivia) ctx.captureTrivia = false;
|
|
2838
2884
|
let r = combinator.parse(input, pos, ctx);
|
|
2839
2885
|
if (r.ok && trailingTrivia && ctx.trivia) {
|
|
2840
2886
|
const end = consumeTrivia(input, r.span.end, ctx);
|
|
2841
2887
|
r = { ...r, span: { start: r.span.start, end } };
|
|
2842
2888
|
}
|
|
2843
|
-
const fields =
|
|
2889
|
+
const fields = effFields ? buildFieldMap(ctx._fields) : void 0;
|
|
2844
2890
|
ctx._fields = savedFields;
|
|
2845
2891
|
ctx._triviaCaptureMask = savedMask;
|
|
2846
2892
|
const { children: children2, rawChildren, triviaLog } = endCstNodeCapture(ctx, saved);
|
|
2847
2893
|
if (!r.ok) return r;
|
|
2848
2894
|
const st = clonesState && ctx.state !== void 0 ? Object.assign({}, ctx.state) : void 0;
|
|
2849
2895
|
const nodeType = def.type ?? missingInferredType();
|
|
2850
|
-
const cstOutput = ctx.build
|
|
2851
|
-
const
|
|
2896
|
+
const cstOutput = cstOutputHost(ctx.build);
|
|
2897
|
+
const hostState = clonesState ? st : ctx.state !== void 0 ? Object.assign({}, ctx.state) : void 0;
|
|
2898
|
+
const built = unwrap && children2.length === 1 ? unwrapChild(children2[0]) : collapse && children2.length === 1 ? children2[0] : !build && ctx.build?._parsemanCstCollapse && children2.length === 1 && rawChildren.length === 1 && ctx.build._parsemanCstCollapse(nodeType, children2[0], children2, rawChildren) ? children2[0] : build ? cstOutput && ctx.build ? ctx.build(nodeType, children2, fields, r.span, rawChildren, triviaLog, hostState) : build(children2, fields, r.span, rawChildren, triviaLog, st) : ctx.build ? ctx.build(nodeType, children2, fields, r.span, rawChildren, triviaLog, st) : { _tag: "node", type: nodeType, span: { start: r.span.start, end: r.span.end }, state: st ?? null, children: children2 };
|
|
2852
2899
|
const rawEntry = isCstChild(built) ? built : { _tag: "leaf", value: typeof built === "string" ? built : typeof built === "object" && built !== null ? input.slice(r.span.start, r.span.end) : "", span: r.span };
|
|
2853
2900
|
if (saved.buf !== void 0 || saved.ch !== void 0) {
|
|
2854
2901
|
pushCstChild(ctx, built, rawEntry);
|
|
@@ -3126,7 +3173,24 @@ function leaf(root, fn) {
|
|
|
3126
3173
|
}
|
|
3127
3174
|
|
|
3128
3175
|
// src/version.ts
|
|
3129
|
-
var PARSEMAN_VERSION = "0.
|
|
3176
|
+
var PARSEMAN_VERSION = "0.38.0";
|
|
3177
|
+
|
|
3178
|
+
// src/cst/host-mode.ts
|
|
3179
|
+
var FUSED_HOST_MODE = /* @__PURE__ */ Symbol.for("parseman.fusedHostMode");
|
|
3180
|
+
var FUSED_HOST_ELIDED = /* @__PURE__ */ Symbol.for("parseman.fusedHostElided");
|
|
3181
|
+
function assertHostModeCompatible(mode, build, hostBranchElided = true) {
|
|
3182
|
+
const isCstHost = build?._parsemanCstOutput === true;
|
|
3183
|
+
if (mode === "ast" && isCstHost && hostBranchElided) {
|
|
3184
|
+
throw new Error(
|
|
3185
|
+
"parseman: this parser was compiled for host mode \"ast\" (the default), so its nodes build through their own `build` callbacks and no positioned-CST branch was emitted. It cannot be driven with a positioned-CST host (cstBuildHost / a language-service host). Compile a second artifact from the SAME grammar with `compile(grammar, { hostMode: 'cst' })` \u2014 or, under the macro, a second `rules({ hostMode: 'cst' }, factory)` call site \u2014 and drive that one instead."
|
|
3186
|
+
);
|
|
3187
|
+
}
|
|
3188
|
+
if (mode === "cst" && !isCstHost) {
|
|
3189
|
+
throw new Error(
|
|
3190
|
+
'parseman: this parser was compiled for host mode "cst", so every node builds through a positioned-CST `ctx.build` host. Pass one (e.g. `{ build: cstBuildHost }`), or use an artifact compiled with the default `hostMode: "ast"` to get the grammar\'s own AST.'
|
|
3191
|
+
);
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3130
3194
|
|
|
3131
3195
|
// src/analysis/gating.ts
|
|
3132
3196
|
var isAny = (fs) => fs.kind === "any";
|
|
@@ -3163,7 +3227,7 @@ function firstSetToString(fs) {
|
|
|
3163
3227
|
if (fs.kind === "empty") return "(empty)";
|
|
3164
3228
|
return fs.ranges.map((r) => r.lo === r.hi ? ch(r.lo) : `${ch(r.lo)}-${ch(r.hi)}`).join(",");
|
|
3165
3229
|
}
|
|
3166
|
-
var
|
|
3230
|
+
var ruleNameOf2 = (p) => p._ruleName;
|
|
3167
3231
|
var SUGGESTIONS = {
|
|
3168
3232
|
"broad-recognizer": "if this arm leads with a keyword regex, use word('kw', boundary) / keywords([...]) \u2014 they expose an EXACT, gating first-set and lower to the same charCodeAt scan. A genuine scanTo fallback is fine; accept it in the gating snapshot if intentional.",
|
|
3169
3233
|
"leading-not": "let the arm lead with its actual consuming terminal (first-sets gate it automatically); keep not(...) only as a TRAILING boundary. Do not hand-roll not(not(...)) to fake gating \u2014 use peek(X), which is zero-width AND carries X first-set.",
|
|
@@ -3321,17 +3385,40 @@ function detectAntiPatterns(rule, arms) {
|
|
|
3321
3385
|
return out;
|
|
3322
3386
|
}
|
|
3323
3387
|
function analyzeGating(entry, opts) {
|
|
3324
|
-
return analyzeGatingRules([[
|
|
3388
|
+
return analyzeGatingRules([[ruleNameOf2(entry) ?? opts?.entryName ?? "<entry>", entry]], opts);
|
|
3325
3389
|
}
|
|
3326
3390
|
function analyzeGatingRules(ruleMap, opts) {
|
|
3327
3391
|
const raw = [];
|
|
3328
3392
|
const antiPatterns = [];
|
|
3393
|
+
const unanalysable = [];
|
|
3394
|
+
const seenUnanalysable = /* @__PURE__ */ new Set();
|
|
3329
3395
|
const visited = /* @__PURE__ */ new Set();
|
|
3396
|
+
const noteUnanalysable = (u) => {
|
|
3397
|
+
const key = `${u.rule}\0${u.kind}`;
|
|
3398
|
+
if (seenUnanalysable.has(key)) return;
|
|
3399
|
+
seenUnanalysable.add(key);
|
|
3400
|
+
unanalysable.push(u);
|
|
3401
|
+
};
|
|
3330
3402
|
const visit = (p, enclosingRule) => {
|
|
3331
3403
|
if (visited.has(p)) return;
|
|
3332
3404
|
visited.add(p);
|
|
3333
|
-
const
|
|
3334
|
-
|
|
3405
|
+
const def = p?._def;
|
|
3406
|
+
if (def === null || typeof def !== "object") {
|
|
3407
|
+
noteUnanalysable(
|
|
3408
|
+
typeof p === "function" ? {
|
|
3409
|
+
rule: enclosingRule,
|
|
3410
|
+
kind: "fused-rule",
|
|
3411
|
+
reason: "fused rule function \u2014 a compose()/fuse result has no combinator graph. Analyze the composed grammar itself (analyzeGrammarGating) so the carried IR is re-lowered first."
|
|
3412
|
+
} : {
|
|
3413
|
+
rule: enclosingRule,
|
|
3414
|
+
kind: "not-a-combinator",
|
|
3415
|
+
reason: `value of type ${p === null ? "null" : typeof p} is not a combinator and carries no _def descriptor.`
|
|
3416
|
+
}
|
|
3417
|
+
);
|
|
3418
|
+
return;
|
|
3419
|
+
}
|
|
3420
|
+
const d = def;
|
|
3421
|
+
const rule = ruleNameOf2(p) ?? enclosingRule;
|
|
3335
3422
|
if (d.tag === "choice") {
|
|
3336
3423
|
raw.push({ g: analyzeChoice(p, d, rule, opts?.resolveRef), rule });
|
|
3337
3424
|
antiPatterns.push(...detectAntiPatterns(rule, d.parsers));
|
|
@@ -3350,7 +3437,7 @@ function analyzeGatingRules(ruleMap, opts) {
|
|
|
3350
3437
|
}
|
|
3351
3438
|
for (const k of kids) visit(k, rule);
|
|
3352
3439
|
};
|
|
3353
|
-
for (const [name, root] of ruleMap) visit(root,
|
|
3440
|
+
for (const [name, root] of ruleMap) visit(root, ruleNameOf2(root) ?? name);
|
|
3354
3441
|
const perRule = /* @__PURE__ */ new Map();
|
|
3355
3442
|
for (const r of raw) perRule.set(r.rule, (perRule.get(r.rule) ?? 0) + 1);
|
|
3356
3443
|
const seenInRule = /* @__PURE__ */ new Map();
|
|
@@ -3370,7 +3457,18 @@ function analyzeGatingRules(ruleMap, opts) {
|
|
|
3370
3457
|
const accepted = choices.filter((c) => c.gates === "no" && c.accepted);
|
|
3371
3458
|
const deferred = choices.filter((c) => c.gates === "no" && !c.accepted && c.deferred);
|
|
3372
3459
|
const acceptedUnused = [...accept].filter((id) => !usedAccept.has(id));
|
|
3373
|
-
return {
|
|
3460
|
+
return {
|
|
3461
|
+
totalChoices: choices.length,
|
|
3462
|
+
gated,
|
|
3463
|
+
recoverable,
|
|
3464
|
+
unanalysable,
|
|
3465
|
+
ungated,
|
|
3466
|
+
accepted,
|
|
3467
|
+
deferred,
|
|
3468
|
+
acceptedUnused,
|
|
3469
|
+
choices,
|
|
3470
|
+
antiPatterns
|
|
3471
|
+
};
|
|
3374
3472
|
}
|
|
3375
3473
|
function analyzeChoice(p, d, rule, resolve) {
|
|
3376
3474
|
const arms = d.parsers;
|
|
@@ -3407,6 +3505,13 @@ function analyzeChoice(p, d, rule, resolve) {
|
|
|
3407
3505
|
}
|
|
3408
3506
|
function formatGatingWarnings(report) {
|
|
3409
3507
|
const lines = [];
|
|
3508
|
+
if (report.unanalysable.length > 0) {
|
|
3509
|
+
lines.push(
|
|
3510
|
+
`parseman gating: ${report.unanalysable.length} rule(s) UNANALYSABLE \u2014 this report is PARTIAL (${report.totalChoices} choice(s) examined). An empty finding list below does NOT mean the grammar is clean.`
|
|
3511
|
+
);
|
|
3512
|
+
for (const u of report.unanalysable)
|
|
3513
|
+
lines.push(` \xB7 ${u.rule} [${u.kind}]: ${u.reason}`);
|
|
3514
|
+
}
|
|
3410
3515
|
for (const c of report.ungated) {
|
|
3411
3516
|
lines.push(`parseman gating: choice @ ${c.id} is UNGATED [${c.strategy}] \u2014 no first-char dispatch; every position speculatively enters doomed arms.`);
|
|
3412
3517
|
for (const a of c.anyArms)
|
|
@@ -3422,6 +3527,1284 @@ function formatGatingWarnings(report) {
|
|
|
3422
3527
|
return lines;
|
|
3423
3528
|
}
|
|
3424
3529
|
|
|
3530
|
+
// src/analysis/duplication.ts
|
|
3531
|
+
var siteToString = (s) => s.path === "" ? s.rule : `${s.rule} \u203A ${s.path}`;
|
|
3532
|
+
function childrenOf(d) {
|
|
3533
|
+
switch (d.tag) {
|
|
3534
|
+
case "sequence":
|
|
3535
|
+
case "choice":
|
|
3536
|
+
return d.parsers;
|
|
3537
|
+
case "skip":
|
|
3538
|
+
return [d.main, d.skipped];
|
|
3539
|
+
case "sepBy":
|
|
3540
|
+
return [d.parser, d.separator];
|
|
3541
|
+
case "recover":
|
|
3542
|
+
return [d.parser, d.sentinel];
|
|
3543
|
+
case "scanTo":
|
|
3544
|
+
return [d.sentinel, ...d.skip];
|
|
3545
|
+
case "grammar":
|
|
3546
|
+
return d.triviaParser ? [d.parser, d.triviaParser] : [d.parser];
|
|
3547
|
+
// A `lazy` is a REFERENCE, not a subtree: hashing through it would make every
|
|
3548
|
+
// rule's shape include the whole reachable grammar. Treated as a leaf, keyed
|
|
3549
|
+
// by the rule name it refers to — which is also the right semantics, since two
|
|
3550
|
+
// productions referencing `g.Ident` really do fill that slot the same way.
|
|
3551
|
+
case "lazy":
|
|
3552
|
+
case "literal":
|
|
3553
|
+
case "regex":
|
|
3554
|
+
case "keywords":
|
|
3555
|
+
case "guard":
|
|
3556
|
+
case "unknown":
|
|
3557
|
+
return [];
|
|
3558
|
+
default: {
|
|
3559
|
+
const rec = d;
|
|
3560
|
+
return rec.parser ? [rec.parser] : [];
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
var ruleNameOf3 = (p) => p._ruleName;
|
|
3565
|
+
var fnKey = (src, fn) => src ?? (typeof fn === "function" ? String(fn) : "");
|
|
3566
|
+
function payloadKey(p, d) {
|
|
3567
|
+
switch (d.tag) {
|
|
3568
|
+
case "literal":
|
|
3569
|
+
return `literal\0${d.value}\0${d.caseInsensitive}`;
|
|
3570
|
+
case "regex":
|
|
3571
|
+
return `regex\0${d.source}\0${d.flags}`;
|
|
3572
|
+
case "keywords":
|
|
3573
|
+
return `keywords\0${[...d.words].join("")}\0${d.caseInsensitive}\0${d.boundary ?? ""}`;
|
|
3574
|
+
case "lazy": {
|
|
3575
|
+
const n = ruleNameOf3(p);
|
|
3576
|
+
return n === void 0 ? `ref\0#${anonId(p)}` : `ref\0${n}`;
|
|
3577
|
+
}
|
|
3578
|
+
case "label":
|
|
3579
|
+
return `label\0${d.label}`;
|
|
3580
|
+
case "field":
|
|
3581
|
+
return `field\0${d.name}`;
|
|
3582
|
+
case "node":
|
|
3583
|
+
return `node\0${d.type ?? ""}\0${d.unwrap === true}\0${d.collapse === true}\0${fnKey(d.buildSrc, d.build)}`;
|
|
3584
|
+
case "transform":
|
|
3585
|
+
return `transform\0${fnKey(d.fnSrc, d.fn)}\0${d.recognitionOnly === true}`;
|
|
3586
|
+
case "leaf":
|
|
3587
|
+
return `leaf\0${fnKey(d.fnSrc, d.fn)}`;
|
|
3588
|
+
case "many":
|
|
3589
|
+
case "oneOrMore":
|
|
3590
|
+
return `${d.tag}\0${d.min}\0${d.max ?? ""}`;
|
|
3591
|
+
case "sepBy":
|
|
3592
|
+
return `sepBy\0${d.min}\0${d.max ?? ""}\0${d.trailing ?? ""}`;
|
|
3593
|
+
case "expect":
|
|
3594
|
+
return `expect\0${d.label ?? ""}\0${d.expected.join("")}`;
|
|
3595
|
+
case "scanTo":
|
|
3596
|
+
return `scanTo\0${d.raw}\0${d.orEOF}`;
|
|
3597
|
+
case "guard":
|
|
3598
|
+
return `guard\0${fnKey(d.predSrc, d.predicate)}`;
|
|
3599
|
+
case "withCtx":
|
|
3600
|
+
return `withCtx\0${d.extraSrc ?? safeJson(d.extra)}`;
|
|
3601
|
+
case "grammar":
|
|
3602
|
+
return `grammar\0${d.clearTrivia === true}\0${d.captureTrivia === true}\0${d.trackLines}`;
|
|
3603
|
+
default:
|
|
3604
|
+
return d.tag;
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
function safeJson(v2) {
|
|
3608
|
+
try {
|
|
3609
|
+
return JSON.stringify(v2) ?? String(v2);
|
|
3610
|
+
} catch {
|
|
3611
|
+
return "?";
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
var anonCounter = 0;
|
|
3615
|
+
var anonIds = /* @__PURE__ */ new WeakMap();
|
|
3616
|
+
function anonId(p) {
|
|
3617
|
+
let id = anonIds.get(p);
|
|
3618
|
+
if (id === void 0) {
|
|
3619
|
+
id = ++anonCounter;
|
|
3620
|
+
anonIds.set(p, id);
|
|
3621
|
+
}
|
|
3622
|
+
return id;
|
|
3623
|
+
}
|
|
3624
|
+
var Interner = class {
|
|
3625
|
+
ids = /* @__PURE__ */ new Map();
|
|
3626
|
+
hole;
|
|
3627
|
+
constructor() {
|
|
3628
|
+
this.hole = this.of("\0HOLE\0");
|
|
3629
|
+
}
|
|
3630
|
+
of(key) {
|
|
3631
|
+
let id = this.ids.get(key);
|
|
3632
|
+
if (id === void 0) {
|
|
3633
|
+
id = this.ids.size;
|
|
3634
|
+
this.ids.set(key, id);
|
|
3635
|
+
}
|
|
3636
|
+
return id;
|
|
3637
|
+
}
|
|
3638
|
+
};
|
|
3639
|
+
var MAX_RENDER = 160;
|
|
3640
|
+
function render(p, depth = 3) {
|
|
3641
|
+
const d = p._def;
|
|
3642
|
+
const name = ruleNameOf3(p);
|
|
3643
|
+
switch (d.tag) {
|
|
3644
|
+
case "literal":
|
|
3645
|
+
return `literal('${d.value}')`;
|
|
3646
|
+
case "regex":
|
|
3647
|
+
return `regex(/${d.source}/${d.flags})`;
|
|
3648
|
+
case "keywords":
|
|
3649
|
+
return `keywords([${[...d.words].slice(0, 4).map((w) => `'${w}'`).join(", ")}${d.words.length > 4 ? ", \u2026" : ""}])`;
|
|
3650
|
+
case "lazy":
|
|
3651
|
+
return `g.${name ?? "?"}`;
|
|
3652
|
+
case "guard":
|
|
3653
|
+
return "gate(\u2026)";
|
|
3654
|
+
default:
|
|
3655
|
+
break;
|
|
3656
|
+
}
|
|
3657
|
+
if (depth <= 0) return `${d.tag}(\u2026)`;
|
|
3658
|
+
const kids = childrenOf(d).map((k) => render(k, depth - 1));
|
|
3659
|
+
switch (d.tag) {
|
|
3660
|
+
case "sequence":
|
|
3661
|
+
return `sequence(${kids.join(", ")})`;
|
|
3662
|
+
case "choice":
|
|
3663
|
+
return `choice(${kids.join(", ")})`;
|
|
3664
|
+
case "node":
|
|
3665
|
+
return `node(${d.type ? `'${d.type}', ` : ""}${kids[0] ?? ""})`;
|
|
3666
|
+
case "label":
|
|
3667
|
+
return `label('${d.label}', ${kids[0] ?? ""})`;
|
|
3668
|
+
case "field":
|
|
3669
|
+
return `field('${d.name}', ${kids[0] ?? ""})`;
|
|
3670
|
+
case "many":
|
|
3671
|
+
return `many(${kids[0] ?? ""}${d.min === 0 && d.max === void 0 ? "" : `, { min: ${d.min}${d.max === void 0 ? "" : `, max: ${d.max}`} }`})`;
|
|
3672
|
+
case "oneOrMore":
|
|
3673
|
+
return `oneOrMore(${kids[0] ?? ""}${d.min === 1 && d.max === void 0 ? "" : `, { min: ${d.min}${d.max === void 0 ? "" : `, max: ${d.max}`} }`})`;
|
|
3674
|
+
case "sepBy":
|
|
3675
|
+
return `sepBy(${kids[0] ?? ""}, ${kids[1] ?? ""})`;
|
|
3676
|
+
default:
|
|
3677
|
+
return `${d.tag}(${kids.join(", ")})`;
|
|
3678
|
+
}
|
|
3679
|
+
}
|
|
3680
|
+
var clamp = (s) => s.length <= MAX_RENDER ? s : `${s.slice(0, MAX_RENDER - 1)}\u2026`;
|
|
3681
|
+
function segment(d, index) {
|
|
3682
|
+
switch (d.tag) {
|
|
3683
|
+
case "sequence":
|
|
3684
|
+
return `seq[${index}]`;
|
|
3685
|
+
case "choice":
|
|
3686
|
+
return `choice[${index}]`;
|
|
3687
|
+
case "node":
|
|
3688
|
+
return `node(${d.type ?? ""})`;
|
|
3689
|
+
case "label":
|
|
3690
|
+
return `label(${d.label})`;
|
|
3691
|
+
case "field":
|
|
3692
|
+
return `field(${d.name})`;
|
|
3693
|
+
case "sepBy":
|
|
3694
|
+
return index === 0 ? "sepBy.item" : "sepBy.sep";
|
|
3695
|
+
case "skip":
|
|
3696
|
+
return index === 0 ? "skip.main" : "skip.skipped";
|
|
3697
|
+
case "recover":
|
|
3698
|
+
return index === 0 ? "recover.body" : "recover.sentinel";
|
|
3699
|
+
case "scanTo":
|
|
3700
|
+
return index === 0 ? "scanTo.sentinel" : `scanTo.skip[${index - 1}]`;
|
|
3701
|
+
default:
|
|
3702
|
+
return d.tag;
|
|
3703
|
+
}
|
|
3704
|
+
}
|
|
3705
|
+
function alternationGroups(src) {
|
|
3706
|
+
const out = [];
|
|
3707
|
+
const stack = [{ branches: [], last: 0 }];
|
|
3708
|
+
let i = 0;
|
|
3709
|
+
while (i < src.length) {
|
|
3710
|
+
const c = src[i];
|
|
3711
|
+
if (c === "\\") {
|
|
3712
|
+
i += 2;
|
|
3713
|
+
continue;
|
|
3714
|
+
}
|
|
3715
|
+
if (c === "[") {
|
|
3716
|
+
let j = i + 1;
|
|
3717
|
+
if (src[j] === "^") j++;
|
|
3718
|
+
if (src[j] === "]") j++;
|
|
3719
|
+
while (j < src.length && src[j] !== "]") {
|
|
3720
|
+
if (src[j] === "\\") j++;
|
|
3721
|
+
j++;
|
|
3722
|
+
}
|
|
3723
|
+
i = j + 1;
|
|
3724
|
+
continue;
|
|
3725
|
+
}
|
|
3726
|
+
if (c === "(") {
|
|
3727
|
+
let open = i + 1;
|
|
3728
|
+
if (src[open] === "?") {
|
|
3729
|
+
const rest = src.slice(open);
|
|
3730
|
+
const m = /^\?(?::|=|!|<=|<!|<[^>]*>)/.exec(rest);
|
|
3731
|
+
open += m ? m[0].length : 1;
|
|
3732
|
+
}
|
|
3733
|
+
stack.push({ branches: [], last: open });
|
|
3734
|
+
i = open;
|
|
3735
|
+
continue;
|
|
3736
|
+
}
|
|
3737
|
+
if (c === ")") {
|
|
3738
|
+
const top = stack.pop();
|
|
3739
|
+
if (top !== void 0) {
|
|
3740
|
+
top.branches.push(src.slice(top.last, i));
|
|
3741
|
+
if (top.branches.length >= 2) out.push(top.branches);
|
|
3742
|
+
}
|
|
3743
|
+
i++;
|
|
3744
|
+
continue;
|
|
3745
|
+
}
|
|
3746
|
+
if (c === "|") {
|
|
3747
|
+
const top = stack[stack.length - 1];
|
|
3748
|
+
top.branches.push(src.slice(top.last, i));
|
|
3749
|
+
top.last = i + 1;
|
|
3750
|
+
i++;
|
|
3751
|
+
continue;
|
|
3752
|
+
}
|
|
3753
|
+
i++;
|
|
3754
|
+
}
|
|
3755
|
+
const root = stack[0];
|
|
3756
|
+
root.branches.push(src.slice(root.last));
|
|
3757
|
+
if (root.branches.length >= 2) out.push(root.branches);
|
|
3758
|
+
return out;
|
|
3759
|
+
}
|
|
3760
|
+
var WORD_RE_SRC = "-?[A-Za-z][A-Za-z0-9_-]*";
|
|
3761
|
+
var WORDS_RE = new RegExp(`^${WORD_RE_SRC}(?:\\|${WORD_RE_SRC})*$`);
|
|
3762
|
+
var VOCABULARY_MIN_WORDS = 3;
|
|
3763
|
+
function keywordRegexShape(source) {
|
|
3764
|
+
let s = source;
|
|
3765
|
+
if (s.startsWith("^")) s = s.slice(1);
|
|
3766
|
+
s = s.replace(/^\(\?<!\[[^\]]+\]\)/, "");
|
|
3767
|
+
let boundary = null;
|
|
3768
|
+
const tail = /(?:\(\?!\[([^\]]+)\]\)|\\b)$/.exec(s);
|
|
3769
|
+
if (tail !== null) {
|
|
3770
|
+
const cls = tail[1];
|
|
3771
|
+
if (cls !== void 0 && cls.startsWith("^")) return null;
|
|
3772
|
+
boundary = cls ?? "_0-9A-Za-z";
|
|
3773
|
+
s = s.slice(0, tail.index);
|
|
3774
|
+
}
|
|
3775
|
+
const grouped = /^\(\?:([^()]*)\)$/.exec(s);
|
|
3776
|
+
if (grouped !== null) s = grouped[1];
|
|
3777
|
+
if (s.endsWith("$")) s = s.slice(0, -1);
|
|
3778
|
+
if (!WORDS_RE.test(s)) return null;
|
|
3779
|
+
const words = s.split("|");
|
|
3780
|
+
if (boundary === null && words.length < VOCABULARY_MIN_WORDS) return null;
|
|
3781
|
+
return { words, boundary };
|
|
3782
|
+
}
|
|
3783
|
+
function boundaryMatches(boundary, ch2) {
|
|
3784
|
+
const cp = ch2.codePointAt(0);
|
|
3785
|
+
for (const m of charClassMembers(boundary)) {
|
|
3786
|
+
if (m === "\\w") {
|
|
3787
|
+
if (/[A-Za-z0-9_]/.test(ch2)) return true;
|
|
3788
|
+
continue;
|
|
3789
|
+
}
|
|
3790
|
+
if (m === "\\d") {
|
|
3791
|
+
if (/[0-9]/.test(ch2)) return true;
|
|
3792
|
+
continue;
|
|
3793
|
+
}
|
|
3794
|
+
if (m === "\\s") {
|
|
3795
|
+
if (/\s/.test(ch2)) return true;
|
|
3796
|
+
continue;
|
|
3797
|
+
}
|
|
3798
|
+
const range = /^(.|\\u[0-9a-f]{4})-(.|\\u[0-9a-f]{4})$/.exec(m);
|
|
3799
|
+
if (range !== null) {
|
|
3800
|
+
const dec = (t) => t.startsWith("\\u") ? parseInt(t.slice(2), 16) : t.codePointAt(0);
|
|
3801
|
+
if (cp >= dec(range[1]) && cp <= dec(range[2])) return true;
|
|
3802
|
+
continue;
|
|
3803
|
+
}
|
|
3804
|
+
if (m === ch2) return true;
|
|
3805
|
+
}
|
|
3806
|
+
return false;
|
|
3807
|
+
}
|
|
3808
|
+
function keywordAlternationHazards(words, boundary) {
|
|
3809
|
+
const out = [];
|
|
3810
|
+
for (let i = 0; i < words.length; i++) {
|
|
3811
|
+
for (let j = i + 1; j < words.length; j++) {
|
|
3812
|
+
const a = words[i], b = words[j];
|
|
3813
|
+
if (a.length >= b.length || !b.startsWith(a)) continue;
|
|
3814
|
+
const at = b[a.length];
|
|
3815
|
+
out.push({ shorter: a, longer: b, at, rescuedByBoundary: boundary !== null && boundaryMatches(boundary, at) });
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
3818
|
+
return out;
|
|
3819
|
+
}
|
|
3820
|
+
function analyzeDuplication(entry, opts) {
|
|
3821
|
+
return analyzeDuplicationRules([[ruleNameOf3(entry) ?? opts?.entryName ?? "<entry>", entry]], opts);
|
|
3822
|
+
}
|
|
3823
|
+
function assertAnalyzable(ruleMap) {
|
|
3824
|
+
for (const [name, rule] of ruleMap) {
|
|
3825
|
+
const def = rule?._def;
|
|
3826
|
+
if (def !== null && typeof def === "object" && typeof def.tag === "string") continue;
|
|
3827
|
+
throw new TypeError(
|
|
3828
|
+
`analyzeDuplicationRules: rule '${name}' is not a combinator (no _def). This analysis walks the COMBINATOR TREE; the value returned by compose()/composeLeaf() is a fused, already-compiled artifact whose entries are parse functions. Pass the rules() map itself (or the map given to compileRuleMap/compileLinkable) instead \u2014 for a composed grammar, analyze each piece's own rules() map.`
|
|
3829
|
+
);
|
|
3830
|
+
}
|
|
3831
|
+
}
|
|
3832
|
+
function analyzeDuplicationRules(ruleMap, opts) {
|
|
3833
|
+
assertAnalyzable(ruleMap);
|
|
3834
|
+
const minSize = opts?.minSize ?? 3;
|
|
3835
|
+
const maxFindings = opts?.maxFindings ?? 25;
|
|
3836
|
+
const interner = new Interner();
|
|
3837
|
+
const shapeCache = /* @__PURE__ */ new Map();
|
|
3838
|
+
const sizeCache = /* @__PURE__ */ new Map();
|
|
3839
|
+
const shapeOf = (p) => {
|
|
3840
|
+
const hit = shapeCache.get(p);
|
|
3841
|
+
if (hit !== void 0) return hit;
|
|
3842
|
+
const d = p._def;
|
|
3843
|
+
shapeCache.set(p, interner.hole);
|
|
3844
|
+
const kids = childrenOf(d);
|
|
3845
|
+
const id = interner.of(`${payloadKey(p, d)}|${kids.map(shapeOf).join(",")}`);
|
|
3846
|
+
shapeCache.set(p, id);
|
|
3847
|
+
return id;
|
|
3848
|
+
};
|
|
3849
|
+
const sizeOf = (p) => {
|
|
3850
|
+
const hit = sizeCache.get(p);
|
|
3851
|
+
if (hit !== void 0) return hit;
|
|
3852
|
+
sizeCache.set(p, 1);
|
|
3853
|
+
let n = 1;
|
|
3854
|
+
for (const k of childrenOf(p._def)) n += sizeOf(k);
|
|
3855
|
+
sizeCache.set(p, n);
|
|
3856
|
+
return n;
|
|
3857
|
+
};
|
|
3858
|
+
const visited = /* @__PURE__ */ new Map();
|
|
3859
|
+
const visit = (p, rule, path, enclosingNode, enclosingReducer) => {
|
|
3860
|
+
if (visited.has(p)) return;
|
|
3861
|
+
const d = p._def;
|
|
3862
|
+
const own = ruleNameOf3(p);
|
|
3863
|
+
const here = own !== void 0 && own !== rule ? { rule: own, path: "" } : { rule, path };
|
|
3864
|
+
visited.set(p, { p, d, site: here, shape: shapeOf(p), size: sizeOf(p), enclosingNode, enclosingReducer });
|
|
3865
|
+
const inner = d.tag === "node" ? d.type ?? "(anonymous)" : enclosingNode;
|
|
3866
|
+
const innerReducer = enclosingReducer || d.tag === "node" && (d.build !== void 0 || d.buildSrc !== void 0) || d.tag === "transform" || d.tag === "leaf";
|
|
3867
|
+
const kids = childrenOf(d);
|
|
3868
|
+
kids.forEach((k, i) => {
|
|
3869
|
+
const seg = segment(d, i);
|
|
3870
|
+
visit(k, here.rule, here.path === "" ? seg : `${here.path} \u203A ${seg}`, inner, innerReducer);
|
|
3871
|
+
});
|
|
3872
|
+
if (d.tag === "lazy") {
|
|
3873
|
+
try {
|
|
3874
|
+
const target = d.thunk();
|
|
3875
|
+
visit(target, ruleNameOf3(target) ?? ruleNameOf3(p) ?? here.rule, "", null, false);
|
|
3876
|
+
} catch {
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3879
|
+
};
|
|
3880
|
+
for (const [name, root] of ruleMap) visit(root, ruleNameOf3(root) ?? name, "", null, false);
|
|
3881
|
+
const accept = new Set(opts?.accept ?? []);
|
|
3882
|
+
const used = /* @__PURE__ */ new Set();
|
|
3883
|
+
const keep = (f) => {
|
|
3884
|
+
if (!accept.has(f.id)) return true;
|
|
3885
|
+
used.add(f.id);
|
|
3886
|
+
return false;
|
|
3887
|
+
};
|
|
3888
|
+
const duplicates = findExactDuplicates(visited, minSize);
|
|
3889
|
+
const nearDuplicates = findNearDuplicates(visited, interner, shapeOf, minSize);
|
|
3890
|
+
const regexFragments = findRegexFragments(visited);
|
|
3891
|
+
const regexClasses = findRegexClasses(visited);
|
|
3892
|
+
const overlaps = findOverlaps(visited, shapeOf, opts?.resolveRef);
|
|
3893
|
+
const rewrites = findRewrites(visited, shapeOf);
|
|
3894
|
+
const divergentNodes = findDivergentNodes(visited, shapeOf);
|
|
3895
|
+
const structureLoss = findStructureLoss(visited, opts?.resolveRef);
|
|
3896
|
+
const keywordRegexes = findKeywordRegexes(visited);
|
|
3897
|
+
const subsume = (fs) => {
|
|
3898
|
+
const covered = /* @__PURE__ */ new Set();
|
|
3899
|
+
const surviving = /* @__PURE__ */ new Set();
|
|
3900
|
+
for (const f of [...fs].sort((a, b) => b.savings - a.savings)) {
|
|
3901
|
+
if (f._members.every((m) => covered.has(m))) continue;
|
|
3902
|
+
surviving.add(f.id);
|
|
3903
|
+
for (const m of f._members) markSubtree(m, covered);
|
|
3904
|
+
}
|
|
3905
|
+
return surviving;
|
|
3906
|
+
};
|
|
3907
|
+
const survivingExact = subsume(duplicates);
|
|
3908
|
+
const survivingNear = subsume(nearDuplicates);
|
|
3909
|
+
const report = {
|
|
3910
|
+
duplicates: duplicates.filter((f) => survivingExact.has(f.id)).filter(keep).slice(0, maxFindings).map(stripMembers),
|
|
3911
|
+
nearDuplicates: nearDuplicates.filter((f) => survivingNear.has(f.id)).filter(keep).slice(0, maxFindings).map(stripMembers),
|
|
3912
|
+
regexFragments: regexFragments.filter(keep).slice(0, maxFindings),
|
|
3913
|
+
regexClasses: regexClasses.filter(keep).slice(0, maxFindings),
|
|
3914
|
+
overlaps: overlaps.filter(keep).slice(0, maxFindings),
|
|
3915
|
+
rewrites: rewrites.filter(keep).slice(0, maxFindings),
|
|
3916
|
+
divergentNodes: divergentNodes.filter(keep).slice(0, maxFindings),
|
|
3917
|
+
structureLoss: structureLoss.filter(keep).slice(0, maxFindings),
|
|
3918
|
+
keywordRegexes: keywordRegexes.filter(keep).slice(0, maxFindings),
|
|
3919
|
+
acceptedUnused: [...accept].filter((id) => !used.has(id)),
|
|
3920
|
+
stats: { rules: ruleMap.length, nodes: visited.size, shapes: new Set([...visited.values()].map((v2) => v2.shape)).size }
|
|
3921
|
+
};
|
|
3922
|
+
return report;
|
|
3923
|
+
}
|
|
3924
|
+
function markSubtree(p, into) {
|
|
3925
|
+
if (into.has(p)) return;
|
|
3926
|
+
into.add(p);
|
|
3927
|
+
for (const k of childrenOf(p._def)) markSubtree(k, into);
|
|
3928
|
+
}
|
|
3929
|
+
var stripMembers = (f) => {
|
|
3930
|
+
const { _members, ...rest } = f;
|
|
3931
|
+
void _members;
|
|
3932
|
+
return rest;
|
|
3933
|
+
};
|
|
3934
|
+
function findExactDuplicates(visited, minSize) {
|
|
3935
|
+
const byShape = /* @__PURE__ */ new Map();
|
|
3936
|
+
for (const v2 of visited.values()) {
|
|
3937
|
+
if (v2.size < minSize) continue;
|
|
3938
|
+
const list = byShape.get(v2.shape);
|
|
3939
|
+
if (list === void 0) byShape.set(v2.shape, [v2]);
|
|
3940
|
+
else list.push(v2);
|
|
3941
|
+
}
|
|
3942
|
+
const out = [];
|
|
3943
|
+
for (const group of byShape.values()) {
|
|
3944
|
+
if (group.length < 2) continue;
|
|
3945
|
+
const size = group[0].size;
|
|
3946
|
+
out.push({
|
|
3947
|
+
kind: "exact-duplicate",
|
|
3948
|
+
id: `exact-duplicate:${siteToString(group[0].site)}`,
|
|
3949
|
+
size,
|
|
3950
|
+
count: group.length,
|
|
3951
|
+
savings: (group.length - 1) * size,
|
|
3952
|
+
shape: clamp(render(group[0].p)),
|
|
3953
|
+
sites: group.map((g) => g.site),
|
|
3954
|
+
suggestion: `${group.length} DISTINCT instances build this identical ${size}-node shape. Bind it to one const and reference that \u2014 hoisting removes ${(group.length - 1) * size} nodes and makes a future edit land in one place instead of ${group.length}.`,
|
|
3955
|
+
_members: group.map((g) => g.p)
|
|
3956
|
+
});
|
|
3957
|
+
}
|
|
3958
|
+
return out.sort((a, b) => b.savings - a.savings);
|
|
3959
|
+
}
|
|
3960
|
+
function findNearDuplicates(visited, interner, shapeOf, minSize) {
|
|
3961
|
+
const holeCache = /* @__PURE__ */ new Map();
|
|
3962
|
+
const holesOf = (p) => {
|
|
3963
|
+
const hit = holeCache.get(p);
|
|
3964
|
+
if (hit !== void 0) return hit;
|
|
3965
|
+
holeCache.set(p, []);
|
|
3966
|
+
const d = p._def;
|
|
3967
|
+
const kids = childrenOf(d);
|
|
3968
|
+
const kidShapes = kids.map(shapeOf);
|
|
3969
|
+
const payload = payloadKey(p, d);
|
|
3970
|
+
const out2 = [];
|
|
3971
|
+
kids.forEach((k, i) => {
|
|
3972
|
+
for (const inner of [interner.hole, ...holesOf(k)]) {
|
|
3973
|
+
const arr = kidShapes.slice();
|
|
3974
|
+
arr[i] = inner;
|
|
3975
|
+
out2.push(interner.of(`${payload}|${arr.join(",")}`));
|
|
3976
|
+
}
|
|
3977
|
+
});
|
|
3978
|
+
holeCache.set(p, out2);
|
|
3979
|
+
return out2;
|
|
3980
|
+
};
|
|
3981
|
+
const byHole = /* @__PURE__ */ new Map();
|
|
3982
|
+
for (const v2 of visited.values()) {
|
|
3983
|
+
if (v2.size < minSize) continue;
|
|
3984
|
+
for (const h of holesOf(v2.p)) {
|
|
3985
|
+
const list = byHole.get(h);
|
|
3986
|
+
if (list === void 0) byHole.set(h, [v2]);
|
|
3987
|
+
else list.push(v2);
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
const out = [];
|
|
3991
|
+
const seenGroups = /* @__PURE__ */ new Set();
|
|
3992
|
+
for (const group of byHole.values()) {
|
|
3993
|
+
if (group.length < 2) continue;
|
|
3994
|
+
const shapes = new Set(group.map((g) => g.shape));
|
|
3995
|
+
if (shapes.size < 2) continue;
|
|
3996
|
+
const perShape = /* @__PURE__ */ new Map();
|
|
3997
|
+
for (const g of group) if (!perShape.has(g.shape)) perShape.set(g.shape, g);
|
|
3998
|
+
const members = [...perShape.values()];
|
|
3999
|
+
const key = members.map((m) => m.shape).sort((a, b) => a - b).join(",");
|
|
4000
|
+
if (seenGroups.has(key)) continue;
|
|
4001
|
+
seenGroups.add(key);
|
|
4002
|
+
const slot = findDivergentSlot(members.map((m) => m.p), shapeOf);
|
|
4003
|
+
if (slot === null) continue;
|
|
4004
|
+
const sharedSize = Math.min(...members.map((m) => m.size)) - slot.slotSize;
|
|
4005
|
+
if (sharedSize < minSize) continue;
|
|
4006
|
+
out.push({
|
|
4007
|
+
kind: "near-duplicate",
|
|
4008
|
+
id: `near-duplicate:${siteToString(members[0].site)}`,
|
|
4009
|
+
sharedSize,
|
|
4010
|
+
count: members.length,
|
|
4011
|
+
savings: (members.length - 1) * sharedSize,
|
|
4012
|
+
shape: clamp(renderWithHole(members[0].p, slot.path)),
|
|
4013
|
+
slotPath: slot.path.map((i, k) => segment(slot.defs[k], i)).join(" \u203A "),
|
|
4014
|
+
variants: slot.variants.map((v2) => clamp(render(v2))),
|
|
4015
|
+
sites: members.map((m) => m.site),
|
|
4016
|
+
suggestion: `${members.length} productions share this ${sharedSize}-node scaffolding and differ ONLY at \`${slot.path.map((i, k) => segment(slot.defs[k], i)).join(" \u203A ")}\`. That is one production whose varying slot is a \`choice(${slot.variants.map((v2) => clamp(render(v2, 1))).join(", ")})\` \u2014 not ${members.length} copies of the scaffolding. Re-check the AST: a single production emits one node type where the clones may emit several.`,
|
|
4017
|
+
_members: members.map((m) => m.p)
|
|
4018
|
+
});
|
|
4019
|
+
}
|
|
4020
|
+
return out.sort((a, b) => b.savings - a.savings);
|
|
4021
|
+
}
|
|
4022
|
+
function findDivergentSlot(members, shapeOf) {
|
|
4023
|
+
const path = [];
|
|
4024
|
+
const defs = [];
|
|
4025
|
+
let cur = [...members];
|
|
4026
|
+
for (let guard2 = 0; guard2 < 64; guard2++) {
|
|
4027
|
+
const d0 = cur[0]._def;
|
|
4028
|
+
const kidLists = cur.map((c) => childrenOf(c._def));
|
|
4029
|
+
const arity = kidLists[0].length;
|
|
4030
|
+
if (kidLists.some((k) => k.length !== arity) || arity === 0) break;
|
|
4031
|
+
const diff = [];
|
|
4032
|
+
for (let i2 = 0; i2 < arity; i2++) {
|
|
4033
|
+
const s = shapeOf(kidLists[0][i2]);
|
|
4034
|
+
if (kidLists.some((k) => shapeOf(k[i2]) !== s)) diff.push(i2);
|
|
4035
|
+
}
|
|
4036
|
+
if (diff.length !== 1) break;
|
|
4037
|
+
const i = diff[0];
|
|
4038
|
+
path.push(i);
|
|
4039
|
+
defs.push(d0);
|
|
4040
|
+
cur = kidLists.map((k) => k[i]);
|
|
4041
|
+
const nextArity = childrenOf(cur[0]._def).length;
|
|
4042
|
+
const samePayload = cur.every((c) => payloadKey(c, c._def) === payloadKey(cur[0], cur[0]._def));
|
|
4043
|
+
if (!samePayload || nextArity === 0) break;
|
|
4044
|
+
}
|
|
4045
|
+
if (path.length === 0) return null;
|
|
4046
|
+
let slotSize = 0;
|
|
4047
|
+
const sizeOf = (p) => {
|
|
4048
|
+
let n = 1;
|
|
4049
|
+
for (const k of childrenOf(p._def)) n += sizeOf(k);
|
|
4050
|
+
return n;
|
|
4051
|
+
};
|
|
4052
|
+
for (const c of cur) slotSize = Math.max(slotSize, sizeOf(c));
|
|
4053
|
+
return { path, defs, variants: cur, slotSize };
|
|
4054
|
+
}
|
|
4055
|
+
function renderWithHole(p, path, depth = 0) {
|
|
4056
|
+
if (depth === path.length) return "\u2039slot\u203A";
|
|
4057
|
+
const d = p._def;
|
|
4058
|
+
const kids = childrenOf(d);
|
|
4059
|
+
const idx = path[depth];
|
|
4060
|
+
const parts = kids.map((k, i) => i === idx ? renderWithHole(k, path, depth + 1) : render(k, 2));
|
|
4061
|
+
switch (d.tag) {
|
|
4062
|
+
case "sequence":
|
|
4063
|
+
return `sequence(${parts.join(", ")})`;
|
|
4064
|
+
case "choice":
|
|
4065
|
+
return `choice(${parts.join(", ")})`;
|
|
4066
|
+
case "node":
|
|
4067
|
+
return `node(${d.type ? `'${d.type}', ` : ""}${parts[0] ?? ""})`;
|
|
4068
|
+
default:
|
|
4069
|
+
return `${d.tag}(${parts.join(", ")})`;
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
var MAX_RUN = 32;
|
|
4073
|
+
function findRegexFragments(visited) {
|
|
4074
|
+
const byRun = /* @__PURE__ */ new Map();
|
|
4075
|
+
for (const v2 of visited.values()) {
|
|
4076
|
+
const d = v2.d;
|
|
4077
|
+
if (d.tag !== "regex") continue;
|
|
4078
|
+
const runs = /* @__PURE__ */ new Set();
|
|
4079
|
+
for (const branches of alternationGroups(d.source)) {
|
|
4080
|
+
if (branches.length >= 2) runs.add(branches.join("|"));
|
|
4081
|
+
for (let i = 0; i < branches.length; i++) {
|
|
4082
|
+
for (let n = 2; n <= Math.min(MAX_RUN, branches.length - i); n++) {
|
|
4083
|
+
const run2 = branches.slice(i, i + n).join("|");
|
|
4084
|
+
if (run2.length < 4) continue;
|
|
4085
|
+
runs.add(run2);
|
|
4086
|
+
}
|
|
4087
|
+
}
|
|
4088
|
+
}
|
|
4089
|
+
for (const run2 of runs) {
|
|
4090
|
+
let e = byRun.get(run2);
|
|
4091
|
+
if (e === void 0) {
|
|
4092
|
+
e = { occ: [], nodes: /* @__PURE__ */ new Set(), branches: run2.split("|").length };
|
|
4093
|
+
byRun.set(run2, e);
|
|
4094
|
+
}
|
|
4095
|
+
if (e.nodes.has(v2.p)) continue;
|
|
4096
|
+
e.nodes.add(v2.p);
|
|
4097
|
+
e.occ.push({ site: v2.site, source: d.source });
|
|
4098
|
+
}
|
|
4099
|
+
}
|
|
4100
|
+
const candidates = [...byRun.entries()].filter(([, e]) => e.occ.length >= 2).map(([run2, e]) => ({ run: run2, e, savings: (e.occ.length - 1) * run2.length })).sort((a, b) => b.e.occ.length - a.e.occ.length || b.savings - a.savings || b.run.length - a.run.length);
|
|
4101
|
+
const kept = [];
|
|
4102
|
+
for (const c of candidates) {
|
|
4103
|
+
if (kept.some((k) => k.e.occ.length >= c.e.occ.length && `|${k.run}|`.includes(`|${c.run}|`))) continue;
|
|
4104
|
+
kept.push(c);
|
|
4105
|
+
}
|
|
4106
|
+
return kept.map(({ run: run2, e }) => ({
|
|
4107
|
+
kind: "regex-fragment",
|
|
4108
|
+
id: `regex-fragment:${run2}`,
|
|
4109
|
+
fragment: run2,
|
|
4110
|
+
branches: e.branches,
|
|
4111
|
+
count: e.occ.length,
|
|
4112
|
+
savings: (e.occ.length - 1) * run2.length,
|
|
4113
|
+
sources: e.occ.map((o) => `/${o.source}/`),
|
|
4114
|
+
sites: e.occ.map((o) => o.site),
|
|
4115
|
+
suggestion: `this ${e.branches}-branch alternation is re-spelled in ${e.occ.length} DISTINCT regex() terminals. Hoist it to one shared source string and interpolate it (\`new RegExp(\\\`\u2026\\\${OP}\u2026\\\`)\`) or lift the shared part into its own terminal \u2014 an edit to the operator set currently has to land in ${e.occ.length} places, and the analysis cannot tell you when one of them drifts.`
|
|
4116
|
+
}));
|
|
4117
|
+
}
|
|
4118
|
+
function extractCharClasses(src) {
|
|
4119
|
+
const out = [];
|
|
4120
|
+
let i = 0;
|
|
4121
|
+
while (i < src.length) {
|
|
4122
|
+
const c = src[i];
|
|
4123
|
+
if (c === "\\") {
|
|
4124
|
+
i += 2;
|
|
4125
|
+
continue;
|
|
4126
|
+
}
|
|
4127
|
+
if (c !== "[") {
|
|
4128
|
+
i++;
|
|
4129
|
+
continue;
|
|
4130
|
+
}
|
|
4131
|
+
let j = i + 1;
|
|
4132
|
+
if (src[j] === "^") j++;
|
|
4133
|
+
if (src[j] === "]") j++;
|
|
4134
|
+
while (j < src.length && src[j] !== "]") {
|
|
4135
|
+
if (src[j] === "\\") j++;
|
|
4136
|
+
j++;
|
|
4137
|
+
}
|
|
4138
|
+
out.push(src.slice(i + 1, j));
|
|
4139
|
+
i = j + 1;
|
|
4140
|
+
}
|
|
4141
|
+
return out;
|
|
4142
|
+
}
|
|
4143
|
+
function charClassMembers(body) {
|
|
4144
|
+
const esc = (ch2) => {
|
|
4145
|
+
const cp = ch2.codePointAt(0);
|
|
4146
|
+
return cp >= 128 ? `\\u${cp.toString(16).padStart(4, "0")}` : ch2;
|
|
4147
|
+
};
|
|
4148
|
+
const atoms = [];
|
|
4149
|
+
let i = 0;
|
|
4150
|
+
while (i < body.length) {
|
|
4151
|
+
if (body[i] === "\\") {
|
|
4152
|
+
const m = /^\\(?:u\{([0-9a-fA-F]+)\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2}))/.exec(body.slice(i));
|
|
4153
|
+
if (m !== null) {
|
|
4154
|
+
const cp = parseInt(m[1] ?? m[2] ?? m[3], 16);
|
|
4155
|
+
atoms.push(cp >= 128 ? `\\u${cp.toString(16).padStart(4, "0")}` : String.fromCodePoint(cp));
|
|
4156
|
+
i += m[0].length;
|
|
4157
|
+
continue;
|
|
4158
|
+
}
|
|
4159
|
+
atoms.push(body.slice(i, i + 2));
|
|
4160
|
+
i += 2;
|
|
4161
|
+
continue;
|
|
4162
|
+
}
|
|
4163
|
+
atoms.push(esc(body[i]));
|
|
4164
|
+
i++;
|
|
4165
|
+
}
|
|
4166
|
+
const members = [];
|
|
4167
|
+
for (let k = 0; k < atoms.length; k++) {
|
|
4168
|
+
if (k + 2 < atoms.length && atoms[k + 1] === "-" && atoms[k + 2] !== void 0) {
|
|
4169
|
+
members.push(`${atoms[k]}-${atoms[k + 2]}`);
|
|
4170
|
+
k += 2;
|
|
4171
|
+
continue;
|
|
4172
|
+
}
|
|
4173
|
+
members.push(atoms[k]);
|
|
4174
|
+
}
|
|
4175
|
+
return members;
|
|
4176
|
+
}
|
|
4177
|
+
var CLASS_MIN_MEMBERS = 2;
|
|
4178
|
+
function findRegexClasses(visited) {
|
|
4179
|
+
const spellings = /* @__PURE__ */ new Map();
|
|
4180
|
+
for (const v2 of visited.values()) {
|
|
4181
|
+
if (v2.d.tag !== "regex") continue;
|
|
4182
|
+
for (const body of new Set(extractCharClasses(v2.d.source))) {
|
|
4183
|
+
const members = charClassMembers(body);
|
|
4184
|
+
if (members.length < CLASS_MIN_MEMBERS) continue;
|
|
4185
|
+
const key = [...new Set(members)].sort().join("");
|
|
4186
|
+
let s = spellings.get(key);
|
|
4187
|
+
if (s === void 0) {
|
|
4188
|
+
s = { source: body, members, key, nodes: /* @__PURE__ */ new Set(), sites: [] };
|
|
4189
|
+
spellings.set(key, s);
|
|
4190
|
+
}
|
|
4191
|
+
if (s.nodes.has(v2.p)) continue;
|
|
4192
|
+
s.nodes.add(v2.p);
|
|
4193
|
+
s.sites.push(v2.site);
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
4196
|
+
const MAX_DELTA = 3;
|
|
4197
|
+
const MIN_JACCARD = 0.6;
|
|
4198
|
+
const all = [...spellings.values()];
|
|
4199
|
+
const parent = all.map((_, i) => i);
|
|
4200
|
+
const find = (i) => {
|
|
4201
|
+
while (parent[i] !== i) {
|
|
4202
|
+
parent[i] = parent[parent[i]];
|
|
4203
|
+
i = parent[i];
|
|
4204
|
+
}
|
|
4205
|
+
return i;
|
|
4206
|
+
};
|
|
4207
|
+
const union2 = (a, b) => {
|
|
4208
|
+
const ra = find(a), rb = find(b);
|
|
4209
|
+
if (ra !== rb) parent[ra] = rb;
|
|
4210
|
+
};
|
|
4211
|
+
for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) {
|
|
4212
|
+
if (all[i].members.includes("^") !== all[j].members.includes("^")) continue;
|
|
4213
|
+
const A = new Set(all[i].members), B = new Set(all[j].members);
|
|
4214
|
+
let shared = 0;
|
|
4215
|
+
for (const m of A) if (B.has(m)) shared++;
|
|
4216
|
+
const delta = A.size - shared + (B.size - shared);
|
|
4217
|
+
const jaccard = shared / (shared + delta);
|
|
4218
|
+
if (shared >= 2 && delta > 0 && delta <= MAX_DELTA && jaccard >= MIN_JACCARD) union2(i, j);
|
|
4219
|
+
}
|
|
4220
|
+
const clusters = /* @__PURE__ */ new Map();
|
|
4221
|
+
all.forEach((_, i) => {
|
|
4222
|
+
const r = find(i);
|
|
4223
|
+
const list = clusters.get(r);
|
|
4224
|
+
if (list === void 0) clusters.set(r, [i]);
|
|
4225
|
+
else list.push(i);
|
|
4226
|
+
});
|
|
4227
|
+
const out = [];
|
|
4228
|
+
for (const idxs of clusters.values()) {
|
|
4229
|
+
const members = idxs.map((i) => all[i]).sort((a, b) => b.nodes.size - a.nodes.size);
|
|
4230
|
+
const total = members.reduce((n, m) => n + m.nodes.size, 0);
|
|
4231
|
+
const drifted = members.length > 1;
|
|
4232
|
+
if (!drifted && total < 2) continue;
|
|
4233
|
+
const base = new Set(members[0].members);
|
|
4234
|
+
const variants = members.map((m) => {
|
|
4235
|
+
const mine = new Set(m.members);
|
|
4236
|
+
const plus = [...mine].filter((x) => !base.has(x));
|
|
4237
|
+
const minus = [...base].filter((x) => !mine.has(x));
|
|
4238
|
+
return {
|
|
4239
|
+
source: m.source,
|
|
4240
|
+
delta: m === members[0] ? "(reference)" : `${plus.map((p) => `+\`${p}\``).join(" ")}${plus.length > 0 && minus.length > 0 ? " " : ""}${minus.map((p) => `-\`${p}\``).join(" ")}` || "(same members, different spelling)",
|
|
4241
|
+
count: m.nodes.size,
|
|
4242
|
+
sites: m.sites
|
|
4243
|
+
};
|
|
4244
|
+
});
|
|
4245
|
+
const bmpCeiling = members.some((m) => m.members.some((x) => x.endsWith("-\\uffff")));
|
|
4246
|
+
out.push({
|
|
4247
|
+
kind: "regex-class",
|
|
4248
|
+
id: `regex-class:${members[0].key}`,
|
|
4249
|
+
canonical: members[0].source,
|
|
4250
|
+
variants,
|
|
4251
|
+
count: total,
|
|
4252
|
+
drifted,
|
|
4253
|
+
bmpCeiling,
|
|
4254
|
+
suggestion: [
|
|
4255
|
+
drifted ? `${members.length} NEARLY-identical spellings of one class, across ${total} regex() terminals. They differ by ${variants.slice(1).map((v2) => v2.delta).join("; ")} \u2014 which is drift, not intent, unless every difference is deliberate. Two classes this close cannot be told apart by reading, so one of them is probably wrong and the tests cannot say which.` : `this class is re-spelled in ${total} DISTINCT regex() terminals. Extract it once \u2014 a named sub-leaf, or an interpolated source constant \u2014 so a range fix lands in one place. Re-spelling is how the near-identical variants elsewhere in this report came to exist.`,
|
|
4256
|
+
`Prefer the shared recognition surface where one already covers this class rather than a local extraction.`,
|
|
4257
|
+
bmpCeiling ? `The class tops out at U+FFFF, so astral-plane characters (emoji, many CJK extensions) fall OUTSIDE it. Widening the ceiling is exactly the kind of edit that has to reach every spelling.` : "",
|
|
4258
|
+
`Single-regex hygiene (duplicate class members, obscure ranges, a missing \`u\` flag) is \`eslint-plugin-regexp\`'s job and is not duplicated here \u2014 it sees one regex at a time, which is why it cannot see this.`
|
|
4259
|
+
].filter((s) => s !== "").join(" ")
|
|
4260
|
+
});
|
|
4261
|
+
}
|
|
4262
|
+
return out.sort((a, b) => Number(b.drifted) - Number(a.drifted) || b.count - a.count);
|
|
4263
|
+
}
|
|
4264
|
+
function intersects3(a, b) {
|
|
4265
|
+
if (a.kind === "any" || b.kind === "any") return true;
|
|
4266
|
+
if (a.kind === "empty" || b.kind === "empty") return false;
|
|
4267
|
+
for (const ra of a.ranges) for (const rb of b.ranges) if (ra.lo <= rb.hi && rb.lo <= ra.hi) return true;
|
|
4268
|
+
return false;
|
|
4269
|
+
}
|
|
4270
|
+
function intersection2(a, b) {
|
|
4271
|
+
if (a.kind === "any") return b;
|
|
4272
|
+
if (b.kind === "any") return a;
|
|
4273
|
+
if (a.kind === "empty" || b.kind === "empty") return { kind: "empty" };
|
|
4274
|
+
const ranges = [];
|
|
4275
|
+
for (const ra of a.ranges) for (const rb of b.ranges) {
|
|
4276
|
+
const lo = Math.max(ra.lo, rb.lo);
|
|
4277
|
+
const hi = Math.min(ra.hi, rb.hi);
|
|
4278
|
+
if (lo <= hi) ranges.push({ lo, hi });
|
|
4279
|
+
}
|
|
4280
|
+
return ranges.length === 0 ? { kind: "empty" } : { kind: "ranges", ranges };
|
|
4281
|
+
}
|
|
4282
|
+
function termsOf(arm) {
|
|
4283
|
+
let cur = arm;
|
|
4284
|
+
for (; ; ) {
|
|
4285
|
+
const d2 = cur._def;
|
|
4286
|
+
if (d2.tag === "node" || d2.tag === "transform" || d2.tag === "label" || d2.tag === "attempt" || d2.tag === "leaf") {
|
|
4287
|
+
cur = d2.parser;
|
|
4288
|
+
continue;
|
|
4289
|
+
}
|
|
4290
|
+
break;
|
|
4291
|
+
}
|
|
4292
|
+
const d = cur._def;
|
|
4293
|
+
return d.tag === "sequence" ? d.parsers : [cur];
|
|
4294
|
+
}
|
|
4295
|
+
function leadingRegex(arm) {
|
|
4296
|
+
const t = termsOf(arm)[0];
|
|
4297
|
+
return t !== void 0 && t._def.tag === "regex";
|
|
4298
|
+
}
|
|
4299
|
+
function findOverlaps(visited, shapeOf, resolve) {
|
|
4300
|
+
const out = [];
|
|
4301
|
+
for (const v2 of visited.values()) {
|
|
4302
|
+
const d = v2.d;
|
|
4303
|
+
if (d.tag !== "choice") continue;
|
|
4304
|
+
const arms = d.parsers;
|
|
4305
|
+
const fs = arms.map((a) => firstSetOf(a, /* @__PURE__ */ new Set(), resolve));
|
|
4306
|
+
const strategy = d.strategy;
|
|
4307
|
+
for (let i = 0; i < arms.length; i++) {
|
|
4308
|
+
for (let j = i + 1; j < arms.length; j++) {
|
|
4309
|
+
const A = fs[i], B = fs[j];
|
|
4310
|
+
if (A.kind === "any" || B.kind === "any") continue;
|
|
4311
|
+
if (!intersects3(A, B)) continue;
|
|
4312
|
+
const ta = termsOf(arms[i]), tb = termsOf(arms[j]);
|
|
4313
|
+
let shared = 0;
|
|
4314
|
+
while (shared < ta.length && shared < tb.length && shapeOf(ta[shared]) === shapeOf(tb[shared])) shared++;
|
|
4315
|
+
const handled = strategy?.tag === "sharedPrefix" && Array.isArray(strategy.members) && strategy.members.includes(i) && strategy.members.includes(j);
|
|
4316
|
+
const regexPair = leadingRegex(arms[i]) && leadingRegex(arms[j]);
|
|
4317
|
+
out.push({
|
|
4318
|
+
kind: "arm-overlap",
|
|
4319
|
+
id: `arm-overlap:${siteToString(v2.site)}:${i}-${j}`,
|
|
4320
|
+
site: v2.site,
|
|
4321
|
+
a: i,
|
|
4322
|
+
b: j,
|
|
4323
|
+
on: intersection2(A, B),
|
|
4324
|
+
sharedLeadingTerms: shared,
|
|
4325
|
+
sharedPrefix: shared > 0 ? clamp(ta.slice(0, shared).map((t) => render(t, 2)).join(", ")) : null,
|
|
4326
|
+
handledByStrategy: handled,
|
|
4327
|
+
regexPair,
|
|
4328
|
+
suggestion: overlapSuggestion(shared, handled, regexPair)
|
|
4329
|
+
});
|
|
4330
|
+
}
|
|
4331
|
+
}
|
|
4332
|
+
}
|
|
4333
|
+
return out.sort((a, b) => b.sharedLeadingTerms - a.sharedLeadingTerms);
|
|
4334
|
+
}
|
|
4335
|
+
function overlapSuggestion(shared, handled, regexPair) {
|
|
4336
|
+
if (shared > 0 && handled)
|
|
4337
|
+
return `the arms spell ${shared} identical leading term(s). parseman's \`sharedPrefix\` strategy already recognizes that prefix ONCE at runtime, so this costs nothing at parse time \u2014 it is a READABILITY finding: left-factoring into \`sequence(prefix, choice(\u2026))\` says in the grammar what the compiler had to infer.`;
|
|
4338
|
+
if (shared > 0)
|
|
4339
|
+
return `the arms spell ${shared} identical leading term(s) but do NOT qualify for the \`sharedPrefix\` strategy (it needs every arm to be a bare \`sequence\` starting with the same concrete terminal). Left-factor into \`sequence(prefix, choice(\u2026))\` \u2014 that both removes the re-spelling and lets the inner choice dispatch on its own first char.`;
|
|
4340
|
+
if (regexPair)
|
|
4341
|
+
return `two regex() arms whose character classes intersect. Nothing dispatches between them: the first is tried, and on failure the second re-scans the same position. Two regexes that can start on the same char are usually one terminal \u2014 merge them, or make the distinction explicit with a leading literal.`;
|
|
4342
|
+
return `the arms can start on the same character, so the choice cannot dispatch between them: on those chars the first arm is entered speculatively and rolled back before the second is tried. Give the arms disjoint leading terminals, or left-factor the common part out.`;
|
|
4343
|
+
}
|
|
4344
|
+
var PERF_ARM = "removes a speculative arm: on every input position reaching this choice, the deleted arm no longer costs a ctx save, a child-array push and a rollback before the surviving arm is tried. The saving scales with how often the choice is reached and how much the doomed arm consumes before failing.";
|
|
4345
|
+
var PERF_NONE = "";
|
|
4346
|
+
function findRewrites(visited, shapeOf) {
|
|
4347
|
+
const out = [];
|
|
4348
|
+
const push = (f, at) => {
|
|
4349
|
+
const site = siteToString(f.site);
|
|
4350
|
+
out.push({ kind: "rewrite", id: `rewrite:${f.rewrite}:${site}${at === void 0 ? "" : `:${at}`}`, ...f });
|
|
4351
|
+
};
|
|
4352
|
+
for (const v2 of visited.values()) {
|
|
4353
|
+
const d = v2.d;
|
|
4354
|
+
const site = v2.site;
|
|
4355
|
+
if (d.tag === "optional" || d.tag === "many" || d.tag === "oneOrMore") {
|
|
4356
|
+
const inner = d.parser;
|
|
4357
|
+
const id = inner._def;
|
|
4358
|
+
const collapsed = collapseRepeat(d, id);
|
|
4359
|
+
if (collapsed !== null) {
|
|
4360
|
+
push({
|
|
4361
|
+
rewrite: "idempotent-nesting",
|
|
4362
|
+
site,
|
|
4363
|
+
from: clamp(render(v2.p, 2)),
|
|
4364
|
+
to: collapsed,
|
|
4365
|
+
astNeutral: false,
|
|
4366
|
+
perf: "the outer wrapper still runs its own bookkeeping (and, for `many(optional(X))`, guards a zero-width iteration on every pass) for a nullability the inner combinator already has.",
|
|
4367
|
+
bug: id.tag === "optional" && d.tag === "many",
|
|
4368
|
+
suggestion: `\`${clamp(render(v2.p, 2))}\` is exactly \`${collapsed}\`. ${id.tag === "optional" && d.tag === "many" ? "The nested form is also a zero-width-iteration hazard: the inner `optional` succeeds consuming nothing, which the repeat has to detect and break on every pass." : ""} Candidate \u2014 verify AST identity: the collapsed form produces ONE level of array/undefined where the nested form produces two.`
|
|
4369
|
+
});
|
|
4370
|
+
}
|
|
4371
|
+
}
|
|
4372
|
+
if ((d.tag === "choice" || d.tag === "sequence") && d.parsers.length === 1) {
|
|
4373
|
+
push({
|
|
4374
|
+
rewrite: "single-element",
|
|
4375
|
+
site,
|
|
4376
|
+
from: clamp(render(v2.p, 2)),
|
|
4377
|
+
to: d.tag === "choice" ? clamp(render(d.parsers[0], 2)) : `${clamp(render(d.parsers[0], 2))} (note: sequence() of one still wraps its value in a 1-tuple)`,
|
|
4378
|
+
astNeutral: d.tag === "choice",
|
|
4379
|
+
perf: d.tag === "choice" ? "a one-arm choice still emits its dispatch scaffolding." : PERF_NONE,
|
|
4380
|
+
bug: false,
|
|
4381
|
+
suggestion: d.tag === "choice" ? "a `choice` with one arm IS that arm \u2014 the wrapper only adds dispatch scaffolding. Safe to unwrap (choice forwards its arm value unchanged)." : "a `sequence` with one element only exists to build a 1-tuple. If nothing reads that tuple, drop the wrapper \u2014 CANDIDATE, verify AST identity first."
|
|
4382
|
+
});
|
|
4383
|
+
}
|
|
4384
|
+
if (d.tag !== "choice") continue;
|
|
4385
|
+
const arms = d.parsers;
|
|
4386
|
+
const armShapes = arms.map(shapeOf);
|
|
4387
|
+
const strategy = d.strategy;
|
|
4388
|
+
for (let i = 0; i < arms.length; i++) for (let j = i + 1; j < arms.length; j++) {
|
|
4389
|
+
if (armShapes[i] !== armShapes[j]) continue;
|
|
4390
|
+
push({
|
|
4391
|
+
rewrite: "duplicate-arm",
|
|
4392
|
+
site,
|
|
4393
|
+
from: `choice(\u2026 arm[${i}] \u2026, arm[${j}] \u2026)`,
|
|
4394
|
+
to: `delete arm[${j}]`,
|
|
4395
|
+
astNeutral: true,
|
|
4396
|
+
perf: PERF_ARM,
|
|
4397
|
+
bug: true,
|
|
4398
|
+
suggestion: `arm[${i}] and arm[${j}] are the SAME shape (\`${clamp(render(arms[i], 2))}\`). Under ordered choice arm[${i}] always wins, so arm[${j}] is unreachable \u2014 delete it. This is AST-neutral by construction: an arm that can never be selected contributes nothing to any parse.`
|
|
4399
|
+
}, `${i}-${j}`);
|
|
4400
|
+
}
|
|
4401
|
+
for (let i = 0; i < arms.length; i++) for (let j = 0; j < arms.length; j++) {
|
|
4402
|
+
if (i >= j) continue;
|
|
4403
|
+
if (armShapes[i] === armShapes[j]) continue;
|
|
4404
|
+
const ta = termsOf(arms[i]), tb = termsOf(arms[j]);
|
|
4405
|
+
if (ta.length === 0 || ta.length >= tb.length) continue;
|
|
4406
|
+
let k = 0;
|
|
4407
|
+
while (k < ta.length && shapeOf(ta[k]) === shapeOf(tb[k])) k++;
|
|
4408
|
+
if (k !== ta.length) continue;
|
|
4409
|
+
push({
|
|
4410
|
+
rewrite: "shadowed-arm",
|
|
4411
|
+
site,
|
|
4412
|
+
from: `choice(\u2026 arm[${i}]=${clamp(render(arms[i], 2))}, \u2026 arm[${j}]=${clamp(render(arms[j], 2))} \u2026)`,
|
|
4413
|
+
to: `arm[${j}] is unreachable`,
|
|
4414
|
+
astNeutral: true,
|
|
4415
|
+
perf: PERF_ARM,
|
|
4416
|
+
bug: true,
|
|
4417
|
+
suggestion: `arm[${i}] is a strict prefix of arm[${j}] and comes FIRST. Whenever arm[${j}] would match, arm[${i}] also matches, and ordered choice commits to it \u2014 so arm[${j}] can never be selected. Either swap them (longest first) or fold arm[${j}]'s extra terms into arm[${i}] as \`optional(\u2026)\`. Verify by hand where a \`not()\`/\`gate()\` in arm[${i}] makes its success input-dependent in a way this static check cannot see.`
|
|
4418
|
+
}, `${i}-${j}`);
|
|
4419
|
+
}
|
|
4420
|
+
for (let i = 0; i + 1 < arms.length; i++) {
|
|
4421
|
+
const j = i + 1;
|
|
4422
|
+
const longer = termsOf(arms[i]), shorter = termsOf(arms[j]);
|
|
4423
|
+
const ordered = detectOptionalTerm(longer, shorter, shapeOf);
|
|
4424
|
+
const reversed = ordered === null ? detectOptionalTerm(shorter, longer, shapeOf) : null;
|
|
4425
|
+
const hit = ordered ?? reversed;
|
|
4426
|
+
if (hit === null) continue;
|
|
4427
|
+
const nodeWrapped = v2.enclosingNode ?? wrappingNodeType(arms[i]) ?? wrappingNodeType(arms[j]);
|
|
4428
|
+
const rest = hit.rest.map((t) => render(t, 2)).join(", ");
|
|
4429
|
+
const to = hit.where === "prefix" ? `sequence(optional(${render(hit.term, 2)}), ${rest})` : `sequence(${rest}, optional(${render(hit.term, 2)}))`;
|
|
4430
|
+
push({
|
|
4431
|
+
rewrite: hit.where === "prefix" ? "optional-prefix" : "optional-suffix",
|
|
4432
|
+
site,
|
|
4433
|
+
from: clamp(render(v2.p, 2)),
|
|
4434
|
+
to: clamp(to),
|
|
4435
|
+
astNeutral: false,
|
|
4436
|
+
perf: PERF_ARM,
|
|
4437
|
+
bug: false,
|
|
4438
|
+
suggestion: `these two arms differ only by a ${hit.where === "prefix" ? "leading" : "trailing"} \`${clamp(render(hit.term, 2))}\`, so the choice is exactly \`${clamp(to)}\` \u2014 same language, one arm instead of two.${reversed !== null ? ` NOTE THE ORDER: the shorter arm is FIRST, so under ordered choice the longer arm is only reachable when the shorter one fails \u2014 check that is what you meant.` : ""} CANDIDATE \u2014 verify AST identity before applying:${nodeWrapped === null ? "" : ` this sits under \`node('${nodeWrapped}')\`, and`} the two forms produce different child arrays (two children vs. one child plus an absent optional). If a build fn or a downstream consumer reads children positionally, the rewrite MOVES the tree.`
|
|
4439
|
+
}, `${i}-${j}`);
|
|
4440
|
+
}
|
|
4441
|
+
for (let i = 0; i + 1 < arms.length; i++) {
|
|
4442
|
+
const j = i + 1;
|
|
4443
|
+
const ta = termsOf(arms[i]), tb = termsOf(arms[j]);
|
|
4444
|
+
if (ta.length < 2 || tb.length < 2) continue;
|
|
4445
|
+
let k = 0;
|
|
4446
|
+
while (k < ta.length - 1 && k < tb.length - 1 && shapeOf(ta[k]) === shapeOf(tb[k])) k++;
|
|
4447
|
+
if (k === 0) continue;
|
|
4448
|
+
const handled = strategy?.tag === "sharedPrefix" && Array.isArray(strategy.members) && strategy.members.includes(i) && strategy.members.includes(j);
|
|
4449
|
+
push({
|
|
4450
|
+
rewrite: "left-factor",
|
|
4451
|
+
site,
|
|
4452
|
+
from: `choice(sequence(${ta.map((t) => render(t, 1)).join(", ")}), sequence(${tb.map((t) => render(t, 1)).join(", ")}))`,
|
|
4453
|
+
to: clamp(`sequence(${ta.slice(0, k).map((t) => render(t, 2)).join(", ")}, choice(sequence(${ta.slice(k).map((t) => render(t, 1)).join(", ")}), sequence(${tb.slice(k).map((t) => render(t, 1)).join(", ")})))`),
|
|
4454
|
+
astNeutral: false,
|
|
4455
|
+
perf: handled ? "NONE \u2014 parseman's `sharedPrefix` choice strategy already recognizes this prefix once and replays it, so the runtime cost is identical. This is a readability finding." : PERF_ARM,
|
|
4456
|
+
bug: false,
|
|
4457
|
+
suggestion: `arm[${i}] and arm[${j}] spell the same ${k} leading term(s). ${handled ? "The `sharedPrefix` strategy already collapses this AT RUNTIME, so left-factoring buys readability, not speed \u2014 but writing it out means the next reader does not have to trust the optimizer." : "Left-factor to `sequence(prefix, choice(tailA, tailB))`: the prefix is then recognized once and the inner choice dispatches on its own first char."} CANDIDATE \u2014 verify AST identity: factoring nests the tails one level deeper in the value.`
|
|
4458
|
+
}, `${i}-${j}`);
|
|
4459
|
+
}
|
|
4460
|
+
}
|
|
4461
|
+
for (const v2 of visited.values()) {
|
|
4462
|
+
if (v2.d.tag !== "sequence") continue;
|
|
4463
|
+
const hit = detectHandRolledSepBy(v2.d.parsers, shapeOf);
|
|
4464
|
+
if (hit === null) continue;
|
|
4465
|
+
const captured = v2.d.parsers.some((t) => capturesPositionally(t));
|
|
4466
|
+
const verdict = captured ? "blocked-by-capture" : v2.enclosingReducer ? "reducer-stride-review" : "convertible";
|
|
4467
|
+
const to = `sepBy(${clamp(render(hit.item, 2))}, ${clamp(render(hit.sep, 2))}${hit.trailing ? `, { trailing: 'allow' }` : ""}, { min: ${hit.min} })`;
|
|
4468
|
+
out.push({
|
|
4469
|
+
kind: "rewrite",
|
|
4470
|
+
id: `rewrite:hand-rolled-sepby:${siteToString(v2.site)}`,
|
|
4471
|
+
rewrite: "hand-rolled-sepby",
|
|
4472
|
+
site: v2.site,
|
|
4473
|
+
sepByVerdict: verdict,
|
|
4474
|
+
from: clamp(render(v2.p, 3)),
|
|
4475
|
+
to: verdict === "convertible" ? to : `${to} \u2014 NOT APPLICABLE HERE (${verdict})`,
|
|
4476
|
+
astNeutral: false,
|
|
4477
|
+
perf: verdict === "convertible" ? "the hand-rolled form allocates a tuple per repetition (`sequence(sep, item)`) and a wrapper array around them; `sepBy` builds the item list directly." : PERF_NONE,
|
|
4478
|
+
bug: false,
|
|
4479
|
+
suggestion: `${SEPBY_SPELLED_OUT(hit.trailing)} ${SEPBY_VERDICT_TEXT[verdict](hit.trailing)}`
|
|
4480
|
+
});
|
|
4481
|
+
}
|
|
4482
|
+
return out;
|
|
4483
|
+
}
|
|
4484
|
+
function collapseRepeat(outer, inner) {
|
|
4485
|
+
const o = outer.tag, i = inner.tag;
|
|
4486
|
+
if (o === "optional" && i === "optional") return "optional(X)";
|
|
4487
|
+
if (o === "optional" && i === "many") return "many(X)";
|
|
4488
|
+
if (o === "many" && i === "many") return "many(X)";
|
|
4489
|
+
if (o === "many" && i === "optional") return "many(X)";
|
|
4490
|
+
if (o === "optional" && i === "oneOrMore" && inner.min === 1 && inner.max === void 0) return "many(X)";
|
|
4491
|
+
if (o === "many" && i === "oneOrMore" && inner.min === 1 && inner.max === void 0) return "many(X)";
|
|
4492
|
+
return null;
|
|
4493
|
+
}
|
|
4494
|
+
function wrappingNodeType(arm) {
|
|
4495
|
+
const d = arm._def;
|
|
4496
|
+
return d.tag === "node" ? d.type ?? "(anonymous)" : null;
|
|
4497
|
+
}
|
|
4498
|
+
function detectOptionalTerm(longer, shorter, shapeOf) {
|
|
4499
|
+
if (longer.length !== shorter.length + 1 || shorter.length === 0) return null;
|
|
4500
|
+
const same = (a, b) => a.length === b.length && a.every((x, i) => shapeOf(x) === shapeOf(b[i]));
|
|
4501
|
+
if (same(longer.slice(1), shorter)) return { where: "prefix", term: longer[0], rest: shorter };
|
|
4502
|
+
if (same(longer.slice(0, -1), shorter)) return { where: "suffix", term: longer[longer.length - 1], rest: shorter };
|
|
4503
|
+
return null;
|
|
4504
|
+
}
|
|
4505
|
+
var SEPBY_SPELLED_OUT = (trailing) => `this is \`sepBy(item, sep${trailing ? ", { trailing: 'allow' }" : ""})\` spelled out.`;
|
|
4506
|
+
var SEPBY_VERDICT_TEXT = {
|
|
4507
|
+
"blocked-by-capture": () => "BLOCKED: the repetition CAPTURES its separator (`field`/`label`), which `sepBy` cannot express \u2014 it yields items and discards separators, so byte-faithful layout replay is lost. Leave this site alone; read it as a parseman gap (a separator-capturing repeat), not as work.",
|
|
4508
|
+
"reducer-stride-review": () => "NEEDS REVIEW, not conversion: an enclosing `node()`/`transform()` reducer reads this repetition's children, and a left-associating reducer typically STRIDES BY TWO over `[item, sep, item, sep, \u2026]` and re-emits the operator text. `sepBy` yields a FLAT ITEM list with the separators gone, so the reducer has to be rewritten in the same change or the tree moves. Convert only together with the reducer.",
|
|
4509
|
+
convertible: (trailing) => `No separator capture, and no enclosing reducer reads these children \u2014 this site is convertible. Beyond the ${trailing ? "four" : "three"} combinators it replaces, \`sepBy\` carries the right NULLABILITY: \`min: 0\` (the default) matches empty and does NOT gate as a choice arm, \`min: 1\` does \u2014 a distinction the hand-rolled shape leaves implicit and gets wrong silently. Still check the consumer: \`sepBy\` yields a flat item list where the hand-rolled form yields \`[first, [[sep, item], \u2026]]\`.`
|
|
4510
|
+
};
|
|
4511
|
+
function capturesPositionally(p, seen = /* @__PURE__ */ new Set()) {
|
|
4512
|
+
if (seen.has(p)) return false;
|
|
4513
|
+
seen.add(p);
|
|
4514
|
+
const d = p._def;
|
|
4515
|
+
if (d.tag === "field" || d.tag === "label") return true;
|
|
4516
|
+
for (const k of childrenOf(d)) if (capturesPositionally(k, seen)) return true;
|
|
4517
|
+
return false;
|
|
4518
|
+
}
|
|
4519
|
+
function detectHandRolledSepBy(terms, shapeOf) {
|
|
4520
|
+
if (terms.length < 2 || terms.length > 3) return null;
|
|
4521
|
+
const item = terms[0];
|
|
4522
|
+
const rep = terms[1]._def;
|
|
4523
|
+
if (rep.tag !== "many" && rep.tag !== "oneOrMore") return null;
|
|
4524
|
+
if (rep.max !== void 0) return null;
|
|
4525
|
+
const innerDef = rep.parser._def;
|
|
4526
|
+
if (innerDef.tag !== "sequence" || innerDef.parsers.length !== 2) return null;
|
|
4527
|
+
const [sep, repeated] = innerDef.parsers;
|
|
4528
|
+
if (shapeOf(repeated) !== shapeOf(item)) return null;
|
|
4529
|
+
let trailing = false;
|
|
4530
|
+
if (terms.length === 3) {
|
|
4531
|
+
const t = terms[2]._def;
|
|
4532
|
+
if (t.tag !== "optional" || shapeOf(t.parser) !== shapeOf(sep)) return null;
|
|
4533
|
+
trailing = true;
|
|
4534
|
+
}
|
|
4535
|
+
return { item, sep, trailing, min: 1 + rep.min };
|
|
4536
|
+
}
|
|
4537
|
+
function bodyTermsOf(p) {
|
|
4538
|
+
let cur = p;
|
|
4539
|
+
for (; ; ) {
|
|
4540
|
+
const d2 = cur._def;
|
|
4541
|
+
if (d2.tag === "node" || d2.tag === "grammar" || d2.tag === "transform" || d2.tag === "label" || d2.tag === "attempt" || d2.tag === "leaf" || d2.tag === "token" || d2.tag === "expect" || d2.tag === "field") {
|
|
4542
|
+
cur = d2.parser;
|
|
4543
|
+
continue;
|
|
4544
|
+
}
|
|
4545
|
+
break;
|
|
4546
|
+
}
|
|
4547
|
+
const d = cur._def;
|
|
4548
|
+
return d.tag === "sequence" ? d.parsers : [cur];
|
|
4549
|
+
}
|
|
4550
|
+
var MIN_SHARED_TERMS = 2;
|
|
4551
|
+
function findDivergentNodes(visited, shapeOf) {
|
|
4552
|
+
const byType = /* @__PURE__ */ new Map();
|
|
4553
|
+
for (const v2 of visited.values()) {
|
|
4554
|
+
if (v2.d.tag !== "node" || v2.d.type === void 0) continue;
|
|
4555
|
+
let m = byType.get(v2.d.type);
|
|
4556
|
+
if (m === void 0) {
|
|
4557
|
+
m = /* @__PURE__ */ new Map();
|
|
4558
|
+
byType.set(v2.d.type, m);
|
|
4559
|
+
}
|
|
4560
|
+
if (!m.has(v2.shape)) m.set(v2.shape, v2);
|
|
4561
|
+
}
|
|
4562
|
+
const out = [];
|
|
4563
|
+
for (const [type, m] of byType) {
|
|
4564
|
+
if (m.size < 2) continue;
|
|
4565
|
+
const members = [...m.values()];
|
|
4566
|
+
const termSets = members.map((v2) => bodyTermsOf(v2.p).map((t) => ({ id: shapeOf(t), text: clamp(render(t, 2)) })));
|
|
4567
|
+
const shared = termSets[0].filter((t) => termSets.every((set) => set.some((x) => x.id === t.id)));
|
|
4568
|
+
const uniqueShared = [...new Map(shared.map((t) => [t.id, t])).values()];
|
|
4569
|
+
if (uniqueShared.length < MIN_SHARED_TERMS) continue;
|
|
4570
|
+
const sharedIds = new Set(uniqueShared.map((t) => t.id));
|
|
4571
|
+
out.push({
|
|
4572
|
+
kind: "divergent-node",
|
|
4573
|
+
id: `divergent-node:${type}`,
|
|
4574
|
+
nodeType: type,
|
|
4575
|
+
count: members.length,
|
|
4576
|
+
sharedTerms: uniqueShared.map((t) => t.text),
|
|
4577
|
+
productions: members.map((v2, i) => ({
|
|
4578
|
+
shape: clamp(render(v2.p, 3)),
|
|
4579
|
+
site: v2.site,
|
|
4580
|
+
distinctTerms: termSets[i].filter((t) => !sharedIds.has(t.id)).map((t) => t.text)
|
|
4581
|
+
})),
|
|
4582
|
+
suggestion: `\`${type}\` is built by ${members.length} structurally different productions that nevertheless spell ${uniqueShared.length} identical term(s) (${uniqueShared.map((t) => `\`${t.text}\``).join(", ")}) \u2014 they are variants of ONE shape, not ${members.length} constructs. Nothing keeps them in sync: an edit to the ${type} shape has to land in all ${members.length}, and no test fails when it lands in one. Fold them into a single production whose varying part is a \`choice\`, or, if the variants exist for a parse-order reason (a fast path tried first), say so at the definition \u2014 the cost of the split is that a reader cannot tell which it is.`
|
|
4583
|
+
});
|
|
4584
|
+
}
|
|
4585
|
+
return out.sort((a, b) => b.sharedTerms.length * b.count - a.sharedTerms.length * a.count);
|
|
4586
|
+
}
|
|
4587
|
+
function resolveArm(p) {
|
|
4588
|
+
let cur = p;
|
|
4589
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4590
|
+
for (; ; ) {
|
|
4591
|
+
if (seen.has(cur)) return cur;
|
|
4592
|
+
seen.add(cur);
|
|
4593
|
+
const d = cur._def;
|
|
4594
|
+
if (d.tag === "label" || d.tag === "attempt") {
|
|
4595
|
+
cur = d.parser;
|
|
4596
|
+
continue;
|
|
4597
|
+
}
|
|
4598
|
+
if (d.tag === "lazy") {
|
|
4599
|
+
try {
|
|
4600
|
+
cur = d.thunk();
|
|
4601
|
+
continue;
|
|
4602
|
+
} catch {
|
|
4603
|
+
return cur;
|
|
4604
|
+
}
|
|
4605
|
+
}
|
|
4606
|
+
return cur;
|
|
4607
|
+
}
|
|
4608
|
+
}
|
|
4609
|
+
function nestedNodeTypes(root, out, seen, isRoot) {
|
|
4610
|
+
if (seen.has(root)) return;
|
|
4611
|
+
seen.add(root);
|
|
4612
|
+
const d = root._def;
|
|
4613
|
+
if (d.tag === "leaf" || d.tag === "token") return;
|
|
4614
|
+
if (d.tag === "node" && !isRoot) out.add(d.type ?? "(anonymous)");
|
|
4615
|
+
if (d.tag === "lazy") {
|
|
4616
|
+
try {
|
|
4617
|
+
nestedNodeTypes(d.thunk(), out, seen, false);
|
|
4618
|
+
} catch {
|
|
4619
|
+
}
|
|
4620
|
+
return;
|
|
4621
|
+
}
|
|
4622
|
+
for (const k of childrenOf(d)) nestedNodeTypes(k, out, seen, false);
|
|
4623
|
+
}
|
|
4624
|
+
var MAX_LOST_TYPES = 8;
|
|
4625
|
+
function findStructureLoss(visited, resolve) {
|
|
4626
|
+
const nestedCache = /* @__PURE__ */ new Map();
|
|
4627
|
+
const nested = (p) => {
|
|
4628
|
+
const hit = nestedCache.get(p);
|
|
4629
|
+
if (hit !== void 0) return hit;
|
|
4630
|
+
const out = /* @__PURE__ */ new Set();
|
|
4631
|
+
nestedNodeTypes(p, out, /* @__PURE__ */ new Set(), true);
|
|
4632
|
+
nestedCache.set(p, out);
|
|
4633
|
+
return out;
|
|
4634
|
+
};
|
|
4635
|
+
const findings = [];
|
|
4636
|
+
for (const v2 of visited.values()) {
|
|
4637
|
+
const d = v2.d;
|
|
4638
|
+
if (d.tag !== "choice") continue;
|
|
4639
|
+
const arms = d.parsers;
|
|
4640
|
+
const resolved = arms.map(resolveArm);
|
|
4641
|
+
const nodeTypes = resolved.map(wrappingNodeType);
|
|
4642
|
+
const fs = arms.map((a) => firstSetOf(a, /* @__PURE__ */ new Set(), resolve));
|
|
4643
|
+
for (let i = 0; i < arms.length; i++) {
|
|
4644
|
+
if (d.gates[i] != null) continue;
|
|
4645
|
+
const type = nodeTypes[i] ?? null;
|
|
4646
|
+
if (type === null) continue;
|
|
4647
|
+
if (nested(resolved[i]).size !== 0) continue;
|
|
4648
|
+
const A = fs[i];
|
|
4649
|
+
if (A.kind === "any") continue;
|
|
4650
|
+
for (let j = i + 1; j < arms.length; j++) {
|
|
4651
|
+
if (nodeTypes[j] !== type) continue;
|
|
4652
|
+
const B = fs[j];
|
|
4653
|
+
if (B.kind === "any" || !intersects3(A, B)) continue;
|
|
4654
|
+
const lost = nested(resolved[j]);
|
|
4655
|
+
if (lost.size === 0) continue;
|
|
4656
|
+
const lostNodeTypes = [...lost].sort();
|
|
4657
|
+
findings.push({
|
|
4658
|
+
kind: "structure-loss",
|
|
4659
|
+
id: `structure-loss:${siteToString(v2.site)}:${i}-${j}`,
|
|
4660
|
+
site: v2.site,
|
|
4661
|
+
nodeType: type,
|
|
4662
|
+
earlier: i,
|
|
4663
|
+
later: j,
|
|
4664
|
+
on: intersection2(A, B),
|
|
4665
|
+
lostNodeTypes: lostNodeTypes.slice(0, MAX_LOST_TYPES),
|
|
4666
|
+
earlierShape: clamp(render(resolved[i], 3)),
|
|
4667
|
+
laterShape: clamp(render(resolved[j], 3)),
|
|
4668
|
+
suggestion: structureLossSuggestion(type, i, j, lostNodeTypes)
|
|
4669
|
+
});
|
|
4670
|
+
}
|
|
4671
|
+
}
|
|
4672
|
+
}
|
|
4673
|
+
return findings.sort((a, b) => b.lostNodeTypes.length - a.lostNodeTypes.length);
|
|
4674
|
+
}
|
|
4675
|
+
function structureLossSuggestion(type, i, j, lost) {
|
|
4676
|
+
const names = lost.slice(0, MAX_LOST_TYPES).map((t) => `\`${t}\``).join(", ");
|
|
4677
|
+
const more = lost.length > MAX_LOST_TYPES ? ` (and ${lost.length - MAX_LOST_TYPES} more)` : "";
|
|
4678
|
+
return `arm[${i}] and arm[${j}] both build \`${type}\` and can start on the same character, so on every input BOTH accept, arm[${i}] wins \u2014 and arm[${i}] contains no \`node()\`, so it yields a \`${type}\` over bare leaves. arm[${j}] would have built ${names}${more} there. The parse still succeeds and the span is still right; only the tree shape changes, and only on the inputs arm[${i}] happens to accept \u2014 so a suite that asserts "it parses" and an output diff both stay green while every consumer of the tree (language service, formatter, refactor) silently loses those nodes on the commonest inputs. Decide which tree is the contract: if the structured one is, DELETE arm[${i}] or move it after arm[${j}]; if the flat one is, it must be flat for ALL inputs of this shape, not just the ones arm[${i}] matches \u2014 a fast path that is not tree-neutral is not a fast path.`;
|
|
4679
|
+
}
|
|
4680
|
+
function findKeywordRegexes(visited) {
|
|
4681
|
+
const armIndexOf = /* @__PURE__ */ new Map();
|
|
4682
|
+
for (const v2 of visited.values()) {
|
|
4683
|
+
if (v2.d.tag !== "choice") continue;
|
|
4684
|
+
v2.d.parsers.forEach((a, i) => {
|
|
4685
|
+
let cur = a;
|
|
4686
|
+
for (; ; ) {
|
|
4687
|
+
const cd = cur._def;
|
|
4688
|
+
if (cd.tag === "node" || cd.tag === "label" || cd.tag === "transform" || cd.tag === "leaf" || cd.tag === "attempt") {
|
|
4689
|
+
cur = cd.parser;
|
|
4690
|
+
continue;
|
|
4691
|
+
}
|
|
4692
|
+
break;
|
|
4693
|
+
}
|
|
4694
|
+
armIndexOf.set(cur, { owner: v2.p, index: i });
|
|
4695
|
+
});
|
|
4696
|
+
}
|
|
4697
|
+
const keywordArms = /* @__PURE__ */ new Map();
|
|
4698
|
+
for (const [arm, { owner }] of armIndexOf) {
|
|
4699
|
+
const d = arm._def;
|
|
4700
|
+
if (d.tag !== "regex" || keywordRegexShape(d.source) === null) continue;
|
|
4701
|
+
const list = keywordArms.get(owner) ?? [];
|
|
4702
|
+
list.push(armIndexOf.get(arm).index);
|
|
4703
|
+
keywordArms.set(owner, list);
|
|
4704
|
+
}
|
|
4705
|
+
const out = [];
|
|
4706
|
+
for (const v2 of visited.values()) {
|
|
4707
|
+
const d = v2.d;
|
|
4708
|
+
if (d.tag !== "regex") continue;
|
|
4709
|
+
const shape = keywordRegexShape(d.source);
|
|
4710
|
+
if (shape === null) continue;
|
|
4711
|
+
const ci = d.flags.includes("i");
|
|
4712
|
+
const unicode = d.flags.includes("u") || d.flags.includes("v");
|
|
4713
|
+
const own = armIndexOf.get(v2.p);
|
|
4714
|
+
const siblings = own === void 0 ? [] : (keywordArms.get(own.owner) ?? []).filter((i) => i !== own.index);
|
|
4715
|
+
const bq = (s) => `'${s.replace(/\\/g, "\\\\")}'`;
|
|
4716
|
+
const vocabulary = shape.words.length >= VOCABULARY_MIN_WORDS;
|
|
4717
|
+
const hazards = keywordAlternationHazards(shape.words, shape.boundary);
|
|
4718
|
+
const unrescued = hazards.filter((h) => !h.rescuedByBoundary);
|
|
4719
|
+
const longestFirst = shape.words.every((w, i) => i === 0 || shape.words[i - 1].length >= w.length);
|
|
4720
|
+
const shown = shape.words.length > 8 ? [...shape.words.slice(0, 6), "/* \u2026 */", shape.words[shape.words.length - 1]] : shape.words;
|
|
4721
|
+
const call = shape.words.length === 1 ? `word(${bq(shape.words[0])}, ${bq(shape.boundary ?? "_0-9A-Za-z")}${ci ? ", { caseInsensitive: true }" : ""})` : `keywords([${shown.map((w) => w === "/* \u2026 */" ? w : bq(w)).join(", ")}], { boundary: ${bq(shape.boundary ?? "_0-9A-Za-z")}${ci ? ", caseInsensitive: true" : ""} })`;
|
|
4722
|
+
out.push({
|
|
4723
|
+
kind: "keyword-regex",
|
|
4724
|
+
id: `keyword-regex:${siteToString(v2.site)}`,
|
|
4725
|
+
site: v2.site,
|
|
4726
|
+
source: d.source,
|
|
4727
|
+
flags: d.flags,
|
|
4728
|
+
words: shape.words,
|
|
4729
|
+
boundary: shape.boundary,
|
|
4730
|
+
caseFoldRisk: ci && !unicode,
|
|
4731
|
+
siblingArms: siblings,
|
|
4732
|
+
vocabulary,
|
|
4733
|
+
longestFirst,
|
|
4734
|
+
hazards,
|
|
4735
|
+
bug: unrescued.length > 0,
|
|
4736
|
+
suggestion: [
|
|
4737
|
+
vocabulary ? `this regex enumerates a FIXED VOCABULARY of ${shape.words.length} literal words${shape.words.length > 20 ? " \u2014 the alternation is long enough that nobody re-reads it" : ""}. A regex enumerating a fixed vocabulary is a keyword set written the hard way. Use \`${call}\` \u2014 it exposes an EXACT first-set that \`choice\` can dispatch on (the alternation exposes none), it owns the boundary so it is not re-spelled per word, it sorts LONGEST-FIRST by construction, and it lowers to a single charCodeAt scan.` : `\`regex(/${d.source}/${d.flags})\` hand-rolls a keyword and its word boundary. Use \`${call}\` \u2014 it owns the boundary (so \`${shape.boundary ?? ""}\` is not re-spelled per keyword), exposes an EXACT first-set that \`choice\` can dispatch on, and lowers to the same charCodeAt scan.`,
|
|
4738
|
+
unrescued.length > 0 ? `LIVE BUG \u2014 the alternation is ordered so ${unrescued.length} later alternative(s) can NEVER match: ${unrescued.slice(0, 3).map((h) => `\`${h.shorter}\` precedes \`${h.longer}\` and nothing rejects the following \`${h.at}\``).join("; ")}${unrescued.length > 3 ? `, and ${unrescued.length - 3} more` : ""}. Regex alternation is FIRST-match, not longest-match, and with no boundary guard to force a backtrack the shorter branch wins outright. \`keywords()\` cannot express this bug: it sorts longest-first.` : hazards.length > 0 ? `ORDERING HAZARD (currently harmless): ${hazards.length} earlier alternative(s) are strict prefixes of later ones \u2014 e.g. \`${hazards[0].shorter}\` before \`${hazards[0].longer}\`. Regex alternation is first-match, so these work ONLY because the trailing boundary guard rejects the next character and the engine backtracks into the longer branch. That correctness rests on a hand-maintained order plus a guard that happens to cover the right characters; \`keywords()\` sorts longest-first by construction and does not depend on either.` : !longestFirst && vocabulary ? `The alternation is not in longest-first order. It is correct today${shape.boundary === null ? "" : " (the boundary guard forces a backtrack where it matters)"}, but the ordering is hand-maintained and nothing checks it \u2014 adding a word that extends an existing one is a silent bug. \`keywords()\` sorts longest-first by construction.` : "",
|
|
4739
|
+
ci && !unicode ? `CORRECTNESS, not style: \`/i\` without \`/u\` does NOT fold case the way \`{c, toUpperCase(c), toLowerCase(c)}\` suggests \u2014 67 BMP code points fold in ways those three miss (\u03C2/\u03C3, \xB5/\u03BC, the \u01C4\u01C5\u01C6 digraphs, combining iota subscript). parseman fixed exactly this INSIDE \`keywords()\` (see combinators/case-fold.ts); a hand-rolled copy never received the fix, so its first-set is unsound for those inputs.` : "",
|
|
4740
|
+
siblings.length > 0 ? `arm[${own?.index}] here sits alongside arm(s) [${siblings.join(", ")}] in the same \`choice\`, which are also hand-rolled keywords. A single \`keywords([\u2026])\` replaces the whole group and gives longest-match dispatch across it \u2014 which is what that combinator exists for.` : ""
|
|
4741
|
+
].filter((s) => s !== "").join(" ")
|
|
4742
|
+
});
|
|
4743
|
+
}
|
|
4744
|
+
return out.sort((a, b) => Number(b.bug) - Number(a.bug) || b.hazards.length - a.hazards.length || b.words.length - a.words.length || b.siblingArms.length - a.siblingArms.length);
|
|
4745
|
+
}
|
|
4746
|
+
var sitesLine = (sites) => sites.slice(0, 6).map(siteToString).join("\n ") + (sites.length > 6 ? `
|
|
4747
|
+
\u2026 and ${sites.length - 6} more` : "");
|
|
4748
|
+
function formatDuplicationFindings(report) {
|
|
4749
|
+
const lines = [];
|
|
4750
|
+
for (const f of report.structureLoss) {
|
|
4751
|
+
lines.push(`parseman BUG [structure-loss] @ ${siteToString(f.site)}: arm[${f.earlier}] flattens \`${f.nodeType}\` where arm[${f.later}] structures it, on ${firstSetToString(f.on)}`);
|
|
4752
|
+
lines.push(` flat: ${f.earlierShape}`);
|
|
4753
|
+
lines.push(` rich: ${f.laterShape}`);
|
|
4754
|
+
lines.push(` lost: ${f.lostNodeTypes.map((t) => `\`${t}\``).join(", ")}`);
|
|
4755
|
+
lines.push(` fix: ${f.suggestion}`);
|
|
4756
|
+
}
|
|
4757
|
+
for (const f of report.rewrites) {
|
|
4758
|
+
lines.push(`parseman ${f.bug ? "BUG" : "rewrite"} [${f.rewrite}${f.sepByVerdict === void 0 ? "" : `/${f.sepByVerdict}`}] @ ${siteToString(f.site)}${f.astNeutral ? "" : " (candidate \u2014 verify AST identity)"}`);
|
|
4759
|
+
lines.push(` from: ${f.from}`);
|
|
4760
|
+
lines.push(` to: ${f.to}`);
|
|
4761
|
+
lines.push(` fix: ${f.suggestion}`);
|
|
4762
|
+
if (f.perf !== "") lines.push(` perf: ${f.perf}`);
|
|
4763
|
+
}
|
|
4764
|
+
for (const f of report.divergentNodes) {
|
|
4765
|
+
lines.push(`parseman divergent-node: \`${f.nodeType}\` is built by ${f.count} different productions sharing ${f.sharedTerms.length} term(s): ${f.sharedTerms.map((t) => `\`${t}\``).join(", ")}`);
|
|
4766
|
+
for (const p of f.productions) lines.push(` ${siteToString(p.site)}
|
|
4767
|
+
${p.shape}
|
|
4768
|
+
unique to it: ${p.distinctTerms.length === 0 ? "(none)" : p.distinctTerms.map((t) => `\`${t}\``).join(", ")}`);
|
|
4769
|
+
lines.push(` fix: ${f.suggestion}`);
|
|
4770
|
+
}
|
|
4771
|
+
for (const f of report.keywordRegexes) {
|
|
4772
|
+
const src = f.source.length > 90 ? `${f.source.slice(0, 88)}\u2026` : f.source;
|
|
4773
|
+
lines.push(`parseman ${f.bug ? "BUG " : ""}keyword-regex @ ${siteToString(f.site)}: /${src}/${f.flags}${f.vocabulary ? ` [${f.words.length} literal words${f.longestFirst ? "" : ", NOT longest-first"}]` : ""}${f.hazards.length > 0 ? ` [${f.hazards.length} prefix hazard(s)${f.bug ? ", UNRESCUED" : ""}]` : ""}${f.caseFoldRisk ? " [CASE-FOLD RISK]" : ""}`);
|
|
4774
|
+
lines.push(` fix: ${f.suggestion}`);
|
|
4775
|
+
}
|
|
4776
|
+
for (const f of report.duplicates) {
|
|
4777
|
+
lines.push(`parseman duplication: ${f.count}\xD7 identical ${f.size}-node shape (saves ${f.savings} nodes) \u2014 ${f.shape}`);
|
|
4778
|
+
lines.push(` at: ${sitesLine(f.sites)}`);
|
|
4779
|
+
lines.push(` fix: ${f.suggestion}`);
|
|
4780
|
+
}
|
|
4781
|
+
for (const f of report.nearDuplicates) {
|
|
4782
|
+
lines.push(`parseman near-duplication: ${f.count} clones of a ${f.sharedSize}-node shape differing at ONE slot (saves ${f.savings} nodes) \u2014 ${f.shape}`);
|
|
4783
|
+
lines.push(` slot: ${f.slotPath} \u2208 { ${f.variants.join(" | ")} }`);
|
|
4784
|
+
lines.push(` at: ${sitesLine(f.sites)}`);
|
|
4785
|
+
lines.push(` fix: ${f.suggestion}`);
|
|
4786
|
+
}
|
|
4787
|
+
for (const f of report.regexFragments) {
|
|
4788
|
+
lines.push(`parseman regex-fragment: \`${f.fragment}\` (${f.branches} branches) re-spelled in ${f.count} regex() terminals`);
|
|
4789
|
+
lines.push(` at: ${sitesLine(f.sites)}`);
|
|
4790
|
+
lines.push(` fix: ${f.suggestion}`);
|
|
4791
|
+
}
|
|
4792
|
+
for (const f of report.regexClasses) {
|
|
4793
|
+
lines.push(`parseman regex-class${f.drifted ? " DRIFT" : ""}: [${f.canonical}] across ${f.count} regex() terminals${f.bmpCeiling ? " [BMP ceiling]" : ""}`);
|
|
4794
|
+
for (const v2 of f.variants) lines.push(` [${v2.source}] \xD7${v2.count} ${v2.delta}
|
|
4795
|
+
${sitesLine(v2.sites)}`);
|
|
4796
|
+
lines.push(` fix: ${f.suggestion}`);
|
|
4797
|
+
}
|
|
4798
|
+
for (const f of report.overlaps) {
|
|
4799
|
+
lines.push(`parseman overlap @ ${siteToString(f.site)}: arm[${f.a}] \u2229 arm[${f.b}] on ${firstSetToString(f.on)}${f.sharedPrefix === null ? "" : ` \u2014 shared prefix: ${f.sharedPrefix}`}${f.handledByStrategy ? " [sharedPrefix strategy already handles this]" : ""}`);
|
|
4800
|
+
lines.push(` fix: ${f.suggestion}`);
|
|
4801
|
+
}
|
|
4802
|
+
return lines;
|
|
4803
|
+
}
|
|
4804
|
+
function duplicationFindingCount(report) {
|
|
4805
|
+
return report.duplicates.length + report.nearDuplicates.length + report.regexFragments.length + report.regexClasses.length + report.overlaps.length + report.rewrites.length + report.divergentNodes.length + report.structureLoss.length + report.keywordRegexes.length;
|
|
4806
|
+
}
|
|
4807
|
+
|
|
3425
4808
|
// src/combinators/scanTo.ts
|
|
3426
4809
|
function resolveScanSkip(explicitSkip, raw, ctx) {
|
|
3427
4810
|
if (raw) return [];
|
|
@@ -6642,12 +8025,14 @@ function emitNode(def, ctx, pos) {
|
|
|
6642
8025
|
ctx.buildSrcs.push(def.buildSrc ?? null);
|
|
6643
8026
|
}
|
|
6644
8027
|
const i = ind(ctx);
|
|
6645
|
-
const
|
|
6646
|
-
|
|
6647
|
-
const
|
|
6648
|
-
const
|
|
8028
|
+
const cstOut = ctx.hostMode === "cst" && !structural;
|
|
8029
|
+
if (!structural && !cstOut) ctx.hostBranchElided = true;
|
|
8030
|
+
const capturesTrivia = cstOut || mkType !== null || def.captureTrivia === true || def.trailingTrivia === true || !structural && buildReadsTrivia(def);
|
|
8031
|
+
const clonesState = !structural && (cstOut || buildReadsState(def));
|
|
8032
|
+
const capturesChildren = !structural && (cstOut || mkType !== null || def.unwrap || def.collapse || buildReadsChildren(def));
|
|
8033
|
+
const capturesRaw = !structural && (cstOut || mkType !== null || buildReadsRaw(def));
|
|
6649
8034
|
const hasFields = parserHasOwnFields(def.parser);
|
|
6650
|
-
const capturesFields = hasFields && !structural && buildReadsFields(def);
|
|
8035
|
+
const capturesFields = hasFields && !structural && (cstOut || buildReadsFields(def));
|
|
6651
8036
|
const innerEnablesTriviaCapture = parserEnablesTriviaCapture(def.parser);
|
|
6652
8037
|
const chV = v(ctx, "_ch");
|
|
6653
8038
|
const rawV = v(ctx, "_raw");
|
|
@@ -6664,8 +8049,9 @@ function emitNode(def, ctx, pos) {
|
|
|
6664
8049
|
const profHoist = `${i}const ${pmV} = _ctx._pmProfile, ${recV} = ${pmV}?.phase === 'recognizer', ${capV} = ${pmV}?.phase === 'capture'`;
|
|
6665
8050
|
const profileRecognizer = recV;
|
|
6666
8051
|
const profileCapture = capV;
|
|
6667
|
-
const
|
|
6668
|
-
const
|
|
8052
|
+
const cstMode = ctx.hostMode === "cst";
|
|
8053
|
+
const directCstV = !structural && !cstMode && (!capturesChildren || !capturesRaw) ? v(ctx, "_dcst") : null;
|
|
8054
|
+
const directCstGate = directCstV === null ? "false" : profileCapture;
|
|
6669
8055
|
const structuralCapturesTrivia = structural && (def.captureTrivia === true || def.trailingTrivia === true);
|
|
6670
8056
|
const chNeededExpr = def.unwrap || def.collapse ? "true" : `(_ctx._pmReadsCh ??= (_ctx.build === undefined || _ctx.build._parsemanReadsChildren !== false || _ctx.build._parsemanCstCollapse !== undefined))`;
|
|
6671
8057
|
const chAlloc = def.unwrap || def.collapse ? `${profileRecognizer} ? undefined : []` : `${profileRecognizer} ? undefined : ((${profileCapture} || ${chNeededExpr}) ? [] : undefined)`;
|
|
@@ -6746,7 +8132,7 @@ ${i}const ${chV} = ${chAlloc}, ${rawV} = ${profileRecognizer} ? undefined : [],
|
|
|
6746
8132
|
const ndV = v(ctx, "_nd");
|
|
6747
8133
|
const buildExpr = structural ? `{ _tag: 'node', type: ${JSON.stringify(def.type)}, span: { start: ${pos}, end: ${endVar} }, state: ${stV} ?? null, children: ${chV} }` : mkType ? emitInlineMkNodeExpr(mkType, chV, rawV, pos, endVar, tlV) : `${buildRef(ctx)}[${buildIdx}](${chV}, ${fObj}, { start: ${pos}, end: ${endVar} }, ${rawV}, ${tlV}, ${stV})`;
|
|
6748
8134
|
const hostBuildExpr = `_ctx._pmProfile?.phase === 'host' && _ctx._pmProfile.hostCalls++, _ctx.build(${JSON.stringify(def.type)}, ${chV}, ${fObj}, { start: ${pos}, end: ${endVar} }, ${rawV}, ${tlV}, ${stV})`;
|
|
6749
|
-
const ndExpr = structural ? `_ctx.build !== undefined ? (${hostBuildExpr}) : (${buildExpr})` :
|
|
8135
|
+
const ndExpr = structural ? `_ctx.build !== undefined ? (${hostBuildExpr}) : (${buildExpr})` : cstOut ? hostBuildExpr : buildExpr;
|
|
6750
8136
|
const hostCollapseExpr = structural ? `_ctx.build !== undefined && _ctx.build._parsemanCstCollapse !== undefined && ${chV}.length === 1 && ${rawV}.length === 1 && _ctx.build._parsemanCstCollapse(${JSON.stringify(def.type)}, ${chV}[0], ${chV}, ${rawV}) ? ${chV}[0] : (${ndExpr})` : ndExpr;
|
|
6751
8137
|
const unwrapExpr = `${chV}.length === 1 ? (${chV}[0] !== null && typeof ${chV}[0] === 'object' && ${chV}[0]._tag === 'leaf' ? ${chV}[0].value : ${chV}[0]) : (${ndExpr})`;
|
|
6752
8138
|
const collapseExpr = `${chV}.length === 1 ? ${chV}[0] : (${ndExpr})`;
|
|
@@ -7195,9 +8581,9 @@ function hasDirectBuildDef(p, seen = /* @__PURE__ */ new Set()) {
|
|
|
7195
8581
|
return false;
|
|
7196
8582
|
}
|
|
7197
8583
|
}
|
|
7198
|
-
return
|
|
8584
|
+
return childrenOf2(d).some((child) => hasDirectBuildDef(child, seen));
|
|
7199
8585
|
}
|
|
7200
|
-
function
|
|
8586
|
+
function childrenOf2(def) {
|
|
7201
8587
|
switch (def.tag) {
|
|
7202
8588
|
case "sequence":
|
|
7203
8589
|
case "choice":
|
|
@@ -7268,7 +8654,7 @@ function subtreeSizes(roots) {
|
|
|
7268
8654
|
const def = p._def;
|
|
7269
8655
|
let s = 1;
|
|
7270
8656
|
if (def.tag !== "lazy") {
|
|
7271
|
-
for (const child of
|
|
8657
|
+
for (const child of childrenOf2(def)) s += child._def.tag === "lazy" ? 1 : sz(child);
|
|
7272
8658
|
}
|
|
7273
8659
|
sizes.set(p, s);
|
|
7274
8660
|
return s;
|
|
@@ -7289,7 +8675,7 @@ function subtreeSizes(roots) {
|
|
|
7289
8675
|
return;
|
|
7290
8676
|
}
|
|
7291
8677
|
sz(p);
|
|
7292
|
-
for (const child of
|
|
8678
|
+
for (const child of childrenOf2(def)) visit(child);
|
|
7293
8679
|
};
|
|
7294
8680
|
for (const root of roots) visit(root);
|
|
7295
8681
|
return sizes;
|
|
@@ -7323,7 +8709,7 @@ function analyzeLazyUsageMulti(roots) {
|
|
|
7323
8709
|
if (isHoistableTag(def.tag)) {
|
|
7324
8710
|
counts.set(p, (counts.get(p) ?? 0) + 1);
|
|
7325
8711
|
}
|
|
7326
|
-
for (const child of
|
|
8712
|
+
for (const child of childrenOf2(def)) walk2(child);
|
|
7327
8713
|
}
|
|
7328
8714
|
const rootsArr = [...roots];
|
|
7329
8715
|
for (const root of rootsArr) walk2(root);
|
|
@@ -7338,7 +8724,7 @@ function ruleDependencies(ruleMap) {
|
|
|
7338
8724
|
const seen = /* @__PURE__ */ new Set();
|
|
7339
8725
|
const walk2 = (p, isRoot) => {
|
|
7340
8726
|
const def = p._def;
|
|
7341
|
-
const boundary = def.tag === "lazy" ?
|
|
8727
|
+
const boundary = def.tag === "lazy" ? nameOf.get(p) ?? p._ruleName : nameOf.get(p);
|
|
7342
8728
|
if (!isRoot && boundary !== void 0) {
|
|
7343
8729
|
found.add(boundary);
|
|
7344
8730
|
return;
|
|
@@ -7355,7 +8741,7 @@ function ruleDependencies(ruleMap) {
|
|
|
7355
8741
|
walk2(resolved, false);
|
|
7356
8742
|
return;
|
|
7357
8743
|
}
|
|
7358
|
-
for (const child of
|
|
8744
|
+
for (const child of childrenOf2(def)) walk2(child, false);
|
|
7359
8745
|
};
|
|
7360
8746
|
walk2(comb, true);
|
|
7361
8747
|
deps.set(name, [...found]);
|
|
@@ -7375,10 +8761,30 @@ function runGatingDiagnostic(combinator, opt) {
|
|
|
7375
8761
|
function runGatingDiagnosticRules(ruleMap, opt) {
|
|
7376
8762
|
return reportGating(opt, (o) => analyzeGatingRules(ruleMap, o));
|
|
7377
8763
|
}
|
|
7378
|
-
function runFusedGatingDiagnostic(getRuleMaps, opt) {
|
|
8764
|
+
function runFusedGatingDiagnostic(getRuleMaps, opt, getOpaque) {
|
|
7379
8765
|
if (resolveGatingLevel(opt) === "off") return void 0;
|
|
7380
8766
|
const ruleMaps = getRuleMaps();
|
|
7381
|
-
|
|
8767
|
+
const opaque = getOpaque?.() ?? [];
|
|
8768
|
+
const opaqueFindings = opaque.map((o) => ({
|
|
8769
|
+
rule: o.ruleNames.length > 0 ? o.ruleNames.join(", ") : `<artifact ${o.ns}>`,
|
|
8770
|
+
kind: "opaque-artifact",
|
|
8771
|
+
reason: `precompiled artifact "${o.ns}" carries compiled rule functions, not re-lowerable IR \u2014 its choices were not examined at this fuse.`
|
|
8772
|
+
}));
|
|
8773
|
+
if (!ruleMaps.some((map) => hasExternalRuleRef(map))) {
|
|
8774
|
+
if (opaqueFindings.length === 0) return void 0;
|
|
8775
|
+
return reportGating(opt, () => ({
|
|
8776
|
+
totalChoices: 0,
|
|
8777
|
+
gated: 0,
|
|
8778
|
+
recoverable: 0,
|
|
8779
|
+
unanalysable: opaqueFindings,
|
|
8780
|
+
ungated: [],
|
|
8781
|
+
accepted: [],
|
|
8782
|
+
deferred: [],
|
|
8783
|
+
acceptedUnused: [],
|
|
8784
|
+
choices: [],
|
|
8785
|
+
antiPatterns: []
|
|
8786
|
+
}));
|
|
8787
|
+
}
|
|
7382
8788
|
const winners = /* @__PURE__ */ new Map();
|
|
7383
8789
|
for (const map of ruleMaps) for (const [name, rule] of map) {
|
|
7384
8790
|
if (rule._def.tag === "lazy") {
|
|
@@ -7394,7 +8800,12 @@ function runFusedGatingDiagnostic(getRuleMaps, opt) {
|
|
|
7394
8800
|
return reportGating(opt, (o) => {
|
|
7395
8801
|
const deferredHere = new Set(analyzeGatingRules(entries, o).deferred.map((c) => c.id));
|
|
7396
8802
|
const fused = analyzeGatingRules(entries, { ...o, resolveRef: (name) => winners.get(name) });
|
|
7397
|
-
return {
|
|
8803
|
+
return {
|
|
8804
|
+
...fused,
|
|
8805
|
+
ungated: fused.ungated.filter((c) => deferredHere.has(c.id)),
|
|
8806
|
+
antiPatterns: [],
|
|
8807
|
+
unanalysable: [...fused.unanalysable, ...opaqueFindings]
|
|
8808
|
+
};
|
|
7398
8809
|
});
|
|
7399
8810
|
}
|
|
7400
8811
|
function reportGating(opt, analyze) {
|
|
@@ -7405,22 +8816,82 @@ function reportGating(opt, analyze) {
|
|
|
7405
8816
|
let report;
|
|
7406
8817
|
try {
|
|
7407
8818
|
report = analyze(analyzeOpts);
|
|
7408
|
-
} catch {
|
|
7409
|
-
|
|
8819
|
+
} catch (e) {
|
|
8820
|
+
const reason = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
|
8821
|
+
const failed = {
|
|
8822
|
+
totalChoices: 0,
|
|
8823
|
+
gated: 0,
|
|
8824
|
+
recoverable: 0,
|
|
8825
|
+
unanalysable: [{ rule: "<whole grammar>", kind: "not-a-combinator", reason: `gating analysis threw \u2014 ${reason}` }],
|
|
8826
|
+
ungated: [],
|
|
8827
|
+
accepted: [],
|
|
8828
|
+
deferred: [],
|
|
8829
|
+
acceptedUnused: [],
|
|
8830
|
+
choices: [],
|
|
8831
|
+
antiPatterns: []
|
|
8832
|
+
};
|
|
8833
|
+
const failLines = formatGatingWarnings(failed);
|
|
8834
|
+
if (level === "error") throw new Error(`parseman: gating analysis FAILED (no verdict available)
|
|
8835
|
+
${failLines.join("\n")}`);
|
|
8836
|
+
for (const l of failLines) console.warn(l);
|
|
8837
|
+
return failed;
|
|
7410
8838
|
}
|
|
7411
8839
|
const lines = formatGatingWarnings(report);
|
|
7412
8840
|
if (lines.length > 0) {
|
|
7413
|
-
if (level === "error") throw new Error(`parseman: ${report.ungated.length} ungated hot choice(s) / ${report.antiPatterns.length} anti-pattern(s)
|
|
8841
|
+
if (level === "error") throw new Error(`parseman: ${report.ungated.length} ungated hot choice(s) / ${report.antiPatterns.length} anti-pattern(s) / ${report.unanalysable.length} unanalysable rule(s)
|
|
8842
|
+
${lines.join("\n")}`);
|
|
8843
|
+
for (const l of lines) console.warn(l);
|
|
8844
|
+
}
|
|
8845
|
+
return report;
|
|
8846
|
+
}
|
|
8847
|
+
function resolveDuplicationLevel(opt) {
|
|
8848
|
+
const explicit = typeof opt === "string" ? opt : opt?.level;
|
|
8849
|
+
if (explicit !== void 0) return explicit;
|
|
8850
|
+
const env = typeof process !== "undefined" ? process.env?.PARSEMAN_DUPLICATION : void 0;
|
|
8851
|
+
if (env === "off" || env === "warn" || env === "error") return env;
|
|
8852
|
+
return "off";
|
|
8853
|
+
}
|
|
8854
|
+
function reportDuplication(opt, analyze) {
|
|
8855
|
+
const level = resolveDuplicationLevel(opt);
|
|
8856
|
+
if (level === "off") return void 0;
|
|
8857
|
+
const obj = opt !== null && typeof opt === "object" ? opt : void 0;
|
|
8858
|
+
const analyzeOpts = obj === void 0 ? void 0 : {
|
|
8859
|
+
...obj.accept !== void 0 ? { accept: obj.accept } : {},
|
|
8860
|
+
...obj.minSize !== void 0 ? { minSize: obj.minSize } : {},
|
|
8861
|
+
...obj.maxFindings !== void 0 ? { maxFindings: obj.maxFindings } : {},
|
|
8862
|
+
...obj.entryName !== void 0 ? { entryName: obj.entryName } : {}
|
|
8863
|
+
};
|
|
8864
|
+
let report;
|
|
8865
|
+
try {
|
|
8866
|
+
report = analyze(analyzeOpts);
|
|
8867
|
+
} catch (err) {
|
|
8868
|
+
console.warn(
|
|
8869
|
+
`parseman: the duplication diagnostic could not run, so NOTHING was checked \u2014 this is not a clean result.
|
|
8870
|
+
${err instanceof Error ? err.message : String(err)}`
|
|
8871
|
+
);
|
|
8872
|
+
return void 0;
|
|
8873
|
+
}
|
|
8874
|
+
const lines = formatDuplicationFindings(report);
|
|
8875
|
+
if (lines.length > 0) {
|
|
8876
|
+
if (level === "error") throw new Error(`parseman: ${duplicationFindingCount(report)} duplication/overlap finding(s)
|
|
7414
8877
|
${lines.join("\n")}`);
|
|
7415
8878
|
for (const l of lines) console.warn(l);
|
|
7416
8879
|
}
|
|
7417
8880
|
return report;
|
|
7418
8881
|
}
|
|
8882
|
+
function runDuplicationDiagnostic(combinator, opt) {
|
|
8883
|
+
return reportDuplication(opt, (o) => analyzeDuplication(combinator, o));
|
|
8884
|
+
}
|
|
8885
|
+
function runDuplicationDiagnosticRules(ruleMap, opt) {
|
|
8886
|
+
return reportDuplication(opt, (o) => analyzeDuplicationRules(ruleMap, o));
|
|
8887
|
+
}
|
|
7419
8888
|
function compile(combinator, mapFnSources, opts) {
|
|
7420
8889
|
const gatingReport = runGatingDiagnostic(combinator, opts?.gating);
|
|
8890
|
+
runDuplicationDiagnostic(combinator, opts?.duplication);
|
|
7421
8891
|
markUnusedValues(combinator);
|
|
7422
8892
|
const grammarTrivia = combinator._meta.grammarTrivia;
|
|
7423
8893
|
const grammarScanSkip = combinator._meta.grammarScanSkip;
|
|
8894
|
+
const grammarHostMode = combinator._meta.grammarHostMode;
|
|
7424
8895
|
const ctx = {
|
|
7425
8896
|
vars: 0,
|
|
7426
8897
|
indent: 1,
|
|
@@ -7440,6 +8911,7 @@ function compile(combinator, mapFnSources, opts) {
|
|
|
7440
8911
|
namedFnDecls: [],
|
|
7441
8912
|
capturing: hasNodeDef(combinator),
|
|
7442
8913
|
recovery: opts?.recovery ?? false,
|
|
8914
|
+
hostMode: opts?.hostMode ?? grammarHostMode ?? "ast",
|
|
7443
8915
|
...opts?.coverage ? { coverage: { plan: buildGrammarPlan(combinator), entry: combinator } } : {},
|
|
7444
8916
|
lazyUsage: analyzeLazyUsage(combinator),
|
|
7445
8917
|
...grammarTrivia ? { activeTrivia: grammarTrivia, triviaKindLabels: grammarTrivia._meta.triviaKindLabels } : {},
|
|
@@ -7512,6 +8984,7 @@ function compile(combinator, mapFnSources, opts) {
|
|
|
7512
8984
|
return fn(input, pos, ctx.runtimeParsers, ctx.mapFns, ctx.buildFns, defaultCtx);
|
|
7513
8985
|
},
|
|
7514
8986
|
parseWithContext(input, parseCtx, pos = 0) {
|
|
8987
|
+
assertHostModeCompatible(ctx.hostMode ?? "ast", parseCtx.build, !!ctx.hostBranchElided);
|
|
7515
8988
|
return fn(input, pos, ctx.runtimeParsers, ctx.mapFns, ctx.buildFns, parseCtx);
|
|
7516
8989
|
},
|
|
7517
8990
|
// Note: collects expect() errors via _errors. Unlike interpreter
|
|
@@ -7542,9 +9015,11 @@ function publicRuleWrapperSource(rule, fnSource) {
|
|
|
7542
9015
|
function compileLinkable(ruleMapArg, ns, opts) {
|
|
7543
9016
|
if (!ns) throw new Error("compileLinkable: ns must be a non-empty namespace");
|
|
7544
9017
|
if (opts?.gating !== void 0) runGatingDiagnosticRules(ruleMapArg, opts.gating);
|
|
9018
|
+
if (opts?.duplication !== void 0) runDuplicationDiagnosticRules(ruleMapArg, opts.duplication);
|
|
7545
9019
|
for (const [, rule] of ruleMapArg) markUnusedValues(rule);
|
|
7546
9020
|
const grammarTrivia = opts?.trivia ?? ruleMapArg.map(([, r]) => r._meta.grammarTrivia).find(Boolean);
|
|
7547
9021
|
const grammarScanSkip = opts?.scanSkip ?? ruleMapArg.map(([, r]) => r._meta.grammarScanSkip).find(Boolean);
|
|
9022
|
+
const grammarHostMode = opts?.hostMode ?? ruleMapArg.map(([, r]) => r._meta.grammarHostMode).find(Boolean);
|
|
7548
9023
|
const ruleMap = ruleMapArg.filter(([, val]) => {
|
|
7549
9024
|
const d = val._def;
|
|
7550
9025
|
if (d.tag !== "lazy") return true;
|
|
@@ -7578,6 +9053,7 @@ function compileLinkable(ruleMapArg, ns, opts) {
|
|
|
7578
9053
|
// merely returning their scalar parse values.
|
|
7579
9054
|
capturing: opts?.captureTerminals === true || ruleMap.some(([, rule]) => hasNodeDef(rule)),
|
|
7580
9055
|
recovery: opts?.recovery ?? false,
|
|
9056
|
+
hostMode: grammarHostMode ?? "ast",
|
|
7581
9057
|
...opts?.coverage ? { coverage: { plan: opts.coverage } } : {},
|
|
7582
9058
|
lazyUsage: analyzeLazyUsageMulti(ruleMap.map(([, rule]) => rule)),
|
|
7583
9059
|
ns,
|
|
@@ -7628,7 +9104,7 @@ function compileLinkable(ruleMapArg, ns, opts) {
|
|
|
7628
9104
|
scanExternal(resolved);
|
|
7629
9105
|
return;
|
|
7630
9106
|
}
|
|
7631
|
-
for (const child of
|
|
9107
|
+
for (const child of childrenOf2(def)) scanExternal(child);
|
|
7632
9108
|
};
|
|
7633
9109
|
for (const [, rule] of ruleMap) scanExternal(rule);
|
|
7634
9110
|
}
|
|
@@ -7733,6 +9209,8 @@ function compileLinkable(ruleMapArg, ns, opts) {
|
|
|
7733
9209
|
deps: ruleDependencies(ruleMap),
|
|
7734
9210
|
needsEmptyTl: !!ctx.needsEmptyTl,
|
|
7735
9211
|
needsHostReads: !!ctx.needsHostReads,
|
|
9212
|
+
hostMode: ctx.hostMode ?? "ast",
|
|
9213
|
+
hostBranchElided: !!ctx.hostBranchElided,
|
|
7736
9214
|
hasDirectBuilders: ruleMap.some(([, rule]) => hasDirectBuildDef(rule)),
|
|
7737
9215
|
isRecognitionOnly: !hasSemanticReduction(ruleMap.map(([, rule]) => rule), externalRefs),
|
|
7738
9216
|
mfFns: mfSrcs ? [] : ctx.mapFns,
|
|
@@ -7755,7 +9233,7 @@ function hasExternalRuleRef(ruleMap) {
|
|
|
7755
9233
|
if (resolved === void 0) return p._ruleName !== void 0;
|
|
7756
9234
|
return visit(resolved);
|
|
7757
9235
|
}
|
|
7758
|
-
return
|
|
9236
|
+
return childrenOf2(def).some(visit);
|
|
7759
9237
|
};
|
|
7760
9238
|
return ruleMap.some(([, rule]) => visit(rule));
|
|
7761
9239
|
}
|
|
@@ -7777,7 +9255,7 @@ function hasSemanticReduction(roots, externalRefs) {
|
|
|
7777
9255
|
return true;
|
|
7778
9256
|
}
|
|
7779
9257
|
}
|
|
7780
|
-
return
|
|
9258
|
+
return childrenOf2(def).some(visit);
|
|
7781
9259
|
};
|
|
7782
9260
|
return roots.some(visit);
|
|
7783
9261
|
}
|
|
@@ -7846,7 +9324,7 @@ function withCtx(extra, combinator) {
|
|
|
7846
9324
|
}
|
|
7847
9325
|
|
|
7848
9326
|
// src/compiler/ir-serialize.ts
|
|
7849
|
-
function
|
|
9327
|
+
function childrenOf3(def) {
|
|
7850
9328
|
switch (def.tag) {
|
|
7851
9329
|
case "sequence":
|
|
7852
9330
|
case "choice":
|
|
@@ -7885,7 +9363,7 @@ function childrenOf2(def) {
|
|
|
7885
9363
|
return [];
|
|
7886
9364
|
}
|
|
7887
9365
|
}
|
|
7888
|
-
var
|
|
9366
|
+
var ruleNameOf4 = (c) => c._ruleName;
|
|
7889
9367
|
function lazyTarget(c) {
|
|
7890
9368
|
if (c._def.tag !== "lazy") return null;
|
|
7891
9369
|
try {
|
|
@@ -8067,12 +9545,16 @@ var Serializer = class {
|
|
|
8067
9545
|
emitted = /* @__PURE__ */ new Set();
|
|
8068
9546
|
decls = [];
|
|
8069
9547
|
ruleValues = /* @__PURE__ */ new Set();
|
|
9548
|
+
localRuleNames = /* @__PURE__ */ new Map();
|
|
8070
9549
|
ruleMap;
|
|
8071
9550
|
scanSkip;
|
|
8072
9551
|
constructor(ruleMap, scanSkip = []) {
|
|
8073
9552
|
this.ruleMap = ruleMap;
|
|
8074
9553
|
this.scanSkip = scanSkip;
|
|
8075
|
-
for (const [, c] of ruleMap)
|
|
9554
|
+
for (const [name, c] of ruleMap) {
|
|
9555
|
+
this.localRuleNames.set(c, name);
|
|
9556
|
+
this.ruleValues.add(this.body(c));
|
|
9557
|
+
}
|
|
8076
9558
|
}
|
|
8077
9559
|
/** A grammar-level `scanSkip` unit is an opaque TERMINAL (a string, a balanced
|
|
8078
9560
|
* bracket) — it must not reach a grammar-rule ref (`g[name]`), because the
|
|
@@ -8083,18 +9565,18 @@ var Serializer = class {
|
|
|
8083
9565
|
if (seen.has(c)) return;
|
|
8084
9566
|
seen.add(c);
|
|
8085
9567
|
if (c._def.tag === "lazy") {
|
|
8086
|
-
if (
|
|
9568
|
+
if (ruleNameOf4(c) !== void 0) throw new Unserializable("scanSkip unit references a grammar rule");
|
|
8087
9569
|
const target = lazyTarget(c);
|
|
8088
9570
|
if (target) this.assertNoRuleRef(target, seen);
|
|
8089
9571
|
return;
|
|
8090
9572
|
}
|
|
8091
|
-
for (const child of
|
|
9573
|
+
for (const child of childrenOf3(c._def)) this.assertNoRuleRef(child, seen);
|
|
8092
9574
|
}
|
|
8093
9575
|
/** A `rules()` map wraps each rule value in a named `lazy` proxy; the real body is
|
|
8094
9576
|
* its target (compileLinkable unwraps the same way). A ref INSIDE a body stays a
|
|
8095
9577
|
* `g[name]` ref — only the top-level entry is unwrapped. */
|
|
8096
9578
|
body(c) {
|
|
8097
|
-
return c._def.tag === "lazy" &&
|
|
9579
|
+
return c._def.tag === "lazy" && ruleNameOf4(c) !== void 0 ? lazyTarget(c) ?? c : c;
|
|
8098
9580
|
}
|
|
8099
9581
|
run() {
|
|
8100
9582
|
for (const [, c] of this.ruleMap) this.analyze(this.body(c), /* @__PURE__ */ new Set());
|
|
@@ -8136,7 +9618,7 @@ ${outerDecls.map((d) => " " + d).join("\n")}
|
|
|
8136
9618
|
analyze(c, active) {
|
|
8137
9619
|
const def = c._def;
|
|
8138
9620
|
if (def.tag === "lazy") {
|
|
8139
|
-
if (
|
|
9621
|
+
if (ruleNameOf4(c) !== void 0) return;
|
|
8140
9622
|
const target = lazyTarget(c);
|
|
8141
9623
|
if (!target) throw new Unserializable("unresolved unnamed ref");
|
|
8142
9624
|
if (active.has(target)) {
|
|
@@ -8152,7 +9634,7 @@ ${outerDecls.map((d) => " " + d).join("\n")}
|
|
|
8152
9634
|
this.counts.set(c, (this.counts.get(c) ?? 0) + 1);
|
|
8153
9635
|
if ((this.counts.get(c) ?? 0) > 1) return;
|
|
8154
9636
|
active.add(c);
|
|
8155
|
-
for (const child of
|
|
9637
|
+
for (const child of childrenOf3(def)) this.analyze(child, active);
|
|
8156
9638
|
active.delete(c);
|
|
8157
9639
|
}
|
|
8158
9640
|
/** Reference `c`: a const var if it has one (emitting its decl first), else inline.
|
|
@@ -8188,7 +9670,8 @@ ${outerDecls.map((d) => " " + d).join("\n")}
|
|
|
8188
9670
|
const kid = (x) => this.ref(x, selfOf);
|
|
8189
9671
|
switch (def.tag) {
|
|
8190
9672
|
case "lazy": {
|
|
8191
|
-
|
|
9673
|
+
const name = this.localRuleNames.get(c) ?? ruleNameOf4(c);
|
|
9674
|
+
if (name !== void 0) return `g[${JSON.stringify(name)}]`;
|
|
8192
9675
|
const target = lazyTarget(c);
|
|
8193
9676
|
if (selfOf && target === selfOf.comb) return selfOf.var;
|
|
8194
9677
|
return this.ref(target, selfOf);
|
|
@@ -8288,8 +9771,12 @@ ${outerDecls.map((d) => " " + d).join("\n")}
|
|
|
8288
9771
|
|
|
8289
9772
|
// src/compiler/linker.ts
|
|
8290
9773
|
var _nsCounter = 0;
|
|
8291
|
-
function linkable(rulesMap, ns, trivia2) {
|
|
8292
|
-
const pieces = compileLinkable(
|
|
9774
|
+
function linkable(rulesMap, ns, trivia2, hostMode) {
|
|
9775
|
+
const pieces = compileLinkable(
|
|
9776
|
+
Object.entries(rulesMap),
|
|
9777
|
+
ns ?? `_lk${_nsCounter++}_`,
|
|
9778
|
+
{ ...trivia2 ? { trivia: trivia2 } : {}, ...hostMode ? { hostMode } : {} }
|
|
9779
|
+
);
|
|
8293
9780
|
if (!pieces) throw new Error("linkable(): this grammar cannot be compiled to a linkable artifact (contains a runtime-only parser fallback)");
|
|
8294
9781
|
return pieces;
|
|
8295
9782
|
}
|
|
@@ -8351,7 +9838,23 @@ function fusedBody(pieces) {
|
|
|
8351
9838
|
...[...winner].map(([k, p]) => p.ruleFns.get(k))
|
|
8352
9839
|
];
|
|
8353
9840
|
const wrapperEntries = [...winner].map(([k, p]) => `${JSON.stringify(k)}: ${p.wrappers.get(k)}`);
|
|
8354
|
-
const
|
|
9841
|
+
const { mode, elided } = hostModeOfPieces(pieces);
|
|
9842
|
+
const rawBody = [
|
|
9843
|
+
...lines,
|
|
9844
|
+
"const _map = {",
|
|
9845
|
+
wrapperEntries.join(",\n"),
|
|
9846
|
+
"}",
|
|
9847
|
+
`Object.defineProperty(_map, Symbol.for(${JSON.stringify(FUSED_HOST_MODE.description)}), { value: ${JSON.stringify(mode)}, enumerable: false })`,
|
|
9848
|
+
`Object.defineProperty(_map, Symbol.for(${JSON.stringify(FUSED_HOST_ELIDED.description)}), { value: ${JSON.stringify(elided)}, enumerable: false })`,
|
|
9849
|
+
// …and on each rule FUNCTION, because `run(map.Rule, …)` is handed the rule, not the
|
|
9850
|
+
// map, and would otherwise have nothing to check against. Done once at fuse time,
|
|
9851
|
+
// before any rule has been called, so it costs nothing per parse.
|
|
9852
|
+
"for (const _k of Object.keys(_map)) {",
|
|
9853
|
+
` Object.defineProperty(_map[_k], Symbol.for(${JSON.stringify(FUSED_HOST_MODE.description)}), { value: ${JSON.stringify(mode)}, enumerable: false })`,
|
|
9854
|
+
` Object.defineProperty(_map[_k], Symbol.for(${JSON.stringify(FUSED_HOST_ELIDED.description)}), { value: ${JSON.stringify(elided)}, enumerable: false })`,
|
|
9855
|
+
"}",
|
|
9856
|
+
"return _map"
|
|
9857
|
+
].join("\n");
|
|
8355
9858
|
const ANY2 = { kind: "any" };
|
|
8356
9859
|
const EMPTY3 = { kind: "empty" };
|
|
8357
9860
|
const recipes = /* @__PURE__ */ new Map();
|
|
@@ -8410,19 +9913,78 @@ function fusedBody(pieces) {
|
|
|
8410
9913
|
}
|
|
8411
9914
|
return { body, env };
|
|
8412
9915
|
}
|
|
9916
|
+
function hostModeOfPieces(pieces) {
|
|
9917
|
+
const mode = pieces.find((p) => p.hostMode !== void 0 && p.hostMode !== "ast")?.hostMode ?? "ast";
|
|
9918
|
+
const elided = pieces.filter((p) => p.hostBranchElided === true);
|
|
9919
|
+
if (mode === "cst" && elided.length > 0) {
|
|
9920
|
+
throw new Error(
|
|
9921
|
+
`compose: cannot build a positioned-CST artifact from pieces that were compiled for host mode "ast" \u2014 ${elided.map((p) => `"${p.ns}"`).join(", ")} ${elided.length === 1 ? "dropped its" : "dropped their"} direct builders' CST branch, so ${elided.length === 1 ? "it" : "they"} would return AST objects inside the CST. An already-compiled piece keeps the mode it was built with; only pieces carried as IR are re-lowered by this compose. Re-compile ${elided.length === 1 ? "it" : "them"} with hostMode: 'cst', or pass the source grammar so it can be re-lowered.`
|
|
9922
|
+
);
|
|
9923
|
+
}
|
|
9924
|
+
return { mode, elided: elided.length > 0 };
|
|
9925
|
+
}
|
|
8413
9926
|
function fuseRules(pieces) {
|
|
8414
9927
|
const { body, env } = fusedBody(pieces);
|
|
8415
9928
|
return new Function("_env", body)(env);
|
|
8416
9929
|
}
|
|
9930
|
+
function fusedHostModeOf(registry) {
|
|
9931
|
+
const m = registry[FUSED_HOST_MODE];
|
|
9932
|
+
return m === "cst" ? "cst" : "ast";
|
|
9933
|
+
}
|
|
9934
|
+
function fusedHostElidedOf(registry) {
|
|
9935
|
+
return registry[FUSED_HOST_ELIDED] === true;
|
|
9936
|
+
}
|
|
8417
9937
|
var COMPOSED_PIECES = /* @__PURE__ */ Symbol.for("parseman.composedPieces");
|
|
9938
|
+
function composedPiecesOf(grammar) {
|
|
9939
|
+
const pieces = grammar[COMPOSED_PIECES];
|
|
9940
|
+
return Array.isArray(pieces) ? pieces : void 0;
|
|
9941
|
+
}
|
|
8418
9942
|
var LEAF_COMPOSED = /* @__PURE__ */ Symbol.for("parseman.leafComposed");
|
|
8419
9943
|
var COMPOSED_TRIVIA = /* @__PURE__ */ Symbol.for("parseman.composedTrivia");
|
|
8420
9944
|
var COMPOSED_COVERAGE_RULES = /* @__PURE__ */ Symbol.for("parseman.composedCoverageRules");
|
|
8421
9945
|
function isIRPiece(p) {
|
|
8422
9946
|
return !!p && typeof p === "object" && typeof p.ir === "string" && typeof p.ns === "string" && !("ruleFns" in p);
|
|
8423
9947
|
}
|
|
8424
|
-
function
|
|
8425
|
-
|
|
9948
|
+
function once(fn) {
|
|
9949
|
+
let done = false;
|
|
9950
|
+
let value;
|
|
9951
|
+
return () => {
|
|
9952
|
+
if (!done) {
|
|
9953
|
+
value = fn();
|
|
9954
|
+
done = true;
|
|
9955
|
+
}
|
|
9956
|
+
return value;
|
|
9957
|
+
};
|
|
9958
|
+
}
|
|
9959
|
+
function carriedRuleMapsDetailed(carried) {
|
|
9960
|
+
const maps = [];
|
|
9961
|
+
const opaque = [];
|
|
9962
|
+
for (const p of carried) {
|
|
9963
|
+
if (isIRPiece(p)) {
|
|
9964
|
+
maps.push(evalRuleMapIR(p.ir));
|
|
9965
|
+
continue;
|
|
9966
|
+
}
|
|
9967
|
+
const ruleFns = p.ruleFns;
|
|
9968
|
+
opaque.push({ ns: p.ns, ruleNames: ruleFns instanceof Map ? [...ruleFns.keys()] : [] });
|
|
9969
|
+
}
|
|
9970
|
+
return { maps, opaque };
|
|
9971
|
+
}
|
|
9972
|
+
function recoverComposedRules(grammar) {
|
|
9973
|
+
const carried = composedPiecesOf(grammar);
|
|
9974
|
+
if (carried === void 0) return void 0;
|
|
9975
|
+
const { maps, opaque } = carriedRuleMapsDetailed(carried);
|
|
9976
|
+
const rules2 = /* @__PURE__ */ new Map();
|
|
9977
|
+
for (const map of maps) for (const [name, rule] of map) {
|
|
9978
|
+
if (rule._def.tag === "lazy") {
|
|
9979
|
+
try {
|
|
9980
|
+
rule._def.thunk();
|
|
9981
|
+
} catch {
|
|
9982
|
+
continue;
|
|
9983
|
+
}
|
|
9984
|
+
}
|
|
9985
|
+
rules2.set(name, rule);
|
|
9986
|
+
}
|
|
9987
|
+
return { rules: rules2, opaque };
|
|
8426
9988
|
}
|
|
8427
9989
|
function coverageRulesOf(carried) {
|
|
8428
9990
|
const winners = {};
|
|
@@ -8445,11 +10007,12 @@ function coverageRulesOf(carried) {
|
|
|
8445
10007
|
function composedCoverageRules(grammar) {
|
|
8446
10008
|
return grammar[COMPOSED_COVERAGE_RULES];
|
|
8447
10009
|
}
|
|
8448
|
-
function materializePiece(p, trivia2, captureTerminals = false) {
|
|
10010
|
+
function materializePiece(p, trivia2, captureTerminals = false, hostMode) {
|
|
8449
10011
|
if (!isIRPiece(p)) return p;
|
|
8450
10012
|
const pieces = compileLinkable(evalRuleMapIR(p.ir), p.ns, {
|
|
8451
10013
|
...trivia2 ? { trivia: trivia2 } : {},
|
|
8452
|
-
...captureTerminals ? { captureTerminals: true } : {}
|
|
10014
|
+
...captureTerminals ? { captureTerminals: true } : {},
|
|
10015
|
+
...hostMode ? { hostMode } : {}
|
|
8453
10016
|
});
|
|
8454
10017
|
if (!pieces) throw new Error(`compose: carried IR for ns "${p.ns}" could not be re-lowered`);
|
|
8455
10018
|
return pieces;
|
|
@@ -8462,7 +10025,7 @@ function nextComposeNs(used) {
|
|
|
8462
10025
|
used.add(ns);
|
|
8463
10026
|
return ns;
|
|
8464
10027
|
}
|
|
8465
|
-
function itemCarried(item, used, trivia2) {
|
|
10028
|
+
function itemCarried(item, used, trivia2, hostMode) {
|
|
8466
10029
|
const carried = item[COMPOSED_PIECES];
|
|
8467
10030
|
if (Array.isArray(carried)) {
|
|
8468
10031
|
const items = carried;
|
|
@@ -8488,7 +10051,7 @@ function itemCarried(item, used, trivia2) {
|
|
|
8488
10051
|
});
|
|
8489
10052
|
const scanSkip = entries.map(([, val]) => val._meta.grammarScanSkip).find(Boolean);
|
|
8490
10053
|
const ir = serializeRuleMap(entries, scanSkip);
|
|
8491
|
-
return ir ? [{ ns, ir }] : [linkable(map, ns, trivia2)];
|
|
10054
|
+
return ir ? [{ ns, ir }] : [linkable(map, ns, trivia2, hostMode)];
|
|
8492
10055
|
}
|
|
8493
10056
|
function composingTriviaOf(items) {
|
|
8494
10057
|
for (let i = items.length - 1; i >= 0; i--) {
|
|
@@ -8501,15 +10064,20 @@ function composingTriviaOf(items) {
|
|
|
8501
10064
|
}
|
|
8502
10065
|
return void 0;
|
|
8503
10066
|
}
|
|
8504
|
-
function compose(items) {
|
|
10067
|
+
function compose(items, opts) {
|
|
8505
10068
|
if (items.some((item) => item[LEAF_COMPOSED] === true)) {
|
|
8506
10069
|
throw new Error("compose: a composeLeaf() result is terminal and cannot be composed again");
|
|
8507
10070
|
}
|
|
8508
10071
|
const used = /* @__PURE__ */ new Set();
|
|
8509
10072
|
const trivia2 = composingTriviaOf(items);
|
|
8510
|
-
const carried = items.flatMap((item) => itemCarried(item, used, trivia2));
|
|
8511
|
-
|
|
8512
|
-
|
|
10073
|
+
const carried = items.flatMap((item) => itemCarried(item, used, trivia2, opts?.hostMode));
|
|
10074
|
+
const detailed = once(() => carriedRuleMapsDetailed(carried));
|
|
10075
|
+
runFusedGatingDiagnostic(
|
|
10076
|
+
() => detailed().maps,
|
|
10077
|
+
void 0,
|
|
10078
|
+
() => detailed().opaque
|
|
10079
|
+
);
|
|
10080
|
+
const pieces = carried.map((p) => materializePiece(p, trivia2, false, opts?.hostMode));
|
|
8513
10081
|
const map = fuseRules(pieces);
|
|
8514
10082
|
Object.defineProperty(map, COMPOSED_PIECES, { value: carried, enumerable: false });
|
|
8515
10083
|
if (trivia2) Object.defineProperty(map, COMPOSED_TRIVIA, { value: trivia2, enumerable: false });
|
|
@@ -8521,6 +10089,23 @@ function composeLeaf(_items) {
|
|
|
8521
10089
|
throw new Error("composeLeaf(): requires Parseman macro lowering; runtime composition is forbidden");
|
|
8522
10090
|
}
|
|
8523
10091
|
|
|
10092
|
+
// src/analysis/grammar.ts
|
|
10093
|
+
function analyzeGrammarGating(grammar, opts) {
|
|
10094
|
+
const recovered = recoverComposedRules(grammar);
|
|
10095
|
+
if (recovered === void 0) {
|
|
10096
|
+
return analyzeGatingRules(Object.entries(grammar), opts);
|
|
10097
|
+
}
|
|
10098
|
+
const { rules: winners, opaque } = recovered;
|
|
10099
|
+
const entries = [...winners];
|
|
10100
|
+
const report = analyzeGatingRules(entries, { ...opts, resolveRef: (name) => winners.get(name) });
|
|
10101
|
+
const fromOpaque = opaque.map((o) => ({
|
|
10102
|
+
rule: o.ruleNames.length > 0 ? o.ruleNames.join(", ") : `<artifact ${o.ns}>`,
|
|
10103
|
+
kind: "opaque-artifact",
|
|
10104
|
+
reason: `precompiled artifact "${o.ns}" carries compiled rule functions, not re-lowerable IR \u2014 its choices cannot be examined. Recompile the contributing grammar so it carries IR (the macro emits IR by default) to bring it back into analysis.`
|
|
10105
|
+
}));
|
|
10106
|
+
return fromOpaque.length === 0 ? report : { ...report, unanalysable: [...report.unanalysable, ...fromOpaque] };
|
|
10107
|
+
}
|
|
10108
|
+
|
|
8524
10109
|
// src/combinators/gate.ts
|
|
8525
10110
|
function gate(predicate) {
|
|
8526
10111
|
const meta = {
|
|
@@ -8653,7 +10238,8 @@ function editNode(node2, at, delta, parentBase, parentShift, stats) {
|
|
|
8653
10238
|
}
|
|
8654
10239
|
|
|
8655
10240
|
// src/functional/doc.ts
|
|
8656
|
-
function mkCtx(
|
|
10241
|
+
function mkCtx(rawState, build, tolerant) {
|
|
10242
|
+
const state = rawState ?? void 0;
|
|
8657
10243
|
return tolerant ? { trackLines: false, state, build, _tolerant: true, _rec: REC, _errors: [] } : { trackLines: false, state, build };
|
|
8658
10244
|
}
|
|
8659
10245
|
function asRuleFn(entry) {
|
|
@@ -9050,6 +10636,7 @@ function collectEmbeddedErrors(node2, base, out) {
|
|
|
9050
10636
|
function parseDoc(registry, rootRule, input, opts = {}) {
|
|
9051
10637
|
const entry = registry[rootRule];
|
|
9052
10638
|
if (!entry) throw new Error(`No rule '${rootRule}' in registry`);
|
|
10639
|
+
assertHostModeCompatible(fusedHostModeOf(registry), opts.build, fusedHostElidedOf(registry));
|
|
9053
10640
|
const ctx = mkCtx(opts.state, opts.build, !!opts.tolerant);
|
|
9054
10641
|
const r = asRuleFn(entry)(input, 0, ctx);
|
|
9055
10642
|
if (r.ok) {
|
|
@@ -9075,6 +10662,12 @@ function runOnce(entry, input, options, phase, profileState) {
|
|
|
9075
10662
|
const skipGlobalSinks = phase === "recognizer" || phase === "capture";
|
|
9076
10663
|
const grammarTrivia = typeof entry !== "function" ? entry._meta.grammarTrivia : void 0;
|
|
9077
10664
|
const grammarScanSkip = typeof entry !== "function" ? entry._meta.grammarScanSkip : void 0;
|
|
10665
|
+
const stamped = entry;
|
|
10666
|
+
assertHostModeCompatible(
|
|
10667
|
+
typeof entry === "function" ? stamped[FUSED_HOST_MODE] ?? "ast" : entry._meta.grammarHostMode ?? "ast",
|
|
10668
|
+
options.build,
|
|
10669
|
+
typeof entry === "function" && stamped[FUSED_HOST_ELIDED] === true
|
|
10670
|
+
);
|
|
9078
10671
|
const ctx = {
|
|
9079
10672
|
trackLines: false,
|
|
9080
10673
|
...skipGlobalSinks ? { _pmProfile: profile } : { _triviaLog: triviaLog, _errors: errors, ...profile === void 0 ? {} : { _pmProfile: profile } },
|