parseman 0.45.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/dist/analysis/choice-cost-gate.d.ts +158 -0
- package/dist/analysis/choice-cost-gate.d.ts.map +1 -0
- package/dist/analysis/choice-cost-render.d.ts +96 -0
- package/dist/analysis/choice-cost-render.d.ts.map +1 -0
- package/dist/analysis/choice-cost.d.ts +383 -0
- package/dist/analysis/choice-cost.d.ts.map +1 -0
- package/dist/analysis/commitment.d.ts +131 -0
- package/dist/analysis/commitment.d.ts.map +1 -0
- package/dist/analysis/corpus.d.ts +73 -0
- package/dist/analysis/corpus.d.ts.map +1 -0
- package/dist/analysis/diagnose-render.d.ts +65 -0
- package/dist/analysis/diagnose-render.d.ts.map +1 -0
- package/dist/analysis/diagnose.d.ts +15 -0
- package/dist/analysis/diagnose.d.ts.map +1 -1
- package/dist/analysis/diagnostics.cjs +13736 -0
- package/dist/analysis/diagnostics.cjs.map +6 -0
- package/dist/analysis/diagnostics.d.ts +27 -0
- package/dist/analysis/diagnostics.d.ts.map +1 -0
- package/dist/analysis/diagnostics.js +13713 -0
- package/dist/analysis/diagnostics.js.map +6 -0
- package/dist/analysis/duplication.d.ts.map +1 -1
- package/dist/analysis/fix-render.d.ts +43 -0
- package/dist/analysis/fix-render.d.ts.map +1 -0
- package/dist/analysis/fix.d.ts +172 -0
- package/dist/analysis/fix.d.ts.map +1 -0
- package/dist/analysis/format-number.d.ts +23 -0
- package/dist/analysis/format-number.d.ts.map +1 -0
- package/dist/analysis/gating.d.ts +47 -2
- package/dist/analysis/gating.d.ts.map +1 -1
- package/dist/analysis/rebuild.d.ts +61 -0
- package/dist/analysis/rebuild.d.ts.map +1 -0
- package/dist/analysis/terminal.d.ts +145 -0
- package/dist/analysis/terminal.d.ts.map +1 -0
- package/dist/cli/index.cjs +14837 -0
- package/dist/cli/index.cjs.map +6 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +14814 -0
- package/dist/cli/index.js.map +6 -0
- package/dist/combinators/adjacency.d.ts +100 -0
- package/dist/combinators/adjacency.d.ts.map +1 -0
- package/dist/combinators/choice.d.ts.map +1 -1
- package/dist/combinators/completions.d.ts.map +1 -1
- package/dist/combinators/dispatch.d.ts +8 -1
- package/dist/combinators/dispatch.d.ts.map +1 -1
- package/dist/combinators/expect.d.ts.map +1 -1
- package/dist/combinators/first-set.d.ts.map +1 -1
- package/dist/combinators/gate.d.ts.map +1 -1
- package/dist/combinators/grammar.d.ts +22 -0
- package/dist/combinators/grammar.d.ts.map +1 -1
- package/dist/combinators/literal.d.ts +8 -0
- package/dist/combinators/literal.d.ts.map +1 -1
- package/dist/combinators/map.d.ts +0 -1
- package/dist/combinators/map.d.ts.map +1 -1
- package/dist/combinators/node.d.ts +13 -3
- package/dist/combinators/node.d.ts.map +1 -1
- package/dist/combinators/parser.d.ts +1 -1
- package/dist/combinators/parser.d.ts.map +1 -1
- package/dist/combinators/ref.d.ts.map +1 -1
- package/dist/combinators/regex.d.ts +1 -16
- package/dist/combinators/regex.d.ts.map +1 -1
- package/dist/combinators/repeat.d.ts +30 -2
- package/dist/combinators/repeat.d.ts.map +1 -1
- package/dist/combinators/scanTo.d.ts +71 -1
- package/dist/combinators/scanTo.d.ts.map +1 -1
- package/dist/combinators/sequence.d.ts.map +1 -1
- package/dist/combinators/token.d.ts.map +1 -1
- package/dist/combinators/trivia-boundary.d.ts +26 -0
- package/dist/combinators/trivia-boundary.d.ts.map +1 -0
- package/dist/combinators/trivia-skip.d.ts +40 -1
- package/dist/combinators/trivia-skip.d.ts.map +1 -1
- package/dist/combinators/withCtx.d.ts.map +1 -1
- package/dist/compiler/build-arity.d.ts.map +1 -1
- package/dist/compiler/compile-linkable-table.d.ts +104 -0
- package/dist/compiler/compile-linkable-table.d.ts.map +1 -0
- package/dist/compiler/degradation.d.ts +0 -2
- package/dist/compiler/degradation.d.ts.map +1 -1
- package/dist/compiler/fields.d.ts +13 -0
- package/dist/compiler/fields.d.ts.map +1 -1
- package/dist/compiler/grammar-coverage-ids.d.ts +5 -5
- package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
- package/dist/compiler/ir-serialize.d.ts.map +1 -1
- package/dist/compiler/linker.d.ts +79 -47
- package/dist/compiler/linker.d.ts.map +1 -1
- package/dist/compiler/token-alphabet.d.ts +81 -0
- package/dist/compiler/token-alphabet.d.ts.map +1 -0
- package/dist/compiler/token-dispatch.d.ts +95 -0
- package/dist/compiler/token-dispatch.d.ts.map +1 -0
- package/dist/compiler/token-scanner.d.ts +53 -0
- package/dist/compiler/token-scanner.d.ts.map +1 -0
- package/dist/compiler/value-usage.d.ts.map +1 -1
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.d.ts.map +1 -1
- package/dist/cst/capture-buffer.d.ts +35 -0
- package/dist/cst/capture-buffer.d.ts.map +1 -1
- package/dist/cst/reflection-attach.d.ts +8 -0
- package/dist/cst/reflection-attach.d.ts.map +1 -0
- package/dist/cst/reflection-symbols.d.ts +10 -0
- package/dist/cst/reflection-symbols.d.ts.map +1 -0
- package/dist/cst/reflection.d.ts +2 -4
- package/dist/cst/reflection.d.ts.map +1 -1
- package/dist/cst/root-trivia-scope.d.ts +26 -0
- package/dist/cst/root-trivia-scope.d.ts.map +1 -0
- package/dist/cst/trivia-charscan.d.ts +18 -0
- package/dist/cst/trivia-charscan.d.ts.map +1 -0
- package/dist/cst/trivia-kinds.d.ts +19 -0
- package/dist/cst/trivia-kinds.d.ts.map +1 -1
- package/dist/functional/doc.d.ts.map +1 -1
- package/dist/functional/run-tabled.d.ts +12 -0
- package/dist/functional/run-tabled.d.ts.map +1 -0
- package/dist/functional/run.d.ts +25 -42
- package/dist/functional/run.d.ts.map +1 -1
- package/dist/grammar-metadata.d.ts +3 -0
- package/dist/grammar-metadata.d.ts.map +1 -0
- package/dist/index.cjs +10499 -7478
- package/dist/index.cjs.map +3 -4
- package/dist/index.d.ts +24 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10499 -7478
- package/dist/index.js.map +3 -4
- package/dist/language-service/index.cjs +311 -121
- package/dist/language-service/index.cjs.map +3 -4
- package/dist/language-service/index.js +311 -121
- package/dist/language-service/index.js.map +3 -4
- package/dist/oracle/index.cjs.map +0 -1
- package/dist/oracle/index.js.map +0 -1
- package/dist/parse-context.d.ts +49 -0
- package/dist/parse-context.d.ts.map +1 -0
- package/dist/plugin/evaluator.d.ts +41 -2
- package/dist/plugin/evaluator.d.ts.map +1 -1
- package/dist/plugin/free-identifiers.d.ts +23 -0
- package/dist/plugin/free-identifiers.d.ts.map +1 -0
- package/dist/plugin/index.cjs +11381 -9442
- package/dist/plugin/index.cjs.map +3 -4
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +11381 -9442
- package/dist/plugin/index.js.map +3 -4
- package/dist/plugin/reducer-resolver.d.ts +17 -1
- package/dist/plugin/reducer-resolver.d.ts.map +1 -1
- package/dist/regex/classes.d.ts +28 -0
- package/dist/regex/classes.d.ts.map +1 -1
- package/dist/regex/first-set.d.ts +14 -0
- package/dist/regex/first-set.d.ts.map +1 -1
- package/dist/run/index.cjs +136 -51
- package/dist/run/index.cjs.map +2 -3
- package/dist/run/index.js +136 -51
- package/dist/run/index.js.map +2 -3
- package/dist/spec/index.cjs +2898 -1622
- package/dist/spec/index.cjs.map +3 -4
- package/dist/spec/index.js +2898 -1622
- package/dist/spec/index.js.map +3 -4
- package/dist/spec/model.d.ts +8 -0
- package/dist/spec/model.d.ts.map +1 -1
- package/dist/table/assemble.d.ts +307 -0
- package/dist/table/assemble.d.ts.map +1 -0
- package/dist/table/cell.d.ts +42 -0
- package/dist/table/cell.d.ts.map +1 -0
- package/dist/table/child-slots.d.ts +30 -0
- package/dist/table/child-slots.d.ts.map +1 -0
- package/dist/table/compile-rule-map.d.ts +136 -0
- package/dist/table/compile-rule-map.d.ts.map +1 -0
- package/dist/table/compile.d.ts +56 -0
- package/dist/table/compile.d.ts.map +1 -0
- package/dist/table/duplication-hook.d.ts +32 -0
- package/dist/table/duplication-hook.d.ts.map +1 -0
- package/dist/table/emit-assembly.d.ts +139 -0
- package/dist/table/emit-assembly.d.ts.map +1 -0
- package/dist/table/emit.d.ts +113 -0
- package/dist/table/emit.d.ts.map +1 -0
- package/dist/table/encode-baseline.d.ts +16 -0
- package/dist/table/encode-baseline.d.ts.map +1 -0
- package/dist/table/encode.d.ts +71 -0
- package/dist/table/encode.d.ts.map +1 -0
- package/dist/table/exec-baseline.d.ts +9 -0
- package/dist/table/exec-baseline.d.ts.map +1 -0
- package/dist/table/exec.d.ts +72 -0
- package/dist/table/exec.d.ts.map +1 -0
- package/dist/table/fold.d.ts +25 -0
- package/dist/table/fold.d.ts.map +1 -0
- package/dist/table/index.cjs +11375 -0
- package/dist/table/index.cjs.map +6 -0
- package/dist/table/index.d.ts +100 -0
- package/dist/table/index.d.ts.map +1 -0
- package/dist/table/index.js +11352 -0
- package/dist/table/index.js.map +6 -0
- package/dist/table/inspect.d.ts +17 -0
- package/dist/table/inspect.d.ts.map +1 -0
- package/dist/table/ops.d.ts +446 -0
- package/dist/table/ops.d.ts.map +1 -0
- package/dist/table/program.d.ts +493 -0
- package/dist/table/program.d.ts.map +1 -0
- package/dist/table/run-support.d.ts +38 -0
- package/dist/table/run-support.d.ts.map +1 -0
- package/dist/{compiler/scannable-run.d.ts → table/scan-shapes.d.ts} +14 -13
- package/dist/table/scan-shapes.d.ts.map +1 -0
- package/dist/table/site-labels.d.ts +58 -0
- package/dist/table/site-labels.d.ts.map +1 -0
- package/dist/table/stamp.d.ts +70 -0
- package/dist/table/stamp.d.ts.map +1 -0
- package/dist/types.d.ts +80 -24
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +41 -3
- package/src/analysis/choice-cost-gate.ts +359 -0
- package/src/analysis/choice-cost-render.ts +293 -0
- package/src/analysis/choice-cost.ts +1008 -0
- package/src/analysis/commitment.ts +557 -0
- package/src/analysis/corpus.ts +130 -0
- package/src/analysis/diagnose-render.ts +625 -0
- package/src/analysis/diagnose.ts +317 -0
- package/src/analysis/diagnostics.ts +33 -0
- package/src/analysis/duplication.ts +1955 -0
- package/src/analysis/fix-render.ts +217 -0
- package/src/analysis/fix.ts +669 -0
- package/src/analysis/format-number.ts +31 -0
- package/src/analysis/gating.ts +792 -0
- package/src/analysis/grammar.ts +72 -0
- package/src/analysis/rebuild.ts +287 -0
- package/src/analysis/terminal.ts +336 -0
- package/src/cli/index.ts +413 -0
- package/src/combinators/adjacency.ts +171 -0
- package/src/combinators/attempt.ts +41 -0
- package/src/combinators/case-fold.ts +90 -0
- package/src/combinators/choice.ts +466 -0
- package/src/combinators/completions.ts +64 -0
- package/src/combinators/dispatch.ts +358 -0
- package/src/combinators/expect.ts +159 -0
- package/src/combinators/first-set.ts +322 -0
- package/src/combinators/gate.ts +50 -0
- package/src/combinators/grammar.ts +272 -0
- package/src/combinators/keywords.ts +145 -0
- package/src/combinators/literal.ts +102 -0
- package/src/combinators/map.ts +106 -0
- package/src/combinators/node.ts +345 -0
- package/src/combinators/not.ts +55 -0
- package/src/combinators/parser.ts +266 -0
- package/src/combinators/peek.ts +63 -0
- package/src/combinators/probe.ts +25 -0
- package/src/combinators/ref.ts +109 -0
- package/src/combinators/regex.ts +170 -0
- package/src/combinators/repeat.ts +561 -0
- package/src/combinators/scanTo.ts +393 -0
- package/src/combinators/sequence.ts +221 -0
- package/src/combinators/token.ts +120 -0
- package/src/combinators/trivia-boundary.ts +48 -0
- package/src/combinators/trivia-skip.ts +821 -0
- package/src/combinators/withCtx.ts +45 -0
- package/src/compiler/build-arity.ts +330 -0
- package/src/compiler/compile-linkable-table.ts +209 -0
- package/src/compiler/degradation.ts +266 -0
- package/src/compiler/fields.ts +191 -0
- package/src/compiler/grammar-coverage-ids.ts +129 -0
- package/src/compiler/ir-serialize.ts +615 -0
- package/src/compiler/linker.ts +954 -0
- package/src/compiler/token-alphabet.ts +209 -0
- package/src/compiler/token-dispatch.ts +353 -0
- package/src/compiler/token-scanner.ts +318 -0
- package/src/compiler/value-usage.ts +147 -0
- package/src/coverage.ts +383 -0
- package/src/cst/capture-buffer.ts +297 -0
- package/src/cst/host-mode.ts +69 -0
- package/src/cst/reflection-attach.ts +11 -0
- package/src/cst/reflection-symbols.ts +9 -0
- package/src/cst/reflection.ts +149 -0
- package/src/cst/relative-spans.ts +207 -0
- package/src/cst/root-trivia-scope.ts +30 -0
- package/src/cst/trivia-charscan.ts +312 -0
- package/src/cst/trivia-entries.ts +285 -0
- package/src/cst/trivia-index.ts +131 -0
- package/src/cst/trivia-kinds.ts +358 -0
- package/src/cst/types.ts +76 -0
- package/src/cst/walk.ts +101 -0
- package/src/functional/doc.ts +959 -0
- package/src/functional/run-tabled.ts +153 -0
- package/src/functional/run.ts +558 -0
- package/src/grammar-metadata.ts +2 -0
- package/src/index.ts +189 -0
- package/src/language-service/index.ts +221 -0
- package/src/line-index.ts +131 -0
- package/src/oracle/digest.ts +620 -0
- package/src/oracle/index.ts +55 -0
- package/src/parse-context.ts +91 -0
- package/src/plugin/direct-builder-static.ts +102 -0
- package/src/plugin/evaluator.ts +1505 -0
- package/src/plugin/free-identifiers.ts +338 -0
- package/src/plugin/index.ts +2281 -0
- package/src/plugin/reducer-resolver.ts +606 -0
- package/src/recovery/scan.ts +155 -0
- package/src/regex/classes.ts +183 -0
- package/src/regex/first-set.ts +316 -0
- package/src/run/index.ts +31 -0
- package/src/spec/ebnf.ts +128 -0
- package/src/spec/index.ts +54 -0
- package/src/spec/model.ts +636 -0
- package/src/spec/railroad-lib.ts +559 -0
- package/src/spec/railroad.ts +256 -0
- package/src/table/assemble.ts +2994 -0
- package/src/table/cell.ts +42 -0
- package/src/table/child-slots.ts +113 -0
- package/src/table/compile-rule-map.ts +336 -0
- package/src/table/compile.ts +244 -0
- package/src/table/duplication-hook.ts +89 -0
- package/src/table/emit-assembly.ts +1766 -0
- package/src/table/emit.ts +418 -0
- package/src/table/encode-baseline.ts +340 -0
- package/src/table/encode.ts +1569 -0
- package/src/table/exec-baseline.ts +537 -0
- package/src/table/exec.ts +1672 -0
- package/src/table/fold.ts +38 -0
- package/src/table/index.ts +108 -0
- package/src/table/inspect.ts +52 -0
- package/src/table/ops.ts +459 -0
- package/src/table/program.ts +855 -0
- package/src/table/run-support.ts +66 -0
- package/src/table/scan-shapes.ts +1578 -0
- package/src/table/site-labels.ts +228 -0
- package/src/table/stamp.ts +229 -0
- package/src/types.ts +563 -0
- package/src/version.ts +16 -0
- package/dist/compiler/codegen.d.ts +0 -334
- package/dist/compiler/codegen.d.ts.map +0 -1
- package/dist/compiler/inline-build.d.ts +0 -11
- package/dist/compiler/inline-build.d.ts.map +0 -1
- package/dist/compiler/inline-callback.d.ts +0 -21
- package/dist/compiler/inline-callback.d.ts.map +0 -1
- package/dist/compiler/line-index.d.ts +0 -2
- package/dist/compiler/line-index.d.ts.map +0 -1
- package/dist/compiler/scannable-run.d.ts.map +0 -1
- package/dist/compiler/scannable-terminal.d.ts +0 -23
- package/dist/compiler/scannable-terminal.d.ts.map +0 -1
- package/dist/compiler/trivia-fast-path.d.ts +0 -38
- package/dist/compiler/trivia-fast-path.d.ts.map +0 -1
|
@@ -0,0 +1,1578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHAPE RECOGNITION AND SOURCE EMISSION FOR SCANNABLE REGEXES.
|
|
3
|
+
*
|
|
4
|
+
* Restored from `archive/codegen-fastpaths:src/compiler/scannable-run.ts` and
|
|
5
|
+
* re-aimed at `emit-assembly.ts`. It is a SOURCE emitter, not a runtime
|
|
6
|
+
* interpreter: `emitShapeMatch` constant-folds every range and code point into
|
|
7
|
+
* the text of a per-site `_pf<N>` body via `classCond`/`litCond`/`foldEq`. Built
|
|
8
|
+
* instead as static runtime bodies the nine shapes would reproduce exactly the
|
|
9
|
+
* `inRanges` per-character loop this file exists to remove.
|
|
10
|
+
*
|
|
11
|
+
* The two trivia-loop emitters (`scanBranch`, `scanBranchLabeled`) are NOT
|
|
12
|
+
* restored: fusing the trivia path into piece bodies is a separate unit, and
|
|
13
|
+
* carrying dead emitters here would make this file look like it already did it.
|
|
14
|
+
*
|
|
15
|
+
* Structural recognition of "scannable" parser arms — regex shapes that lower to
|
|
16
|
+
* a tight character-scan loop instead of `regex.exec` / combinator dispatch. Each
|
|
17
|
+
* shape is derived from the regex STRUCTURE, not from any hardcoded knowledge
|
|
18
|
+
* that a given regex "is whitespace" or "is a comment":
|
|
19
|
+
*
|
|
20
|
+
* [X]+ / [X]* → run while the char ∈ X (chars)
|
|
21
|
+
* <lit>[^X]* → consume <lit>, run until char ∈ X (until)
|
|
22
|
+
* <open>(?:…)*<close> → consume <open>, run to <close> literal (delimited)
|
|
23
|
+
* x?<lit><[X]*>… → a general linear chain of lit/run (seq)
|
|
24
|
+
*
|
|
25
|
+
* `seq` is the CATEGORY generalization: any chain of literal segments (required
|
|
26
|
+
* or optional `x?`) and char-class runs (positive/negated, `?`/`*`/`+`). It
|
|
27
|
+
* subsumes CSS/Less `-?ident`, `--custom`, `@-?keyword`, `[^…]+`, `::?`, etc.
|
|
28
|
+
* without hardcoding a single byte — lowered only when a greedy one-pass scan
|
|
29
|
+
* provably equals the engine's backtracking (see `seqIsUnambiguous`).
|
|
30
|
+
*
|
|
31
|
+
* A `oneOrMore(choice(a, b, …))` where every arm is one of these compiles to a
|
|
32
|
+
* single char-dispatch loop with one branch per arm — any count/order, because
|
|
33
|
+
* the shapes dispatch on their first 1–2 chars and are checked in turn. Trivia
|
|
34
|
+
* (whitespace + comments) is just the value-discarded instance of this; nothing
|
|
35
|
+
* here is trivia-specific.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One segment of a `seq` shape. A `seq` is the general category "a fixed linear
|
|
40
|
+
* chain of literals and char-class runs" — no alternation, no groups, no
|
|
41
|
+
* backtracking. It subsumes every CSS/Less token that is "(optional prefix)?
|
|
42
|
+
* literal* char-run* …" without hardcoding any particular byte:
|
|
43
|
+
*
|
|
44
|
+
* lit — a run of fixed code points, optionally present (`x?`) or required.
|
|
45
|
+
* run — a char-class run: positive/negated ranges, min 0/1, bounded to one
|
|
46
|
+
* char (`[x]`, `[x]?`) or unbounded (`[x]*`, `[x]+`).
|
|
47
|
+
* group — a non-capturing sub-pattern `(?:…)` (§8f), quantified the same way
|
|
48
|
+
* as `run` (`min`/`unbounded`). `inner` is any already-recognized
|
|
49
|
+
* `ScanShape` for the group's body (so a group can itself contain
|
|
50
|
+
* `seq`/`chars`/`alt`/… — including a nested alternation via §8e).
|
|
51
|
+
* Only used when `groupInnerSafe(inner)` holds (see that function).
|
|
52
|
+
*
|
|
53
|
+
* We only lower a `seq` when greedy left-to-right scanning provably equals the
|
|
54
|
+
* regex engine's backtracking match (see `seqIsUnambiguous`).
|
|
55
|
+
*/
|
|
56
|
+
export type SeqPart =
|
|
57
|
+
| { part: 'lit'; cps: number[]; optional: boolean }
|
|
58
|
+
// A char-class run with a general counted quantifier: `min`..`max` occurrences.
|
|
59
|
+
// `max === Infinity` is unbounded (`+`/`*`/`{n,}`). The four legacy shapes map
|
|
60
|
+
// as `+`→{1,∞} `*`→{0,∞} `?`→{0,1} bare→{1,1}; `{n,m}` (§8c) fills the rest.
|
|
61
|
+
// Greedy scanning has "wiggle" a backtracker could exploit iff `max > min`.
|
|
62
|
+
| { part: 'run'; ranges: Array<[number, number]>; negated: boolean; min: number; max: number }
|
|
63
|
+
| { part: 'group'; inner: ScanShape; min: 0 | 1; unbounded: boolean }
|
|
64
|
+
|
|
65
|
+
export type ScanShape =
|
|
66
|
+
| { kind: 'chars'; ranges: Array<[number, number]>; minOne: boolean }
|
|
67
|
+
| { kind: 'ident'; head: Array<[number, number]>; tail: Array<[number, number]> }
|
|
68
|
+
| { kind: 'until'; open: number[]; stop: Array<[number, number]> }
|
|
69
|
+
| { kind: 'delimited'; open: number[]; close: number[] }
|
|
70
|
+
// <q>(?:[^<q>\\]|\\.)*<q> — a quote-delimited string with backslash escapes.
|
|
71
|
+
// `excluded` = the body's negated class (always contains the quote + backslash,
|
|
72
|
+
// maybe a newline); `escLineTerm` = whether `\\` may be followed by a line
|
|
73
|
+
// terminator (`\\[\s\S]` → true, `\\.` → false, since `.` excludes them).
|
|
74
|
+
| { kind: 'string'; quote: number; excluded: Array<[number, number]>; escLineTerm: boolean }
|
|
75
|
+
// A linear chain of literal/char-run segments (optional prefixes, literal
|
|
76
|
+
// openers, negated runs, …) — the general CSS/Less token category.
|
|
77
|
+
| { kind: 'seq'; parts: SeqPart[] }
|
|
78
|
+
// A pure literal matched case-insensitively (`/i`): the general "ASCII
|
|
79
|
+
// case-fold literal" category (CSS `url(`, `@media` keywords under `i`, …).
|
|
80
|
+
| { kind: 'litFold'; open: number[] }
|
|
81
|
+
// `<inner>(?!class)` / `<inner>(?=class)` — a trailing lookahead boundary
|
|
82
|
+
// check on an already-lowered shape. Zero-width: `end` is `inner`'s end, never
|
|
83
|
+
// extended. `classNegated` is the bracket's OWN `[^…]` negation (e.g.
|
|
84
|
+
// `(?![0-9a-fA-F])` vs `(?=[^0-9])`), independent of `negative` (`!` vs `=`).
|
|
85
|
+
| { kind: 'lookahead'; inner: ScanShape; ranges: Array<[number, number]>; classNegated: boolean; negative: boolean }
|
|
86
|
+
// `A|B|C` — top-level alternation (§8e), split outside any `[]`/`()`, each arm
|
|
87
|
+
// independently lowered. `disjoint` is true iff every pair of arms has
|
|
88
|
+
// provably non-overlapping first-char sets (`firsts[i]` holds that arm's set,
|
|
89
|
+
// or `null` if it could start with any char — an unbounded-optional `chars`
|
|
90
|
+
// arm, or a non-disjoint mix). When `disjoint`, codegen dispatches straight to
|
|
91
|
+
// the one matching arm; otherwise it tries arms in order and takes the first
|
|
92
|
+
// that succeeds — the same ordered-choice semantics as regex `|` itself (see
|
|
93
|
+
// the ordered-vs-longest verification in PERF_IDEAS §8e).
|
|
94
|
+
| { kind: 'alt'; arms: ScanShape[]; disjoint: boolean; firsts: Array<CharSet | null> }
|
|
95
|
+
|
|
96
|
+
/** Backslash (`\`) code point — the escape lead char in string shapes. */
|
|
97
|
+
export const BACKSLASH = 92
|
|
98
|
+
|
|
99
|
+
const META = new Set('()[]{}*+?|^$.'.split(''))
|
|
100
|
+
|
|
101
|
+
// Regex char-class primitives are shared with the interpreter's first-set
|
|
102
|
+
// analyzer and `regex()`'s scanner — see `../regex/classes.ts`. `SPACE_RANGES`
|
|
103
|
+
// and `parseClassRanges` are re-exported here because codegen's boundary-class
|
|
104
|
+
// lowering imports them from this module.
|
|
105
|
+
import { CLASS_ESCAPES, SPACE_RANGES, literalCodePoints, parseClassOperand, shorthandRanges, readUnicodeEscape, parseClassRanges } from '../regex/classes.ts'
|
|
106
|
+
export { SPACE_RANGES, parseClassRanges }
|
|
107
|
+
|
|
108
|
+
/** A single class token (`[...]` or `\d`/`\w`) to ranges, or null. Rejects negation. */
|
|
109
|
+
function classToRanges(cls: string): Array<[number, number]> | null {
|
|
110
|
+
if (cls === '\\d') return shorthandRanges('d')
|
|
111
|
+
if (cls === '\\w') return shorthandRanges('w')
|
|
112
|
+
if (cls === '\\s') return shorthandRanges('s')
|
|
113
|
+
const body = cls.slice(1, -1)
|
|
114
|
+
if (body.startsWith('^')) return null
|
|
115
|
+
return parseClassRanges(body)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** ASCII case-insensitive letter check for `i`-flag literal lowering. */
|
|
119
|
+
// ASCII case-insensitive char compare. For a letter, upper/lower differ only in
|
|
120
|
+
// bit 0x20, and `(c | 32) === <lowercase>` is satisfied by EXACTLY that letter's
|
|
121
|
+
// two cases (nothing else) — one OR + one compare, ~1.75× faster than the
|
|
122
|
+
// two-compare `(c===U || c===L)` form (measured). `(… | 32)` is parenthesized
|
|
123
|
+
// because `===` binds tighter than `|`. Non-letters keep an exact compare.
|
|
124
|
+
// Exported so the case-insensitive `literal()` emitter (codegen) shares the exact
|
|
125
|
+
// same ASCII fold as `/i`-flag regex lowering — no `Intl.Collator` on any path.
|
|
126
|
+
export const foldEq = (cVar: string, cp: number): string => {
|
|
127
|
+
if (cp >= 65 && cp <= 90) return `(${cVar} | 32) === ${cp + 32}`
|
|
128
|
+
if (cp >= 97 && cp <= 122) return `(${cVar} | 32) === ${cp}`
|
|
129
|
+
return `${cVar} === ${cp}`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Recognize a quote-delimited string with backslash escapes:
|
|
134
|
+
* <q>(?:[^…]|\\.)*<q> or <q>(?:\\.|[^…])*<q> (`?:` optional)
|
|
135
|
+
* where the negated body class contains the quote and the backslash, and the
|
|
136
|
+
* escape body is `.` (no line terminators) or `[\s\S]` (any char). Returns null
|
|
137
|
+
* for anything else so the caller falls back to `regex.exec`.
|
|
138
|
+
*/
|
|
139
|
+
function parseStringShape(source: string): ScanShape | null {
|
|
140
|
+
const q = source[0]
|
|
141
|
+
if (q === undefined || q === '\\' || META.has(q)) return null
|
|
142
|
+
if (source.length < 3 || source[source.length - 1] !== q) return null
|
|
143
|
+
const inner = source.slice(1, -1)
|
|
144
|
+
const CLS = String.raw`\[\^((?:\\.|[^\]])+)\]`
|
|
145
|
+
const ESC = String.raw`\\\\(\.|\[\\s\\S\]|\[\\S\\s\])`
|
|
146
|
+
// Two arm orders; `?:` optional. Capture groups differ, so read both out.
|
|
147
|
+
let body: string | undefined
|
|
148
|
+
let esc: string | undefined
|
|
149
|
+
let m = new RegExp(`^\\((?:\\?:)?${CLS}\\|${ESC}\\)\\*$`).exec(inner)
|
|
150
|
+
if (m) { body = m[1]; esc = m[2] }
|
|
151
|
+
else {
|
|
152
|
+
m = new RegExp(`^\\((?:\\?:)?${ESC}\\|${CLS}\\)\\*$`).exec(inner)
|
|
153
|
+
if (m) { esc = m[1]; body = m[2] }
|
|
154
|
+
}
|
|
155
|
+
if (body === undefined || esc === undefined) return null
|
|
156
|
+
const excluded = parseClassRanges(body)
|
|
157
|
+
if (!excluded) return null
|
|
158
|
+
const qcp = q.codePointAt(0)!
|
|
159
|
+
const inSet = (cp: number) => excluded.some(([lo, hi]) => cp >= lo && cp <= hi)
|
|
160
|
+
if (!inSet(qcp) || !inSet(BACKSLASH)) return null
|
|
161
|
+
return { kind: 'string', quote: qcp, excluded, escLineTerm: esc !== '.' }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Parse a regex source into a linear chain of literal / char-run segments, or
|
|
166
|
+
* null if it uses any construct outside that category (alternation, groups,
|
|
167
|
+
* `.`, `{n,m}`, lookaround, unknown escapes). This is the STRUCTURAL recognizer
|
|
168
|
+
* for the `seq` shape — it encodes categories (optional prefix, literal opener,
|
|
169
|
+
* negated run, …), never any specific byte value.
|
|
170
|
+
*/
|
|
171
|
+
function parseSeqParts(source: string): SeqPart[] | null {
|
|
172
|
+
const parts: SeqPart[] = []
|
|
173
|
+
let lit: number[] = []
|
|
174
|
+
const flush = () => {
|
|
175
|
+
if (lit.length) { parts.push({ part: 'lit', cps: lit, optional: false }); lit = [] }
|
|
176
|
+
}
|
|
177
|
+
// Parse a `{n}` / `{n,}` / `{n,m}` counted quantifier at `source[at]` (`'{'`).
|
|
178
|
+
// Returns the bounds and the token length, or null when it isn't a well-formed
|
|
179
|
+
// brace quantifier (then the `{` is left for the caller's metachar decline).
|
|
180
|
+
const braceQuant = (at: number): { min: number; max: number; len: number } | null => {
|
|
181
|
+
const close = source.indexOf('}', at)
|
|
182
|
+
if (close === -1) return null
|
|
183
|
+
const body = source.slice(at + 1, close)
|
|
184
|
+
const m = /^(\d+)(,(\d*)?)?$/.exec(body)
|
|
185
|
+
if (!m) return null
|
|
186
|
+
const min = Number(m[1])
|
|
187
|
+
const max = m[2] === undefined ? min : m[3] ? Number(m[3]!) : Infinity
|
|
188
|
+
// Decline degenerate/empty-capable or invalid bounds — RegExp handles them.
|
|
189
|
+
if (max < 1 || min > max) return null
|
|
190
|
+
return { min, max, len: close - at + 1 }
|
|
191
|
+
}
|
|
192
|
+
const runFrom = (ranges: Array<[number, number]>, negated: boolean, at: number) => {
|
|
193
|
+
const q = source[at]
|
|
194
|
+
if (q === '+') { parts.push({ part: 'run', ranges, negated, min: 1, max: Infinity }); return 1 }
|
|
195
|
+
if (q === '*') { parts.push({ part: 'run', ranges, negated, min: 0, max: Infinity }); return 1 }
|
|
196
|
+
if (q === '?') { parts.push({ part: 'run', ranges, negated, min: 0, max: 1 }); return 1 }
|
|
197
|
+
if (q === '{') {
|
|
198
|
+
const b = braceQuant(at)
|
|
199
|
+
if (b) { parts.push({ part: 'run', ranges, negated, min: b.min, max: b.max }); return b.len }
|
|
200
|
+
}
|
|
201
|
+
parts.push({ part: 'run', ranges, negated, min: 1, max: 1 })
|
|
202
|
+
return 0
|
|
203
|
+
}
|
|
204
|
+
let i = 0
|
|
205
|
+
while (i < source.length) {
|
|
206
|
+
const ch = source[i]!
|
|
207
|
+
if (ch === '[') {
|
|
208
|
+
// Read a char class, honoring `\]`.
|
|
209
|
+
let k = i + 1
|
|
210
|
+
const neg = source[k] === '^'
|
|
211
|
+
if (neg) k++
|
|
212
|
+
let body = ''
|
|
213
|
+
while (k < source.length && source[k] !== ']') {
|
|
214
|
+
if (source[k] === '\\') { body += source[k]! + (source[k + 1] ?? ''); k += 2 }
|
|
215
|
+
else { body += source[k]!; k++ }
|
|
216
|
+
}
|
|
217
|
+
if (source[k] !== ']') return null
|
|
218
|
+
const ranges = parseClassRanges(body)
|
|
219
|
+
if (!ranges) return null
|
|
220
|
+
flush()
|
|
221
|
+
i = k + 1
|
|
222
|
+
i += runFrom(ranges, neg, i)
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
if (ch === '\\') {
|
|
226
|
+
const e = source[i + 1]
|
|
227
|
+
if (e === 'd' || e === 'w' || e === 's') {
|
|
228
|
+
flush()
|
|
229
|
+
i += 2
|
|
230
|
+
i += runFrom(shorthandRanges(e), false, i)
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
// Escaped literal code point (incl. `\uXXXX`, `\t`, `\(`, …).
|
|
234
|
+
const uni = readUnicodeEscape(source, i)
|
|
235
|
+
let cp: number
|
|
236
|
+
if (uni) { cp = uni.cp; i = uni.next }
|
|
237
|
+
else if (e === undefined) return null
|
|
238
|
+
else if (e in CLASS_ESCAPES) { cp = CLASS_ESCAPES[e]!; i += 2 }
|
|
239
|
+
else if ((e >= 'a' && e <= 'z') || (e >= 'A' && e <= 'Z')) return null
|
|
240
|
+
else { cp = e.codePointAt(0)!; i += 2 }
|
|
241
|
+
if (source[i] === '?') { flush(); parts.push({ part: 'lit', cps: [cp], optional: true }); i++ }
|
|
242
|
+
else lit.push(cp)
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
if (ch === '(') {
|
|
246
|
+
// Only a non-capturing group `(?:…)` is modeled (§8f) — a bare capturing
|
|
247
|
+
// group, or a lookaround appearing mid-pattern (a TRAILING one is peeled
|
|
248
|
+
// off separately by `stripTrailingLookahead` before we ever get here), is
|
|
249
|
+
// declined outright rather than risk misreading it.
|
|
250
|
+
if (source[i + 1] !== '?' || source[i + 2] !== ':') return null
|
|
251
|
+
let k = i + 3
|
|
252
|
+
let depth = 1
|
|
253
|
+
while (k < source.length && depth > 0) {
|
|
254
|
+
const c2 = source[k]
|
|
255
|
+
if (c2 === '\\') { k += 2; continue }
|
|
256
|
+
if (c2 === '[') {
|
|
257
|
+
let j = k + 1
|
|
258
|
+
if (source[j] === '^') j++
|
|
259
|
+
while (j < source.length && source[j] !== ']') {
|
|
260
|
+
if (source[j] === '\\') j += 2
|
|
261
|
+
else j++
|
|
262
|
+
}
|
|
263
|
+
if (source[j] !== ']') return null
|
|
264
|
+
k = j + 1
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
if (c2 === '(') depth++
|
|
268
|
+
else if (c2 === ')') depth--
|
|
269
|
+
k++
|
|
270
|
+
}
|
|
271
|
+
if (depth !== 0) return null
|
|
272
|
+
const body = source.slice(i + 3, k - 1)
|
|
273
|
+
const inner = parseScanShape(body)
|
|
274
|
+
if (!inner || !groupInnerSafe(inner)) return null
|
|
275
|
+
const q = source[k]
|
|
276
|
+
// `{n,m}` on a group is a §8c/§8f combination not modeled — decline.
|
|
277
|
+
if (q === '{') return null
|
|
278
|
+
flush()
|
|
279
|
+
if (q === '+') {
|
|
280
|
+
if (shapeCanBeEmpty(inner)) return null // would loop forever — decline
|
|
281
|
+
parts.push({ part: 'group', inner, min: 1, unbounded: true })
|
|
282
|
+
i = k + 1
|
|
283
|
+
} else if (q === '*') {
|
|
284
|
+
if (shapeCanBeEmpty(inner)) return null
|
|
285
|
+
parts.push({ part: 'group', inner, min: 0, unbounded: true })
|
|
286
|
+
i = k + 1
|
|
287
|
+
} else if (q === '?') {
|
|
288
|
+
parts.push({ part: 'group', inner, min: 0, unbounded: false })
|
|
289
|
+
i = k + 1
|
|
290
|
+
} else {
|
|
291
|
+
parts.push({ part: 'group', inner, min: 1, unbounded: false })
|
|
292
|
+
i = k
|
|
293
|
+
}
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
// A bare metachar (`|`, `.`, `{`, stray `*`/`+`/`?`, …) is outside the
|
|
297
|
+
// linear-chain category — bail to the RegExp fallback.
|
|
298
|
+
if (META.has(ch)) return null
|
|
299
|
+
const cp = ch.codePointAt(0)!
|
|
300
|
+
i += ch.length
|
|
301
|
+
if (source[i] === '?') { flush(); parts.push({ part: 'lit', cps: [cp], optional: true }); i++ }
|
|
302
|
+
else lit.push(cp)
|
|
303
|
+
}
|
|
304
|
+
flush()
|
|
305
|
+
return parts.length ? parts : null
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Only lower a `seq` when greedy left-to-right scanning matches EXACTLY what the
|
|
310
|
+
* backtracking regex engine would. The dangerous cases are (a) an optional part
|
|
311
|
+
* that could also be consumed by what follows, and (b) an unbounded/repeated
|
|
312
|
+
* part whose accepted class overlaps what follows — both let the engine
|
|
313
|
+
* backtrack to a different boundary than a one-pass scan. We also require at
|
|
314
|
+
* least one mandatory segment (so the token can't match zero-width).
|
|
315
|
+
*
|
|
316
|
+
* A skippable/repeatable part `p` at index `k` is checked against
|
|
317
|
+
* `seqFirstAccept(parts.slice(k + 1))` — the accept-set of EVERYTHING that
|
|
318
|
+
* could start at the very next position, unioned through any further chain of
|
|
319
|
+
* optionals up to the next mandatory part (the exact same computation
|
|
320
|
+
* `seqFirstAccept` already does for a seq's OWN leading accept-set). This
|
|
321
|
+
* subsumes the simpler "check only the immediate neighbor" rule as the
|
|
322
|
+
* one-part-following case, while also correctly handling a CHAIN of several
|
|
323
|
+
* consecutive optional parts (e.g. two independent optional groups in a row,
|
|
324
|
+
* `(?:\.\d+)?(?:[eE][+-]?\d+)?` — JSON/GraphQL's number tail, §8f): each is
|
|
325
|
+
* safe precisely when disjoint from everything that could follow it, however
|
|
326
|
+
* many further optionals that spans. An empty tail (`p` is the last part)
|
|
327
|
+
* naturally resolves to a trivially-disjoint empty set — nothing to check.
|
|
328
|
+
*
|
|
329
|
+
* Uniform across `lit`/`run`/`group`: each part's own "first char it could
|
|
330
|
+
* start with" (`partFirstAccept`) is compared via `firstSetsDisjoint` — the
|
|
331
|
+
* same subset/disjoint math used everywhere else in this file (§8b's
|
|
332
|
+
* lookahead guard, §8e's alt-dispatch guard).
|
|
333
|
+
*/
|
|
334
|
+
function seqIsUnambiguous(parts: SeqPart[]): boolean {
|
|
335
|
+
let hasMandatory = false
|
|
336
|
+
for (let k = 0; k < parts.length; k++) {
|
|
337
|
+
const p = parts[k]!
|
|
338
|
+
const mandatory = p.part === 'lit' ? !p.optional : p.min >= 1
|
|
339
|
+
if (mandatory) hasMandatory = true
|
|
340
|
+
// A `group` part has a right-edge exposure (`groupPartExposure`): the chars a
|
|
341
|
+
// backtracking engine could give back by shrinking the body's own trailing
|
|
342
|
+
// quantifier, dropping the group (if optional/repeated), or — for a
|
|
343
|
+
// non-disjoint alt body — switching arms. Ordered-choice greedy scanning
|
|
344
|
+
// matches the engine only when NONE of those relinquished chars could also
|
|
345
|
+
// start what follows the group (else the scan and the engine disagree on
|
|
346
|
+
// where the group ends). Two outcomes:
|
|
347
|
+
// - a concrete class → must be disjoint from `seqFirstAccept(rest)`. This
|
|
348
|
+
// is the general fix for a group whose body ends in an unbounded run
|
|
349
|
+
// (`(?:\d+)\d` must NOT lower), and it lets a mutually-exclusive fixed
|
|
350
|
+
// alt (§8i, exposure `null`) lower at ANY position, not just trailing.
|
|
351
|
+
// - `'unsupported'` → the exposure can't be bounded (e.g. a non-disjoint
|
|
352
|
+
// alt like `(?:a|ab)` that genuinely needs arm-switching). Sound only
|
|
353
|
+
// when nothing follows to force reconsideration — the group must be the
|
|
354
|
+
// trailing, matched-exactly-once part (§8h); otherwise decline.
|
|
355
|
+
if (p.part === 'group') {
|
|
356
|
+
const exposure = groupPartExposure(p)
|
|
357
|
+
if (exposure === 'unsupported') {
|
|
358
|
+
const trailingOnce = k === parts.length - 1 && p.min === 1 && !p.unbounded
|
|
359
|
+
if (!trailingOnce) return false
|
|
360
|
+
} else if (exposure !== null) {
|
|
361
|
+
if (!firstSetsDisjoint(exposure, seqFirstAccept(parts.slice(k + 1)))) return false
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// A part needs the disjoint-from-continuation check when a greedy one-pass
|
|
365
|
+
// scan has freedom a backtracking engine could resolve differently: an
|
|
366
|
+
// optional `lit`, a `run` with wiggle (`max > min` — covers `?`/`*`/`+` and
|
|
367
|
+
// bounded `{n,m}` with `m > n`; a fixed `{n}` run has none), or an
|
|
368
|
+
// optional/repeated `group`. A fixed-length run (`max === min`) consumes an
|
|
369
|
+
// exact count with no boundary to shift, so it never needs the check.
|
|
370
|
+
const needsDisjoint =
|
|
371
|
+
p.part === 'lit' ? p.optional
|
|
372
|
+
: p.part === 'run' ? p.max > p.min
|
|
373
|
+
: (p.unbounded || p.min === 0)
|
|
374
|
+
if (needsDisjoint) {
|
|
375
|
+
const rest = seqFirstAccept(parts.slice(k + 1))
|
|
376
|
+
if (!firstSetsDisjoint(partFirstAccept(p), rest)) return false
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return hasMandatory
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function rangesOverlap(a: Array<[number, number]>, b: Array<[number, number]>): boolean {
|
|
383
|
+
return a.some(([lo, hi]) => b.some(([lo2, hi2]) => lo <= hi2 && lo2 <= hi))
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Is every range in `sub` fully contained in some range of `sup`? */
|
|
387
|
+
function rangesSubset(sub: Array<[number, number]>, sup: Array<[number, number]>): boolean {
|
|
388
|
+
return sub.every(([lo, hi]) => sup.some(([lo2, hi2]) => lo >= lo2 && hi <= hi2))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
type TrailingLookahead = {
|
|
392
|
+
base: string
|
|
393
|
+
ranges: Array<[number, number]>
|
|
394
|
+
classNegated: boolean
|
|
395
|
+
negative: boolean
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
type CharSet = { ranges: Array<[number, number]>; negated: boolean }
|
|
399
|
+
|
|
400
|
+
/** Is char-set `t` fully contained in char-set `u`? Handles all 4 sign combos. */
|
|
401
|
+
function classSubset(t: CharSet, u: CharSet): boolean {
|
|
402
|
+
if (!t.negated && !u.negated) return rangesSubset(t.ranges, u.ranges)
|
|
403
|
+
if (!t.negated && u.negated) return !rangesOverlap(t.ranges, u.ranges) // t ⊆ ¬u ⟺ t ∩ u = ∅
|
|
404
|
+
if (t.negated && !u.negated) return false // ¬t ⊆ u is never true for finite ranges u
|
|
405
|
+
return rangesSubset(u.ranges, t.ranges) // ¬t ⊆ ¬u ⟺ u ⊆ t
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Are char-sets `t` and `u` disjoint (no overlap)? Handles all 4 sign combos. */
|
|
409
|
+
function classDisjoint(t: CharSet, u: CharSet): boolean {
|
|
410
|
+
if (!t.negated && !u.negated) return !rangesOverlap(t.ranges, u.ranges)
|
|
411
|
+
if (!t.negated && u.negated) return rangesSubset(t.ranges, u.ranges) // t ∩ ¬u = ∅ ⟺ t ⊆ u
|
|
412
|
+
if (t.negated && !u.negated) return rangesSubset(u.ranges, t.ranges) // symmetric
|
|
413
|
+
return false // ¬t ∩ ¬u = ∅ would require t ∪ u = every code point — can't prove with finite ranges
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** `classDisjoint`, `'any'`-aware: an empty-capable part's first-set degrades
|
|
417
|
+
* to `'any'` (see `shapeFirstAccept`), and `'any'` is never disjoint from
|
|
418
|
+
* anything (it could start with literally any char, including whatever the
|
|
419
|
+
* other side accepts). */
|
|
420
|
+
function firstSetsDisjoint(a: CharSet | 'any', b: CharSet | 'any'): boolean {
|
|
421
|
+
if (a === 'any' || b === 'any') return false
|
|
422
|
+
return classDisjoint(a, b)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* A shape's match as a fixed-length list of per-position char-sets, or `null`
|
|
427
|
+
* if its match length is NOT fixed. Only a `seq` of mandatory `lit`s (each code
|
|
428
|
+
* point is one fixed position) and mandatory single-char runs (`[x]`, min 1,
|
|
429
|
+
* bounded) qualifies — anything with an optional part, an unbounded/optional
|
|
430
|
+
* run, or a nested group has a variable length and returns `null`. Used to
|
|
431
|
+
* decide when a non-disjoint alternation's arms are mutually exclusive (§8i).
|
|
432
|
+
*/
|
|
433
|
+
function fixedClassSeq(shape: ScanShape): CharSet[] | null {
|
|
434
|
+
// A case-fold literal is fixed-length: each code point becomes a single-char
|
|
435
|
+
// class holding BOTH ASCII cases (so mutual-exclusivity is judged on what the
|
|
436
|
+
// `/i` match actually accepts — `and` vs `or` are disjoint at position 0).
|
|
437
|
+
if (shape.kind === 'litFold') {
|
|
438
|
+
return shape.open.map(cp => {
|
|
439
|
+
const other = cp >= 65 && cp <= 90 ? cp + 32 : cp >= 97 && cp <= 122 ? cp - 32 : cp
|
|
440
|
+
return other === cp
|
|
441
|
+
? { ranges: [[cp, cp]] as Array<[number, number]>, negated: false }
|
|
442
|
+
: { ranges: [[Math.min(cp, other), Math.min(cp, other)], [Math.max(cp, other), Math.max(cp, other)]] as Array<[number, number]>, negated: false }
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
if (shape.kind !== 'seq') return null
|
|
446
|
+
const out: CharSet[] = []
|
|
447
|
+
for (const p of shape.parts) {
|
|
448
|
+
if (p.part === 'lit') {
|
|
449
|
+
if (p.optional) return null
|
|
450
|
+
for (const cp of p.cps) out.push({ ranges: [[cp, cp]], negated: false })
|
|
451
|
+
} else if (p.part === 'run' && p.max === p.min && Number.isFinite(p.max)) {
|
|
452
|
+
// A fixed-count run `[x]{n}` (incl. the bare `[x]`, n=1) is n fixed
|
|
453
|
+
// positions of the same class. Variable-length runs return null below.
|
|
454
|
+
for (let c = 0; c < p.max; c++) out.push({ ranges: p.ranges, negated: p.negated })
|
|
455
|
+
} else {
|
|
456
|
+
return null // optional/variable/unbounded run, or a nested group — length not fixed
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return out.length ? out : null
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* §8i — is a NON-disjoint `alt` shape one whose arms are pairwise MUTUALLY
|
|
464
|
+
* EXCLUSIVE fixed-length token sets? Every arm must be a `fixedClassSeq`, and no
|
|
465
|
+
* arm's match may ever be a prefix (proper, or an equal-length overlap) of
|
|
466
|
+
* another arm's — i.e. for every ordered pair (A, B) with `A.length <= B.length`
|
|
467
|
+
* there is a position within A where their classes are DISJOINT. That property
|
|
468
|
+
* guarantees at most one arm matches at any input position, so ordered-choice
|
|
469
|
+
* commit lands on the SAME arm (and the SAME fixed end) the engine would, with
|
|
470
|
+
* no arm to switch to under backtracking — sound in ANY position, not just
|
|
471
|
+
* trailing (unlike the general non-disjoint alt, which needs the trailing-once
|
|
472
|
+
* gate in `seqIsUnambiguous`). `(?:ab|ac)` and `(?:foo|barn)` qualify;
|
|
473
|
+
* `(?:a|ab)` (arm `a` is a prefix of `ab`) does NOT.
|
|
474
|
+
*/
|
|
475
|
+
function altFixedMutuallyExclusive(shape: ScanShape): boolean {
|
|
476
|
+
if (shape.kind !== 'alt' || shape.disjoint) return false
|
|
477
|
+
const lists: CharSet[][] = []
|
|
478
|
+
for (const arm of shape.arms) {
|
|
479
|
+
const f = fixedClassSeq(arm)
|
|
480
|
+
if (!f) return false
|
|
481
|
+
lists.push(f)
|
|
482
|
+
}
|
|
483
|
+
for (let i = 0; i < lists.length; i++) {
|
|
484
|
+
for (let j = 0; j < lists.length; j++) {
|
|
485
|
+
if (i === j) continue
|
|
486
|
+
const a = lists[i]!
|
|
487
|
+
const b = lists[j]!
|
|
488
|
+
if (a.length > b.length) continue // only check the shorter (a) as a prefix of the longer (b)
|
|
489
|
+
let disjointAt = false
|
|
490
|
+
for (let p = 0; p < a.length; p++) {
|
|
491
|
+
if (classDisjoint(a[p]!, b[p]!)) { disjointAt = true; break }
|
|
492
|
+
}
|
|
493
|
+
if (!disjointAt) return false // `a` could be a prefix of `b` — not mutually exclusive
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return true
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The char-set a backtracking engine could relinquish at a `group` PART's right
|
|
501
|
+
* edge — its "trailing exposure". Combines two independent backtrack moves: the
|
|
502
|
+
* group body's OWN trailing wiggle (`trailingBacktrackClass(inner)`, e.g. the
|
|
503
|
+
* body ends in an unbounded run), and — if the group is optional (`min 0`) or
|
|
504
|
+
* repeated (`unbounded`) — the "drop the last occurrence" move, which exposes
|
|
505
|
+
* the body's own first-char set. A required-once group (`min 1`, bounded) has
|
|
506
|
+
* only the first move. Returns `'unsupported'` if either component can't be
|
|
507
|
+
* pinned to a concrete set. This is the single computation used everywhere a
|
|
508
|
+
* group's right boundary matters: `trailingBacktrackClass` (a `seq` ending in a
|
|
509
|
+
* group) and `seqIsUnambiguous` (a group at ANY position, followed by more).
|
|
510
|
+
*/
|
|
511
|
+
function groupPartExposure(part: Extract<SeqPart, { part: 'group' }>): CharSet | null | 'unsupported' {
|
|
512
|
+
const innerWiggle = trailingBacktrackClass(part.inner)
|
|
513
|
+
if (innerWiggle === 'unsupported') return 'unsupported'
|
|
514
|
+
const requiredOnce = !part.unbounded && part.min === 1
|
|
515
|
+
if (requiredOnce) return innerWiggle // no "drop it" move possible — only inner's own wiggle matters
|
|
516
|
+
const dropExposed = shapeFirstAccept(part.inner)
|
|
517
|
+
if (dropExposed === 'any') return 'unsupported'
|
|
518
|
+
if (innerWiggle === null) return dropExposed
|
|
519
|
+
const unioned = unionCharSets([innerWiggle, dropExposed])
|
|
520
|
+
return unioned === 'any' ? 'unsupported' : unioned
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The char-class a backtracking engine could expose by shrinking `shape`'s
|
|
525
|
+
* OWN trailing quantifier by one position — i.e. the "wiggle room" a trailing
|
|
526
|
+
* lookahead could interact with. Returns `null` when `shape`'s matched length
|
|
527
|
+
* is fully fixed once it succeeds (no quantifier to shrink), which makes
|
|
528
|
+
* wrapping it in a lookahead unconditionally safe. Returns `'unsupported'` for
|
|
529
|
+
* shape kinds whose backtracking semantics aren't modeled here (declines the
|
|
530
|
+
* lookahead lowering rather than risking an unproven guard).
|
|
531
|
+
*
|
|
532
|
+
* Only the LAST quantifier matters: `seqIsUnambiguous` already proves every
|
|
533
|
+
* EARLIER part of a `seq` has exactly one valid parse (no alternate length),
|
|
534
|
+
* so the sole remaining freedom a backtracking engine has — once we attach an
|
|
535
|
+
* external trailing lookahead — is in whatever quantifier sits at the very end.
|
|
536
|
+
*/
|
|
537
|
+
function trailingBacktrackClass(shape: ScanShape): CharSet | null | 'unsupported' {
|
|
538
|
+
if (shape.kind === 'chars') return { ranges: shape.ranges, negated: false }
|
|
539
|
+
if (shape.kind === 'ident') return { ranges: shape.tail, negated: false }
|
|
540
|
+
if (shape.kind === 'litFold') return null // fixed-length literal — no quantifier at all
|
|
541
|
+
if (shape.kind === 'seq') {
|
|
542
|
+
const last = shape.parts[shape.parts.length - 1]!
|
|
543
|
+
if (last.part === 'lit') {
|
|
544
|
+
// A REQUIRED literal tail is fixed — no quantifier, no wiggle.
|
|
545
|
+
//
|
|
546
|
+
// An OPTIONAL one is the opposite, and reading it as fixed was a defect.
|
|
547
|
+
// `::?(?![ \t\n\r\f])` on `":: "` matches ONE colon: the engine takes both,
|
|
548
|
+
// the lookahead rejects the space, it gives the second colon back, and the
|
|
549
|
+
// lookahead then sees `:` and passes. A greedy scan that resolves `::`
|
|
550
|
+
// once and never reconsiders reports NO MATCH — which is what this
|
|
551
|
+
// returned `null` for, so `lookaheadUnambiguous` waved it through and the
|
|
552
|
+
// shape lowered wrongly. Found by the oracle on jess's css grammar the
|
|
553
|
+
// first time that grammar emitted at all.
|
|
554
|
+
//
|
|
555
|
+
// Declined rather than modelled. The exposed class IS computable — drop
|
|
556
|
+
// the literal and the char at the new end is `cps[0]` — but a chain of
|
|
557
|
+
// optional parts (`a?b?`) exposes more than one level, and this analysis
|
|
558
|
+
// is single-level by construction. Wrong here is a silently wrong parse;
|
|
559
|
+
// declining costs one regex row.
|
|
560
|
+
return last.optional ? 'unsupported' : null
|
|
561
|
+
}
|
|
562
|
+
if (last.part === 'run') {
|
|
563
|
+
// Wiggle iff `max > min`: unbounded (`+`/`*`/`{n,}`), optional (`[x]?`),
|
|
564
|
+
// or bounded `{n,m}` with `m > n` all let a backtracker shrink the trailing
|
|
565
|
+
// run by ≥1 position. A fixed run (`[x]`, `{n}`, `max === min`) has no
|
|
566
|
+
// choice — no wiggle, hence no risk.
|
|
567
|
+
if (last.max > last.min) return { ranges: last.ranges, negated: last.negated }
|
|
568
|
+
return null
|
|
569
|
+
}
|
|
570
|
+
// A trailing `group` (§8f): its trailing exposure combines its body's own
|
|
571
|
+
// wiggle and (if optional/repeated) the "drop it" move — see
|
|
572
|
+
// `groupPartExposure`, shared with `seqIsUnambiguous`.
|
|
573
|
+
return groupPartExposure(last)
|
|
574
|
+
}
|
|
575
|
+
if (shape.kind === 'alt') {
|
|
576
|
+
// Arm-switching hazard (see `groupInnerSafe`) applies here too: a
|
|
577
|
+
// non-disjoint alt can't be trusted to expose the right wiggle class,
|
|
578
|
+
// since which arm ultimately "wins" could itself change under backtrack.
|
|
579
|
+
// A disjoint alt has no such hazard — exactly one arm is ever reachable
|
|
580
|
+
// from a given starting char, so the union of every arm's OWN wiggle
|
|
581
|
+
// covers whichever one is actually taken. A non-disjoint alt whose arms are
|
|
582
|
+
// fixed-length and mutually exclusive (§8i) also has no wiggle — exactly one
|
|
583
|
+
// arm matches at any position and its length is fixed, so `null`.
|
|
584
|
+
if (!shape.disjoint) return altFixedMutuallyExclusive(shape) ? null : 'unsupported'
|
|
585
|
+
const classes = shape.arms.map(trailingBacktrackClass)
|
|
586
|
+
if (classes.some(c => c === 'unsupported')) return 'unsupported'
|
|
587
|
+
const concrete = classes.filter((c): c is CharSet => c !== null)
|
|
588
|
+
if (concrete.length === 0) return null
|
|
589
|
+
const unioned = unionCharSets(concrete)
|
|
590
|
+
return unioned === 'any' ? 'unsupported' : unioned
|
|
591
|
+
}
|
|
592
|
+
// `until`/`delimited`/`string`/nested `lookahead` — not modeled; decline.
|
|
593
|
+
return 'unsupported'
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Is it safe to lower `inner(?!ranges)` / `inner(?=ranges)` to a one-shot
|
|
598
|
+
* post-match check (vs. requiring real backtracking)? See PERF_IDEAS §8b.
|
|
599
|
+
*/
|
|
600
|
+
function lookaheadUnambiguous(inner: ScanShape, operand: CharSet, negative: boolean): boolean {
|
|
601
|
+
const tail = trailingBacktrackClass(inner)
|
|
602
|
+
if (tail === 'unsupported') return false
|
|
603
|
+
if (tail === null) return true // inner's length is fixed — no backtracking possible
|
|
604
|
+
// Negative `(?!op)`: safe iff every char the tail could expose is ALSO
|
|
605
|
+
// excluded by `op` (so shrinking never turns a failure into a pass).
|
|
606
|
+
// Positive `(?=op)`: safe iff the tail is disjoint from `op` (so shrinking
|
|
607
|
+
// could never expose a char that suddenly satisfies `op`).
|
|
608
|
+
return negative ? classSubset(tail, operand) : classDisjoint(tail, operand)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Strip a trailing `(?!class)` / `(?=class)` boundary assertion, if present.
|
|
613
|
+
* Only a TRAILING lookahead with a char-class/single-char operand is
|
|
614
|
+
* recognized — no sub-patterns, no nested groups. Anything else containing an
|
|
615
|
+
* unescaped `(` is left to the other recognizers, which reject it as a
|
|
616
|
+
* metachar (`META`) and fall back to `RegExp.exec`, so a false read here can
|
|
617
|
+
* only fail to lower, never mis-lower.
|
|
618
|
+
*/
|
|
619
|
+
function stripTrailingLookahead(source: string): TrailingLookahead | null {
|
|
620
|
+
if (!source.endsWith(')')) return null
|
|
621
|
+
for (const negative of [true, false] as const) {
|
|
622
|
+
const lead = negative ? '(?!' : '(?='
|
|
623
|
+
const idx = source.lastIndexOf(lead)
|
|
624
|
+
if (idx === -1) continue
|
|
625
|
+
// The operand must be the ENTIRE lookahead body. `parseClassOperand` enforces
|
|
626
|
+
// that a bracketed body is ONE class token, so a sequence like
|
|
627
|
+
// `[ \t\n\r\f]*[\$(]` is declined here rather than read as one garbage class.
|
|
628
|
+
const operand = parseClassOperand(source.slice(idx + lead.length, -1))
|
|
629
|
+
if (!operand) continue
|
|
630
|
+
return { base: source.slice(0, idx), ranges: operand.ranges, classNegated: operand.negated, negative }
|
|
631
|
+
}
|
|
632
|
+
return null
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Strip ONE redundant outer `(?:…)` wrapper, if it spans the ENTIRE source
|
|
637
|
+
* (nothing before it, nothing — not even a quantifier — after its matching
|
|
638
|
+
* close). A non-capturing group with no trailing quantifier is semantically
|
|
639
|
+
* identical to its bare contents, so this is always safe to unwrap; it's what
|
|
640
|
+
* lets `(?:a|b|c)` (a whole-token alternation written as idiomatic regex
|
|
641
|
+
* style, e.g. CSS `basicSel`) reach the top-level `|` split below. Bails
|
|
642
|
+
* (leaves `source` untouched) on anything that isn't a clean whole-string
|
|
643
|
+
* wrap: unbalanced parens, or a group that closes before the string ends
|
|
644
|
+
* (which would mean a quantifier or trailing content follows it).
|
|
645
|
+
*/
|
|
646
|
+
function unwrapOuterGroup(source: string): string {
|
|
647
|
+
while (source.startsWith('(?:')) {
|
|
648
|
+
let depth = 1
|
|
649
|
+
let i = 3
|
|
650
|
+
let ok = true
|
|
651
|
+
while (i < source.length && depth > 0) {
|
|
652
|
+
const ch = source[i]
|
|
653
|
+
if (ch === '\\') { i += 2; continue }
|
|
654
|
+
if (ch === '[') {
|
|
655
|
+
let j = i + 1
|
|
656
|
+
if (source[j] === '^') j++
|
|
657
|
+
while (j < source.length && source[j] !== ']') {
|
|
658
|
+
if (source[j] === '\\') j += 2
|
|
659
|
+
else j++
|
|
660
|
+
}
|
|
661
|
+
if (source[j] !== ']') { ok = false; break }
|
|
662
|
+
i = j + 1
|
|
663
|
+
continue
|
|
664
|
+
}
|
|
665
|
+
if (ch === '(') depth++
|
|
666
|
+
else if (ch === ')') depth--
|
|
667
|
+
i++
|
|
668
|
+
}
|
|
669
|
+
if (!ok || depth !== 0 || i !== source.length) break
|
|
670
|
+
source = source.slice(3, -1)
|
|
671
|
+
}
|
|
672
|
+
return source
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Split a regex source on top-level `|` — i.e. `|` outside any `[]` bracket
|
|
677
|
+
* class and outside any `(...)` group (parens still count toward depth even
|
|
678
|
+
* though we don't otherwise understand groups; a `|` nested in one is never a
|
|
679
|
+
* split point). Returns null if there's no top-level `|` at all, if brackets/
|
|
680
|
+
* parens are unbalanced, or if any resulting alternative is empty (a
|
|
681
|
+
* zero-width arm — an unmodeled edge case, decline rather than mis-lower).
|
|
682
|
+
* One redundant whole-string `(?:…)` wrapper is stripped first (see
|
|
683
|
+
* `unwrapOuterGroup`) so `(?:a|b)` splits the same as bare `a|b`.
|
|
684
|
+
*/
|
|
685
|
+
function splitTopLevelAlternation(source: string): string[] | null {
|
|
686
|
+
const body = unwrapOuterGroup(source)
|
|
687
|
+
const parts: string[] = []
|
|
688
|
+
let depth = 0
|
|
689
|
+
let last = 0
|
|
690
|
+
let i = 0
|
|
691
|
+
while (i < body.length) {
|
|
692
|
+
const ch = body[i]
|
|
693
|
+
if (ch === '\\') { i += 2; continue }
|
|
694
|
+
if (ch === '[') {
|
|
695
|
+
let j = i + 1
|
|
696
|
+
if (body[j] === '^') j++
|
|
697
|
+
while (j < body.length && body[j] !== ']') {
|
|
698
|
+
if (body[j] === '\\') j += 2
|
|
699
|
+
else j++
|
|
700
|
+
}
|
|
701
|
+
if (body[j] !== ']') return null
|
|
702
|
+
i = j + 1
|
|
703
|
+
continue
|
|
704
|
+
}
|
|
705
|
+
if (ch === '(') { depth++; i++; continue }
|
|
706
|
+
if (ch === ')') { depth--; if (depth < 0) return null; i++; continue }
|
|
707
|
+
if (ch === '|' && depth === 0) {
|
|
708
|
+
parts.push(body.slice(last, i))
|
|
709
|
+
last = i + 1
|
|
710
|
+
i++
|
|
711
|
+
continue
|
|
712
|
+
}
|
|
713
|
+
i++
|
|
714
|
+
}
|
|
715
|
+
if (depth !== 0) return null
|
|
716
|
+
parts.push(body.slice(last))
|
|
717
|
+
if (parts.length < 2 || parts.some(p => p.length === 0)) return null
|
|
718
|
+
return parts
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Can `shape` match the empty string? Only an unbounded `chars` run with no
|
|
722
|
+
* `+` (`[x]*`) can — every other shape requires at least one fixed/mandatory
|
|
723
|
+
* char (`seq` always has ≥1 mandatory part per `seqIsUnambiguous`; `ident`/
|
|
724
|
+
* `until`/`delimited`/`string`/`litFold` all require a fixed opening char). A
|
|
725
|
+
* `lookahead`/`alt` defer to what they wrap. */
|
|
726
|
+
function shapeCanBeEmpty(shape: ScanShape): boolean {
|
|
727
|
+
if (shape.kind === 'chars') return !shape.minOne
|
|
728
|
+
if (shape.kind === 'lookahead') return shapeCanBeEmpty(shape.inner)
|
|
729
|
+
if (shape.kind === 'alt') return shape.arms.some(shapeCanBeEmpty)
|
|
730
|
+
return false
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Exact union of char-sets when it's computable, else `'any'` (conservative:
|
|
734
|
+
* forces ordered dispatch rather than guessing at a mixed-polarity union, or
|
|
735
|
+
* when any input is already `'any'`). A single concrete-set list is returned
|
|
736
|
+
* as-is (including if negated) — the ambiguity is only in combining ≥2 sets
|
|
737
|
+
* of differing polarity. */
|
|
738
|
+
function unionCharSets(sets: Array<CharSet | 'any'>): CharSet | 'any' {
|
|
739
|
+
if (sets.some(s => s === 'any')) return 'any'
|
|
740
|
+
const concrete = sets as CharSet[]
|
|
741
|
+
if (concrete.length === 1) return concrete[0]!
|
|
742
|
+
if (concrete.some(s => s.negated)) return 'any'
|
|
743
|
+
return { ranges: concrete.flatMap(s => s.ranges), negated: false }
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** A single seq part's own first-char set, ignoring whether it's skippable.
|
|
747
|
+
* A `group` part delegates to its inner shape's own first-set (§8f) —
|
|
748
|
+
* possibly `'any'` if the inner shape can match empty or is a non-disjoint
|
|
749
|
+
* `alt` (see `shapeFirstAccept`). */
|
|
750
|
+
function partFirstAccept(part: SeqPart): CharSet | 'any' {
|
|
751
|
+
if (part.part === 'lit') return { ranges: [[part.cps[0]!, part.cps[0]!]], negated: false }
|
|
752
|
+
if (part.part === 'run') return { ranges: part.ranges, negated: part.negated }
|
|
753
|
+
return shapeFirstAccept(part.inner)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** The char-set a `seq` can start with: union of each leading skippable part's
|
|
757
|
+
* own set up to and including the first mandatory part (after which later
|
|
758
|
+
* parts can no longer be what the very first input char matches). */
|
|
759
|
+
function seqFirstAccept(parts: SeqPart[]): CharSet | 'any' {
|
|
760
|
+
const sets: Array<CharSet | 'any'> = []
|
|
761
|
+
for (const p of parts) {
|
|
762
|
+
const mandatory = p.part === 'lit' ? !p.optional : p.min >= 1
|
|
763
|
+
sets.push(partFirstAccept(p))
|
|
764
|
+
if (mandatory) break
|
|
765
|
+
}
|
|
766
|
+
return unionCharSets(sets)
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Is `inner` safe to use as a group's body (§8f)? `chars`/`ident`/`seq`/
|
|
771
|
+
* `litFold` are always safe: each is only ever constructed after proving (via
|
|
772
|
+
* `seqIsUnambiguous` or equivalent) that greedy resolution is the UNIQUE valid
|
|
773
|
+
* match at a given position — there is no alternate length for a backtracker
|
|
774
|
+
* to reconsider, so treating the group as an atomic, resolve-once unit is
|
|
775
|
+
* sound regardless of what follows it. A `lookahead` defers to what it wraps
|
|
776
|
+
* (zero-width, adds no new choice). An `alt` is safe ONLY when `disjoint`:
|
|
777
|
+
* a NON-disjoint alt (`(?:a|ab)`) genuinely can need real backtracking into a
|
|
778
|
+
* DIFFERENT arm if something after the group fails (verified: `/^(?:a|ab)c/`
|
|
779
|
+
* matches "abc" via the SECOND arm, only because the first arm's match left
|
|
780
|
+
* "c" unsatisfied) — our ordered-dispatch codegen resolves once and never
|
|
781
|
+
* reconsiders, so that case must decline. `until`/`delimited`/`string` are
|
|
782
|
+
* declined outright, same conservative call as `trailingBacktrackClass`.
|
|
783
|
+
*/
|
|
784
|
+
function groupInnerSafe(shape: ScanShape): boolean {
|
|
785
|
+
if (shape.kind === 'chars' || shape.kind === 'ident' || shape.kind === 'seq' || shape.kind === 'litFold') return true
|
|
786
|
+
if (shape.kind === 'lookahead') return groupInnerSafe(shape.inner)
|
|
787
|
+
// A disjoint alt is safe anywhere. A NON-disjoint alt is admitted here too,
|
|
788
|
+
// but its ordered-choice-commit is only sound in the trailing-once position —
|
|
789
|
+
// `seqIsUnambiguous` (§8h) enforces that positional constraint; a non-trailing
|
|
790
|
+
// or optional/repeated non-disjoint-alt group is rejected there.
|
|
791
|
+
if (shape.kind === 'alt') return true
|
|
792
|
+
return false
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* The set of first chars a NON-EMPTY match of `shape` could start with, or
|
|
797
|
+
* `'any'` if that can't be pinned down to a single char-set (used to decide
|
|
798
|
+
* whether `alt` arms have disjoint first-sets). A shape that can match empty
|
|
799
|
+
* degrades to `'any'`: since it could succeed with zero chars regardless of
|
|
800
|
+
* what follows, it would "accept" any first char by never actually needing one
|
|
801
|
+
* — treating that as disjoint from anything else would be unsound.
|
|
802
|
+
*/
|
|
803
|
+
function shapeFirstAccept(shape: ScanShape): CharSet | 'any' {
|
|
804
|
+
if (shapeCanBeEmpty(shape)) return 'any'
|
|
805
|
+
switch (shape.kind) {
|
|
806
|
+
case 'chars': return { ranges: shape.ranges, negated: false }
|
|
807
|
+
case 'ident': return { ranges: shape.head, negated: false }
|
|
808
|
+
case 'until': return { ranges: [[shape.open[0]!, shape.open[0]!]], negated: false }
|
|
809
|
+
case 'delimited': return { ranges: [[shape.open[0]!, shape.open[0]!]], negated: false }
|
|
810
|
+
case 'string': return { ranges: [[shape.quote, shape.quote]], negated: false }
|
|
811
|
+
case 'litFold': {
|
|
812
|
+
const cp = shape.open[0]!
|
|
813
|
+
if (cp >= 65 && cp <= 90) return { ranges: [[cp, cp], [cp + 32, cp + 32]], negated: false }
|
|
814
|
+
if (cp >= 97 && cp <= 122) return { ranges: [[cp, cp], [cp - 32, cp - 32]], negated: false }
|
|
815
|
+
return { ranges: [[cp, cp]], negated: false }
|
|
816
|
+
}
|
|
817
|
+
case 'seq': return seqFirstAccept(shape.parts)
|
|
818
|
+
case 'lookahead': return shapeFirstAccept(shape.inner) // zero-width — doesn't change first-char acceptance
|
|
819
|
+
case 'alt': {
|
|
820
|
+
// The set of first chars is the union of every arm's own first-set,
|
|
821
|
+
// independent of whether the arms are mutually disjoint (that flag only
|
|
822
|
+
// governs dispatch, not what the shape can start with). Any arm whose
|
|
823
|
+
// first-set is itself `'any'` (stored as `null`) makes the union `'any'`.
|
|
824
|
+
const sets = shape.firsts.map((f): CharSet | 'any' => (f === null ? 'any' : f))
|
|
825
|
+
return unionCharSets(sets)
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** True iff every pair of `sets` is provably disjoint; `'any'` is disjoint from nothing. */
|
|
831
|
+
function allPairsDisjoint(sets: Array<CharSet | 'any'>): boolean {
|
|
832
|
+
for (let i = 0; i < sets.length; i++) {
|
|
833
|
+
const a = sets[i]!
|
|
834
|
+
if (a === 'any') return false
|
|
835
|
+
for (let j = i + 1; j < sets.length; j++) {
|
|
836
|
+
const b = sets[j]!
|
|
837
|
+
if (b === 'any' || !classDisjoint(a, b)) return false
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return true
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Recognize top-level alternation `A|B|C` (§8e): split on `|` outside any
|
|
845
|
+
* `[]`/`()`, lower each arm independently (an arm may itself be a `seq`,
|
|
846
|
+
* `chars`, `lookahead`, …, or even nested alternation via one redundant
|
|
847
|
+
* `(?:…)` wrapper — see `unwrapOuterGroup`). Declines (null) unless EVERY arm
|
|
848
|
+
* independently lowers — a single unsupported arm (e.g. one with a nested
|
|
849
|
+
* capturing/non-capturing group, §8f) falls the whole alternation back to
|
|
850
|
+
* `RegExp.exec` rather than partially lowering.
|
|
851
|
+
*/
|
|
852
|
+
function parseAlternation(source: string): ScanShape | null {
|
|
853
|
+
const parts = splitTopLevelAlternation(source)
|
|
854
|
+
if (!parts) return null
|
|
855
|
+
const arms: ScanShape[] = []
|
|
856
|
+
for (const p of parts) {
|
|
857
|
+
const arm = parseScanShape(p)
|
|
858
|
+
if (!arm) return null
|
|
859
|
+
arms.push(arm)
|
|
860
|
+
}
|
|
861
|
+
const firstSets = arms.map(shapeFirstAccept)
|
|
862
|
+
const disjoint = allPairsDisjoint(firstSets)
|
|
863
|
+
return { kind: 'alt', arms, disjoint, firsts: firstSets.map(f => (f === 'any' ? null : f)) }
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Recognize one scannable arm from its regex source, or null if it isn't one of
|
|
868
|
+
* the structural shapes. Top-level alternation (§8e) is tried FIRST — `|` has
|
|
869
|
+
* the lowest precedence in regex, so splitting on it happens before anything
|
|
870
|
+
* else, with each arm then recursively re-entering this same function (so an
|
|
871
|
+
* arm's own trailing lookahead, or its own nested `(?:…)`-wrapped alternation,
|
|
872
|
+
* is still recognized). Failing that: char-class run, ident, string, then
|
|
873
|
+
* open-until-terminator, delimited, and finally the general linear `seq` chain.
|
|
874
|
+
* A trailing `(?!class)`/`(?=class)` is peeled off next (§8b) and re-wraps
|
|
875
|
+
* whatever shape the remaining base recognizes as.
|
|
876
|
+
*/
|
|
877
|
+
export function parseScanShape(source: string): ScanShape | null {
|
|
878
|
+
const alt = parseAlternation(source)
|
|
879
|
+
if (alt) return alt
|
|
880
|
+
const la = stripTrailingLookahead(source)
|
|
881
|
+
if (la) {
|
|
882
|
+
const inner = parseScanShapeCore(la.base)
|
|
883
|
+
if (!inner) return null
|
|
884
|
+
const operand: CharSet = { ranges: la.ranges, negated: la.classNegated }
|
|
885
|
+
if (!lookaheadUnambiguous(inner, operand, la.negative)) return null
|
|
886
|
+
return { kind: 'lookahead', inner, ranges: la.ranges, classNegated: la.classNegated, negative: la.negative }
|
|
887
|
+
}
|
|
888
|
+
return parseScanShapeCore(source)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Split a regex fragment on top-level `|` (outside `[]`/`()`), escape-aware.
|
|
893
|
+
* Unlike `splitTopLevelAlternation` this allows a SINGLE arm (no `|` at all) and
|
|
894
|
+
* does not unwrap an outer group — it's used to enumerate the arms of a
|
|
895
|
+
* `delimited` body, which may legitimately be one bare arm (`[^x]`). Returns
|
|
896
|
+
* null on unbalanced brackets/parens.
|
|
897
|
+
*/
|
|
898
|
+
function splitDelimArms(body: string): string[] | null {
|
|
899
|
+
const arms: string[] = []
|
|
900
|
+
let depth = 0
|
|
901
|
+
let last = 0
|
|
902
|
+
let i = 0
|
|
903
|
+
while (i < body.length) {
|
|
904
|
+
const ch = body[i]
|
|
905
|
+
if (ch === '\\') { i += 2; continue }
|
|
906
|
+
if (ch === '[') {
|
|
907
|
+
let j = i + 1
|
|
908
|
+
if (body[j] === '^') j++
|
|
909
|
+
while (j < body.length && body[j] !== ']') {
|
|
910
|
+
if (body[j] === '\\') j += 2
|
|
911
|
+
else j++
|
|
912
|
+
}
|
|
913
|
+
if (body[j] !== ']') return null
|
|
914
|
+
i = j + 1
|
|
915
|
+
continue
|
|
916
|
+
}
|
|
917
|
+
if (ch === '(') { depth++; i++; continue }
|
|
918
|
+
if (ch === ')') { depth--; if (depth < 0) return null; i++; continue }
|
|
919
|
+
if (ch === '|' && depth === 0) { arms.push(body.slice(last, i)); last = i + 1 }
|
|
920
|
+
i++
|
|
921
|
+
}
|
|
922
|
+
if (depth !== 0) return null
|
|
923
|
+
arms.push(body.slice(last))
|
|
924
|
+
return arms
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Prove that the `delimited` lowering — "scan the raw input to the first full
|
|
929
|
+
* `close` literal, ignoring the body entirely" — is EQUIVALENT to the real regex
|
|
930
|
+
* `<open>(?:body)*<close>`. That equivalence holds iff `(?:body)*` can never
|
|
931
|
+
* match a string that contains the `close` literal: then greedy repetition stops
|
|
932
|
+
* exactly before the first `close`, which is where the scan lands too.
|
|
933
|
+
*
|
|
934
|
+
* We only prove this for the two-code-point delimiter-safe idiom (the sole shape that expresses
|
|
935
|
+
* "any char that doesn't form the close"):
|
|
936
|
+
*
|
|
937
|
+
* n === 1 (`close` is one char l0): body must be exactly `[^l0]`.
|
|
938
|
+
* n === 2 (`close` is `l0 l1`): body must be exactly `[^l0] | l0(?!l1)`.
|
|
939
|
+
*
|
|
940
|
+
* In both the bulk arm `[^l0]` consumes every char except l0, and the guard arm
|
|
941
|
+
* (n === 2) consumes l0 only when it is NOT followed by l1 — so the body admits
|
|
942
|
+
* l0 but never the full `l0 l1`, and rejects NOTHING else (a body that excluded
|
|
943
|
+
* some non-close char would stop early while the raw scan skipped ahead to the
|
|
944
|
+
* next close, diverging). Anything outside this template — an alternation whose
|
|
945
|
+
* arms can consume the close (`z(?:a|[0-2]+)*a`), a bulk class narrower/wider
|
|
946
|
+
* than `[^l0]`, a longer close, extra arms — is declined, falling through to
|
|
947
|
+
* `RegExp.exec`. Sound-by-construction: we only ever return true for a body we
|
|
948
|
+
* can prove never contains `close`.
|
|
949
|
+
*/
|
|
950
|
+
function delimitedBodySound(body: string, close: number[]): boolean {
|
|
951
|
+
const l0 = close[0]!
|
|
952
|
+
if (close.length > 2) return false
|
|
953
|
+
const arms = splitDelimArms(body)
|
|
954
|
+
if (!arms) return false
|
|
955
|
+
let bulkSeen = false
|
|
956
|
+
let guardSeen = false
|
|
957
|
+
for (const arm of arms) {
|
|
958
|
+
// Bulk arm: a bare negated class `[^l0]` — excluded set is exactly {l0}.
|
|
959
|
+
const bulk = /^\[\^((?:\\.|[^\]])+)\]$/.exec(arm)
|
|
960
|
+
if (bulk) {
|
|
961
|
+
if (bulkSeen) return false
|
|
962
|
+
const ranges = parseClassRanges(bulk[1]!)
|
|
963
|
+
if (!ranges || ranges.length !== 1 || ranges[0]![0] !== l0 || ranges[0]![1] !== l0) return false
|
|
964
|
+
bulkSeen = true
|
|
965
|
+
continue
|
|
966
|
+
}
|
|
967
|
+
// Guard arm (n === 2 only): `l0(?!l1)` — consume l0 unless it starts the close.
|
|
968
|
+
if (close.length === 2 && arm.endsWith(')')) {
|
|
969
|
+
const idx = arm.indexOf('(?!')
|
|
970
|
+
if (idx === -1) return false
|
|
971
|
+
const prefix = literalCodePoints(arm.slice(0, idx))
|
|
972
|
+
const op = parseClassOperand(arm.slice(idx + 3, -1))
|
|
973
|
+
const prefixIsL0 = prefix?.length === 1 && prefix[0] === l0
|
|
974
|
+
const forbidsL1 = op && !op.negated && op.ranges.length === 1 &&
|
|
975
|
+
op.ranges[0]![0] === close[1] && op.ranges[0]![1] === close[1]
|
|
976
|
+
if (!prefixIsL0 || !forbidsL1 || guardSeen) return false
|
|
977
|
+
guardSeen = true
|
|
978
|
+
continue
|
|
979
|
+
}
|
|
980
|
+
return false
|
|
981
|
+
}
|
|
982
|
+
// Require the exact arm set: bulk always; guard iff the close is two chars.
|
|
983
|
+
return bulkSeen && guardSeen === (close.length === 2)
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Structurally recognize `<openLit>(?:<body>)*<closeLit>` — literal opener, a
|
|
988
|
+
* single non-capturing `*`-repeated group, literal closer — and lower it to a
|
|
989
|
+
* `delimited` scan ONLY when `delimitedBodySound` proves the body can't contain
|
|
990
|
+
* the close. Returns null (→ fall through to `seq`/`RegExp.exec`) otherwise.
|
|
991
|
+
*/
|
|
992
|
+
function parseDelimited(source: string): ScanShape | null {
|
|
993
|
+
// An escape-aware body (a literal `\\` in the source ⇒ string-like) is handled
|
|
994
|
+
// by `parseStringShape`; here "scan to first close" would wrongly stop at an
|
|
995
|
+
// escaped delimiter, so decline.
|
|
996
|
+
if (source.includes('\\\\')) return null
|
|
997
|
+
// Opener: literal chars up to the first unescaped `(`, which must open `(?:`.
|
|
998
|
+
let i = 0
|
|
999
|
+
while (i < source.length && source[i] !== '(') {
|
|
1000
|
+
i += source[i] === '\\' ? 2 : 1
|
|
1001
|
+
}
|
|
1002
|
+
if (source[i] !== '(' || source[i + 1] !== '?' || source[i + 2] !== ':') return null
|
|
1003
|
+
const openFrag = source.slice(0, i)
|
|
1004
|
+
// Balanced scan of the group body to its matching `)`.
|
|
1005
|
+
let k = i + 3
|
|
1006
|
+
let depth = 1
|
|
1007
|
+
while (k < source.length && depth > 0) {
|
|
1008
|
+
const ch = source[k]
|
|
1009
|
+
if (ch === '\\') { k += 2; continue }
|
|
1010
|
+
if (ch === '[') {
|
|
1011
|
+
let j = k + 1
|
|
1012
|
+
if (source[j] === '^') j++
|
|
1013
|
+
while (j < source.length && source[j] !== ']') {
|
|
1014
|
+
if (source[j] === '\\') j += 2
|
|
1015
|
+
else j++
|
|
1016
|
+
}
|
|
1017
|
+
if (source[j] !== ']') return null
|
|
1018
|
+
k = j + 1
|
|
1019
|
+
continue
|
|
1020
|
+
}
|
|
1021
|
+
if (ch === '(') depth++
|
|
1022
|
+
else if (ch === ')') depth--
|
|
1023
|
+
k++
|
|
1024
|
+
}
|
|
1025
|
+
if (depth !== 0) return null
|
|
1026
|
+
const body = source.slice(i + 3, k - 1)
|
|
1027
|
+
if (source[k] !== '*') return null
|
|
1028
|
+
const closeFrag = source.slice(k + 1)
|
|
1029
|
+
const open = literalCodePoints(openFrag)
|
|
1030
|
+
const close = literalCodePoints(closeFrag)
|
|
1031
|
+
if (!open || !close || !delimitedBodySound(body, close)) return null
|
|
1032
|
+
return { kind: 'delimited', open, close }
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function parseScanShapeCore(source: string): ScanShape | null {
|
|
1036
|
+
// [X]+ / [X]* — a positive char-class run. A negated `[^X]+`/`[^X]*` is left to
|
|
1037
|
+
// the general `seq` path (which lowers `[^X]+` and declines zero-width `[^X]*`).
|
|
1038
|
+
let m = /^\[((?:\\.|[^\]])+)\]([+*])$/.exec(source)
|
|
1039
|
+
if (m && !m[1]!.startsWith('^')) {
|
|
1040
|
+
const ranges = parseClassRanges(m[1]!)
|
|
1041
|
+
return ranges ? { kind: 'chars', ranges, minOne: m[2] === '+' } : null
|
|
1042
|
+
}
|
|
1043
|
+
// \d+ / \w+ / \s+ / \d* / \w* / \s* — a bare shorthand-class run.
|
|
1044
|
+
m = /^\\([dws])([+*])$/.exec(source)
|
|
1045
|
+
if (m) {
|
|
1046
|
+
return { kind: 'chars', ranges: shorthandRanges(m[1] as 'd' | 'w' | 's'), minOne: m[2] === '+' }
|
|
1047
|
+
}
|
|
1048
|
+
// <head><tail>* — identifier run: one head char, then a run of tail chars.
|
|
1049
|
+
m = /^(\[(?:\\.|[^\]])+\]|\\[dws])(\[(?:\\.|[^\]])+\]|\\[dws])\*$/.exec(source)
|
|
1050
|
+
if (m) {
|
|
1051
|
+
const head = classToRanges(m[1]!)
|
|
1052
|
+
const tail = classToRanges(m[2]!)
|
|
1053
|
+
if (head && tail) return { kind: 'ident', head, tail }
|
|
1054
|
+
}
|
|
1055
|
+
// <q>(?:[^q\\]|\\.)*<q> — a quote-delimited string with escapes.
|
|
1056
|
+
const str = parseStringShape(source)
|
|
1057
|
+
if (str) return str
|
|
1058
|
+
// <lit>[^X]* — consume a literal opener, then run until a terminator char.
|
|
1059
|
+
m = /^(.*?)\[\^((?:\\.|[^\]])+)\]\*$/.exec(source)
|
|
1060
|
+
if (m) {
|
|
1061
|
+
const open = literalCodePoints(m[1]!)
|
|
1062
|
+
const stop = parseClassRanges(m[2]!)
|
|
1063
|
+
if (open && stop) return { kind: 'until', open, stop }
|
|
1064
|
+
}
|
|
1065
|
+
// <open>(?:…)*<close> — delimited token scanned to its first close literal.
|
|
1066
|
+
// Only lowered when the body provably can't contain the close (see
|
|
1067
|
+
// `parseDelimited` / `delimitedBodySound`); any other `X(?:…)*Y` declines here
|
|
1068
|
+
// and falls through to the `seq` path / `RegExp.exec`.
|
|
1069
|
+
const delim = parseDelimited(source)
|
|
1070
|
+
if (delim) return delim
|
|
1071
|
+
// General category: a linear chain of literals + char runs (optional prefixes,
|
|
1072
|
+
// literal openers, negated runs), lowered only when unambiguously greedy.
|
|
1073
|
+
const parts = parseSeqParts(source)
|
|
1074
|
+
if (parts && seqIsUnambiguous(parts)) return { kind: 'seq', parts }
|
|
1075
|
+
return null
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Flag-aware wrapper around `parseScanShape`. Lowering to a raw code-point scan
|
|
1080
|
+
* assumes default regex semantics, so any flag that changes matching (`i` case
|
|
1081
|
+
* folding, `u` surrogate handling, `m`/`s` anchor/dot behavior) disables it.
|
|
1082
|
+
* `g`/`y` are stickiness-only and safe.
|
|
1083
|
+
*/
|
|
1084
|
+
export function scanShapeFromRegex(source: string, flags: string): ScanShape | null {
|
|
1085
|
+
if (/[msu]/.test(flags)) return null
|
|
1086
|
+
if (/i/.test(flags)) return caseFoldShape(source)
|
|
1087
|
+
return parseScanShape(source)
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Case-insensitive (`/i`) scannable shapes. Beyond a pure case-fold literal
|
|
1092
|
+
* (`litFold`), this recognizes a top-level alternation of literals (an ordered
|
|
1093
|
+
* `alt` of litFolds — CSS `(?:and|or)`, `(?:media|container|supports)`), each
|
|
1094
|
+
* optionally wrapped in a trailing lookahead boundary (`(?![-\w])`, `(?=\()`).
|
|
1095
|
+
* That last form is the regex spelling of `makeWord` — a keyword plus a word
|
|
1096
|
+
* boundary — the common CSS at-rule/keyword dispatcher. Anything else declines
|
|
1097
|
+
* (→ `RegExp.exec`); a false read here can only fail to lower, never mis-lower.
|
|
1098
|
+
*/
|
|
1099
|
+
function caseFoldShape(source: string): ScanShape | null {
|
|
1100
|
+
const la = stripTrailingLookahead(source)
|
|
1101
|
+
if (la) {
|
|
1102
|
+
const inner = caseFoldLiteralOrAlt(la.base)
|
|
1103
|
+
if (!inner) return null
|
|
1104
|
+
const operand: CharSet = { ranges: la.ranges, negated: la.classNegated }
|
|
1105
|
+
if (!lookaheadUnambiguous(inner, operand, la.negative)) return null
|
|
1106
|
+
return { kind: 'lookahead', inner, ranges: la.ranges, classNegated: la.classNegated, negative: la.negative }
|
|
1107
|
+
}
|
|
1108
|
+
return caseFoldLiteralOrAlt(source)
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** A case-fold literal, or a top-level `a|b|c` alternation of pure literals. */
|
|
1112
|
+
function caseFoldLiteralOrAlt(source: string): ScanShape | null {
|
|
1113
|
+
// `foldEq` only folds ASCII letters (65–90 / 97–122). A non-ASCII code point
|
|
1114
|
+
// with a Unicode case pair (é/É, ä/Ä, …) would be emitted as an exact compare
|
|
1115
|
+
// and silently miss its alternate case — a mis-lower, not just a failed lower.
|
|
1116
|
+
// Decline those so they fall back to `RegExp.exec` (correct Unicode folding),
|
|
1117
|
+
// preserving this file's "a false read can only fail to lower, never mis-lower".
|
|
1118
|
+
const asciiOnly = (cps: number[]): boolean => cps.every(cp => cp <= 127)
|
|
1119
|
+
|
|
1120
|
+
const open = literalCodePoints(source)
|
|
1121
|
+
if (open) return asciiOnly(open) ? { kind: 'litFold', open } : null
|
|
1122
|
+
// `splitTopLevelAlternation` unwraps the outer group itself, so no pre-strip.
|
|
1123
|
+
const arms = splitTopLevelAlternation(source)
|
|
1124
|
+
if (!arms || arms.length < 2) return null
|
|
1125
|
+
const litArms: ScanShape[] = []
|
|
1126
|
+
for (const arm of arms) {
|
|
1127
|
+
const cp = literalCodePoints(arm)
|
|
1128
|
+
if (!cp || !asciiOnly(cp)) return null
|
|
1129
|
+
litArms.push({ kind: 'litFold', open: cp })
|
|
1130
|
+
}
|
|
1131
|
+
// Ordered choice: each litFold arm case-folds on its own, so skipping the
|
|
1132
|
+
// first-char dispatch guard (disjoint:false, firsts:null) is always correct —
|
|
1133
|
+
// it just tries arms in order, exactly like regex `|`.
|
|
1134
|
+
return { kind: 'alt', arms: litArms, disjoint: false, firsts: litArms.map(() => null) }
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* `PARSEMAN_SCANTO=indexof` swaps the single-stop-char `until` scan loop for
|
|
1139
|
+
* `String.prototype.indexOf`. Defaults OFF so a baseline build is provably
|
|
1140
|
+
* byte-identical to the unmodified compiler, which is what makes the A/B a
|
|
1141
|
+
* toggle rather than two different compilers (docs/design/derived-tokenization.md
|
|
1142
|
+
* §16.3: the gate is a byte-identical parse tree against a toggled baseline).
|
|
1143
|
+
*/
|
|
1144
|
+
const scanToIndexOfEnabled = (): boolean => process.env.PARSEMAN_SCANTO === 'indexof'
|
|
1145
|
+
|
|
1146
|
+
export const classCond = (cVar: string, ranges: Array<[number, number]>): string =>
|
|
1147
|
+
ranges
|
|
1148
|
+
.map(([lo, hi]) => (lo === hi ? `${cVar} === ${lo}` : `(${cVar} >= ${lo} && ${cVar} <= ${hi})`))
|
|
1149
|
+
.join(' || ')
|
|
1150
|
+
|
|
1151
|
+
/** Literal-match condition at `base + k` for each code point; uses `firstVar` at offset 0. */
|
|
1152
|
+
export const litCond = (base: string, cps: number[], firstVar?: string): string =>
|
|
1153
|
+
cps
|
|
1154
|
+
.map((cp, k) =>
|
|
1155
|
+
k === 0 && firstVar
|
|
1156
|
+
? `${firstVar} === ${cp}`
|
|
1157
|
+
: `input.charCodeAt(${base}${k ? ` + ${k}` : ''}) === ${cp}`,
|
|
1158
|
+
)
|
|
1159
|
+
.join(' && ')
|
|
1160
|
+
|
|
1161
|
+
/** Line-terminator code points `.` does not match (`\n \r \u2028 \u2029`). */
|
|
1162
|
+
export const LINE_TERMINATORS = [10, 13, 8232, 8233] as const
|
|
1163
|
+
|
|
1164
|
+
/** The body-stop chars that abort a string match (excluded set minus quote/backslash). */
|
|
1165
|
+
export function stringHardStop(
|
|
1166
|
+
shape: Extract<ScanShape, { kind: 'string' }>,
|
|
1167
|
+
): Array<[number, number]> {
|
|
1168
|
+
return shape.excluded.filter(
|
|
1169
|
+
([lo, hi]) => !(lo === hi && (lo === shape.quote || lo === BACKSLASH)),
|
|
1170
|
+
)
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** Mints a fresh, unique local variable name (`prefix` + counter). */
|
|
1174
|
+
export type Mint = (prefix?: string) => string
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* The SINGLE source of truth for how a scannable shape matches at `start`. Both
|
|
1178
|
+
* the terminal emitter and the trivia scan loop consume this, so no context can
|
|
1179
|
+
* silently reinterpret an incomplete match (e.g. an unterminated string):
|
|
1180
|
+
*
|
|
1181
|
+
* - `setup` statements (indented by `ind`) that compute the match.
|
|
1182
|
+
* - `ok` a boolean expr: did a token match at `start`? (zero-width for
|
|
1183
|
+
* `chars*` counts as a match — terminals allow it.)
|
|
1184
|
+
* - `end` the position AFTER the token. **Invariant:** `end === start`
|
|
1185
|
+
* whenever there is no progress (match failed or matched empty),
|
|
1186
|
+
* so the trivia loop can gate purely on `end > start`.
|
|
1187
|
+
*
|
|
1188
|
+
* `firstChar`, when supplied, is an expression already equal to
|
|
1189
|
+
* `charCodeAt(start)` (the trivia loop reads it once and shares it).
|
|
1190
|
+
*/
|
|
1191
|
+
export type ShapeMatch = { setup: string[]; ok: string; end: string }
|
|
1192
|
+
|
|
1193
|
+
const codeAt = (start: string, firstChar?: string): string =>
|
|
1194
|
+
firstChar ?? `input.charCodeAt(${start})`
|
|
1195
|
+
|
|
1196
|
+
// Dispatch-planning thresholds for a disjoint alt's first-char `switch` — same
|
|
1197
|
+
// tuning as codegen's `planDisjointDispatch` (kept local to avoid a scannable-run
|
|
1198
|
+
// → codegen import cycle): only worth a jump table when every arm keys off a few
|
|
1199
|
+
// DISCRETE points; a wide range (`[a-z]+`) would explode into dozens of cases.
|
|
1200
|
+
const ALT_SWITCH_RANGE_LIMIT = 4
|
|
1201
|
+
const ALT_SWITCH_MAX_CASES = 48
|
|
1202
|
+
const ALT_SWITCH_MIN_CASES = 3
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* A `switch` jump table for a *disjoint* alt when every arm's first-set is a few
|
|
1206
|
+
* discrete code points (operator/keyword first chars); else `{ kind: 'if' }` to
|
|
1207
|
+
* keep the range-comparison `if/else if` chain. Arms are pairwise-disjoint, so
|
|
1208
|
+
* each code point maps to exactly one arm.
|
|
1209
|
+
*/
|
|
1210
|
+
function planAltSwitch(firsts: Array<CharSet | null>): { kind: 'switch'; cases: number[][] } | { kind: 'if' } {
|
|
1211
|
+
const cases: number[][] = []
|
|
1212
|
+
let total = 0
|
|
1213
|
+
for (const fs of firsts) {
|
|
1214
|
+
if (!fs || fs.negated) return { kind: 'if' } // any / negated → no discrete keys
|
|
1215
|
+
const pts: number[] = []
|
|
1216
|
+
for (const [lo, hi] of fs.ranges) {
|
|
1217
|
+
if (hi - lo + 1 > ALT_SWITCH_RANGE_LIMIT) return { kind: 'if' }
|
|
1218
|
+
for (let cp = lo; cp <= hi; cp++) pts.push(cp)
|
|
1219
|
+
}
|
|
1220
|
+
total += pts.length
|
|
1221
|
+
if (total > ALT_SWITCH_MAX_CASES) return { kind: 'if' }
|
|
1222
|
+
cases.push(pts)
|
|
1223
|
+
}
|
|
1224
|
+
return total >= ALT_SWITCH_MIN_CASES ? { kind: 'switch', cases } : { kind: 'if' }
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
export function emitShapeMatch(
|
|
1228
|
+
shape: ScanShape,
|
|
1229
|
+
start: string,
|
|
1230
|
+
mint: Mint,
|
|
1231
|
+
ind: string,
|
|
1232
|
+
firstChar?: string,
|
|
1233
|
+
): ShapeMatch {
|
|
1234
|
+
if (shape.kind === 'lookahead') {
|
|
1235
|
+
const inner = emitShapeMatch(shape.inner, start, mint, ind, firstChar)
|
|
1236
|
+
const okV = mint('_ok')
|
|
1237
|
+
const endV = mint('_end')
|
|
1238
|
+
// `charInClass` folds the operand's OWN `[^…]` negation in; `guardFails`
|
|
1239
|
+
// then applies the lookahead's `!`/`=` polarity on top. Zero-width: `end`
|
|
1240
|
+
// is always `inner.end` (on success) or `start` (on failure) — never past
|
|
1241
|
+
// where `inner` itself stopped.
|
|
1242
|
+
const rawIn = classCond(`input.charCodeAt(${inner.end})`, shape.ranges)
|
|
1243
|
+
const charInClass = shape.classNegated ? `!(${rawIn})` : `(${rawIn})`
|
|
1244
|
+
const hasMatchingChar = `${inner.end} < input.length && ${charInClass}`
|
|
1245
|
+
const guardFails = shape.negative ? hasMatchingChar : `!(${hasMatchingChar})`
|
|
1246
|
+
return {
|
|
1247
|
+
setup: [
|
|
1248
|
+
...inner.setup,
|
|
1249
|
+
`${ind}let ${okV} = ${inner.ok}`,
|
|
1250
|
+
`${ind}let ${endV} = ${inner.end}`,
|
|
1251
|
+
`${ind}if (${okV} && (${guardFails})) { ${okV} = false; ${endV} = ${start} }`,
|
|
1252
|
+
],
|
|
1253
|
+
ok: okV,
|
|
1254
|
+
end: endV,
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
if (shape.kind === 'alt') {
|
|
1259
|
+
const okV = mint('_ok')
|
|
1260
|
+
const endV = mint('_end')
|
|
1261
|
+
const lines: string[] = [`${ind}let ${okV} = false`, `${ind}let ${endV} = ${start}`]
|
|
1262
|
+
// Fast path: an alternation of case-fold literals (CSS keyword lists like
|
|
1263
|
+
// `@media|@container|@supports`, `even|odd`) → a `switch` on the first char
|
|
1264
|
+
// that jumps straight to the matching group, tails compared with the bit-OR
|
|
1265
|
+
// fold. Measured ~2.4x over the ordered if-chain / ~1.4x over per-arm bit-OR.
|
|
1266
|
+
// Ordered-choice is preserved: arms are grouped by their FOLDED first code
|
|
1267
|
+
// point and, within a group, emitted in source order (first match wins);
|
|
1268
|
+
// arms in different groups can't both match a given first char.
|
|
1269
|
+
if (shape.arms.length >= 2 && shape.arms.every(a => a.kind === 'litFold' && a.open.length >= 1)) {
|
|
1270
|
+
const groups = new Map<number, number[][]>()
|
|
1271
|
+
for (const a of shape.arms) {
|
|
1272
|
+
const open = (a as Extract<ScanShape, { kind: 'litFold' }>).open
|
|
1273
|
+
const f = open[0]!
|
|
1274
|
+
const lower = f >= 65 && f <= 90 ? f + 32 : f
|
|
1275
|
+
;(groups.get(lower) ?? groups.set(lower, []).get(lower)!).push(open)
|
|
1276
|
+
}
|
|
1277
|
+
lines.push(`${ind}if (${start} < input.length) switch (${codeAt(start, firstChar)}) {`)
|
|
1278
|
+
for (const [lower, arms] of groups) {
|
|
1279
|
+
const labels = lower >= 97 && lower <= 122 ? `case ${lower}: case ${lower - 32}:` : `case ${lower}:`
|
|
1280
|
+
lines.push(`${ind} ${labels} {`)
|
|
1281
|
+
for (const open of arms) {
|
|
1282
|
+
const tail = open.slice(1).map((cp, i) => foldEq(`input.charCodeAt(${start} + ${i + 1})`, cp))
|
|
1283
|
+
const cond = tail.length ? ` && ${tail.join(' && ')}` : ''
|
|
1284
|
+
lines.push(`${ind} if (${start} + ${open.length} <= input.length${cond}) { ${okV} = true; ${endV} = ${start} + ${open.length}; break }`)
|
|
1285
|
+
}
|
|
1286
|
+
lines.push(`${ind} break`)
|
|
1287
|
+
lines.push(`${ind} }`)
|
|
1288
|
+
}
|
|
1289
|
+
lines.push(`${ind}}`)
|
|
1290
|
+
return { setup: lines, ok: okV, end: endV }
|
|
1291
|
+
}
|
|
1292
|
+
if (shape.disjoint) {
|
|
1293
|
+
// Mutually exclusive first-sets: dispatch straight to the one matching
|
|
1294
|
+
// arm, no ordering/backtracking concerns (§7b-style disjoint dispatch). A
|
|
1295
|
+
// `switch` jump table when arms key off discrete first chars, else the
|
|
1296
|
+
// range-comparison `if/else if` chain (same plan as codegen's choice()).
|
|
1297
|
+
const plan = planAltSwitch(shape.firsts)
|
|
1298
|
+
if (plan.kind === 'switch') {
|
|
1299
|
+
lines.push(`${ind}if (${start} < input.length) switch (${codeAt(start, firstChar)}) {`)
|
|
1300
|
+
shape.arms.forEach((arm, k) => {
|
|
1301
|
+
const labels = plan.cases[k]!.map(cp => `case ${cp}:`).join(' ')
|
|
1302
|
+
const m = emitShapeMatch(arm, start, mint, `${ind} `, firstChar)
|
|
1303
|
+
lines.push(`${ind} ${labels} {`)
|
|
1304
|
+
lines.push(...m.setup)
|
|
1305
|
+
lines.push(`${ind} ${okV} = ${m.ok}`)
|
|
1306
|
+
lines.push(`${ind} ${endV} = ${m.ok} ? ${m.end} : ${start}`)
|
|
1307
|
+
lines.push(`${ind} break`)
|
|
1308
|
+
lines.push(`${ind} }`)
|
|
1309
|
+
})
|
|
1310
|
+
lines.push(`${ind}}`)
|
|
1311
|
+
} else {
|
|
1312
|
+
const c0 = codeAt(start, firstChar)
|
|
1313
|
+
shape.arms.forEach((arm, k) => {
|
|
1314
|
+
const fs = shape.firsts[k]! // non-null whenever `disjoint` is true
|
|
1315
|
+
const cond = fs.negated ? `!(${classCond(c0, fs.ranges)})` : `(${classCond(c0, fs.ranges)})`
|
|
1316
|
+
const m = emitShapeMatch(arm, start, mint, `${ind} `, firstChar)
|
|
1317
|
+
lines.push(`${ind}${k === 0 ? 'if' : 'else if'} (${start} < input.length && ${cond}) {`)
|
|
1318
|
+
lines.push(...m.setup)
|
|
1319
|
+
lines.push(`${ind} ${okV} = ${m.ok}`)
|
|
1320
|
+
lines.push(`${ind} ${endV} = ${m.ok} ? ${m.end} : ${start}`)
|
|
1321
|
+
lines.push(`${ind}}`)
|
|
1322
|
+
})
|
|
1323
|
+
}
|
|
1324
|
+
} else {
|
|
1325
|
+
// Overlapping first-sets: ordered choice — try each arm in turn and take
|
|
1326
|
+
// the first that succeeds. This is exactly regex `|`'s own semantics:
|
|
1327
|
+
// first alternative to match AT ALL wins on ITS OWN greedy length, never
|
|
1328
|
+
// compared against a later alternative's (possibly longer) match.
|
|
1329
|
+
const lbl = mint('_alt')
|
|
1330
|
+
lines.push(`${ind}${lbl}: {`)
|
|
1331
|
+
for (const arm of shape.arms) {
|
|
1332
|
+
const m = emitShapeMatch(arm, start, mint, `${ind} `, firstChar)
|
|
1333
|
+
lines.push(...m.setup)
|
|
1334
|
+
lines.push(`${ind} if (${m.ok}) { ${okV} = true; ${endV} = ${m.end}; break ${lbl} }`)
|
|
1335
|
+
}
|
|
1336
|
+
lines.push(`${ind}}`)
|
|
1337
|
+
}
|
|
1338
|
+
return { setup: lines, ok: okV, end: endV }
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
if (shape.kind === 'chars') {
|
|
1342
|
+
const cur = mint('_e')
|
|
1343
|
+
return {
|
|
1344
|
+
setup: [
|
|
1345
|
+
`${ind}let ${cur} = ${start}`,
|
|
1346
|
+
`${ind}while (${cur} < input.length && (${classCond(`input.charCodeAt(${cur})`, shape.ranges)})) ${cur}++`,
|
|
1347
|
+
],
|
|
1348
|
+
// `*` always matches (possibly empty); `+` needs at least one char.
|
|
1349
|
+
ok: shape.minOne ? `${cur} > ${start}` : 'true',
|
|
1350
|
+
end: cur,
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
if (shape.kind === 'ident') {
|
|
1355
|
+
const cur = mint('_e')
|
|
1356
|
+
return {
|
|
1357
|
+
setup: [
|
|
1358
|
+
`${ind}let ${cur} = ${start}`,
|
|
1359
|
+
`${ind}if (${start} < input.length && (${classCond(codeAt(start, firstChar), shape.head)})) {`,
|
|
1360
|
+
`${ind} ${cur} = ${start} + 1`,
|
|
1361
|
+
`${ind} while (${cur} < input.length && (${classCond(`input.charCodeAt(${cur})`, shape.tail)})) ${cur}++`,
|
|
1362
|
+
`${ind}}`,
|
|
1363
|
+
],
|
|
1364
|
+
ok: `${cur} > ${start}`,
|
|
1365
|
+
end: cur,
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
if (shape.kind === 'seq') {
|
|
1370
|
+
const endV = mint('_e')
|
|
1371
|
+
const okV = mint('_ok')
|
|
1372
|
+
const runCond = (p: Extract<SeqPart, { part: 'run' }>, at: string) => {
|
|
1373
|
+
const c = classCond(`input.charCodeAt(${at})`, p.ranges)
|
|
1374
|
+
return p.negated ? `!(${c})` : `(${c})`
|
|
1375
|
+
}
|
|
1376
|
+
const lines = [`${ind}let ${endV} = ${start}`, `${ind}let ${okV} = false`, `${ind}do {`]
|
|
1377
|
+
let first = true
|
|
1378
|
+
for (const p of shape.parts) {
|
|
1379
|
+
if (p.part === 'lit') {
|
|
1380
|
+
const L = p.cps.length
|
|
1381
|
+
const cond = litCond(endV, p.cps, first ? firstChar : undefined)
|
|
1382
|
+
if (p.optional) {
|
|
1383
|
+
lines.push(`${ind} if (${endV} + ${L} <= input.length && (${cond})) ${endV} += ${L}`)
|
|
1384
|
+
} else {
|
|
1385
|
+
lines.push(`${ind} if (!(${endV} + ${L} <= input.length && (${cond}))) break`)
|
|
1386
|
+
lines.push(`${ind} ${endV} += ${L}`)
|
|
1387
|
+
}
|
|
1388
|
+
} else if (p.part === 'run' && p.max === Infinity) {
|
|
1389
|
+
// Unbounded run (`+`/`*`/`{n,}`): greedy scan, then enforce the minimum.
|
|
1390
|
+
const s = mint('_s')
|
|
1391
|
+
lines.push(`${ind} const ${s} = ${endV}`)
|
|
1392
|
+
lines.push(`${ind} while (${endV} < input.length && ${runCond(p, endV)}) ${endV}++`)
|
|
1393
|
+
if (p.min === 1) lines.push(`${ind} if (${endV} === ${s}) break`)
|
|
1394
|
+
else if (p.min > 1) lines.push(`${ind} if (${endV} - ${s} < ${p.min}) break`)
|
|
1395
|
+
} else if (p.part === 'run' && p.max === 1) {
|
|
1396
|
+
// Exactly one char: required (min 1) or optional (min 0).
|
|
1397
|
+
if (p.min === 1) {
|
|
1398
|
+
lines.push(`${ind} if (!(${endV} < input.length && ${runCond(p, endV)})) break`)
|
|
1399
|
+
lines.push(`${ind} ${endV}++`)
|
|
1400
|
+
} else {
|
|
1401
|
+
lines.push(`${ind} if (${endV} < input.length && ${runCond(p, endV)}) ${endV}++`)
|
|
1402
|
+
}
|
|
1403
|
+
} else if (p.part === 'run') {
|
|
1404
|
+
// Bounded counted run `[x]{min,max}` (§8c), max ≥ 2: consume up to `max`
|
|
1405
|
+
// occurrences, then require at least `min` (greedy — sound because
|
|
1406
|
+
// `seqIsUnambiguous` proved any wiggle is disjoint from what follows).
|
|
1407
|
+
const s = mint('_s')
|
|
1408
|
+
lines.push(`${ind} const ${s} = ${endV}`)
|
|
1409
|
+
lines.push(`${ind} while (${endV} - ${s} < ${p.max} && ${endV} < input.length && ${runCond(p, endV)}) ${endV}++`)
|
|
1410
|
+
if (p.min > 0) lines.push(`${ind} if (${endV} - ${s} < ${p.min}) break`)
|
|
1411
|
+
} else {
|
|
1412
|
+
// `group` (§8f): the group's own body is just another `ScanShape` —
|
|
1413
|
+
// recurse via `emitShapeMatch`, then splice its setup in as the body
|
|
1414
|
+
// of a required-once check, an optional take, or a repeat loop.
|
|
1415
|
+
const inner = emitShapeMatch(p.inner, endV, mint, `${ind} `, first ? firstChar : undefined)
|
|
1416
|
+
if (!p.unbounded) {
|
|
1417
|
+
lines.push(...inner.setup)
|
|
1418
|
+
if (p.min === 1) {
|
|
1419
|
+
lines.push(`${ind} if (!(${inner.ok})) break`)
|
|
1420
|
+
lines.push(`${ind} ${endV} = ${inner.end}`)
|
|
1421
|
+
} else {
|
|
1422
|
+
lines.push(`${ind} if (${inner.ok}) ${endV} = ${inner.end}`)
|
|
1423
|
+
}
|
|
1424
|
+
} else {
|
|
1425
|
+
const progV = mint('_prog')
|
|
1426
|
+
lines.push(`${ind} let ${progV} = false`)
|
|
1427
|
+
lines.push(`${ind} while (true) {`)
|
|
1428
|
+
lines.push(...inner.setup)
|
|
1429
|
+
lines.push(`${ind} if (!(${inner.ok})) break`)
|
|
1430
|
+
lines.push(`${ind} ${endV} = ${inner.end}`)
|
|
1431
|
+
lines.push(`${ind} ${progV} = true`)
|
|
1432
|
+
lines.push(`${ind} }`)
|
|
1433
|
+
if (p.min === 1) lines.push(`${ind} if (!${progV}) break`)
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
first = false
|
|
1437
|
+
}
|
|
1438
|
+
lines.push(`${ind} ${okV} = true`)
|
|
1439
|
+
lines.push(`${ind}} while (false)`)
|
|
1440
|
+
lines.push(`${ind}if (!${okV}) ${endV} = ${start}`)
|
|
1441
|
+
return { setup: lines, ok: okV, end: endV }
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
if (shape.kind === 'litFold') {
|
|
1445
|
+
const openLen = shape.open.length
|
|
1446
|
+
const endV = mint('_end')
|
|
1447
|
+
const checks = shape.open.map((cp, k) => {
|
|
1448
|
+
const cVar = k === 0 && firstChar ? firstChar : `input.charCodeAt(${start} + ${k})`
|
|
1449
|
+
return foldEq(cVar, cp)
|
|
1450
|
+
})
|
|
1451
|
+
return {
|
|
1452
|
+
setup: [
|
|
1453
|
+
`${ind}let ${endV} = ${start}`,
|
|
1454
|
+
`${ind}if (${start} + ${openLen} <= input.length && (${checks.join(' && ')})) ${endV} = ${start} + ${openLen}`,
|
|
1455
|
+
],
|
|
1456
|
+
ok: `${endV} > ${start}`,
|
|
1457
|
+
end: endV,
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
if (shape.kind === 'until') {
|
|
1462
|
+
const j = mint('_j')
|
|
1463
|
+
const openLen = shape.open.length
|
|
1464
|
+
const openChk = openLen === 1
|
|
1465
|
+
? `${codeAt(start, firstChar)} === ${shape.open[0]}`
|
|
1466
|
+
: litCond(start, shape.open, firstChar)
|
|
1467
|
+
// A SINGLE stop code point makes this scan a `memchr`, and V8 implements
|
|
1468
|
+
// `String.prototype.indexOf` with a SIMD one. Measured 4.3x over the char
|
|
1469
|
+
// loop on a 0.97 MiB corpus, 61 of 61 interleaved rounds
|
|
1470
|
+
// (scratchpad/native-primitive-bakeoff/micro-scanto.mjs, regime (a)).
|
|
1471
|
+
// Semantics are identical: the loop stops at the first stop char or at
|
|
1472
|
+
// `input.length`, which is exactly `indexOf`'s hit / not-found pair.
|
|
1473
|
+
// Multi-member stop sets stay on the loop — 3x indexOf + min measured at
|
|
1474
|
+
// parity (0.992), because the min-reduce rescans the tail and discards it.
|
|
1475
|
+
const stopCp =
|
|
1476
|
+
shape.stop.length === 1 && shape.stop[0]![0] === shape.stop[0]![1] ? shape.stop[0]![0] : null
|
|
1477
|
+
const scanLine =
|
|
1478
|
+
stopCp !== null && stopCp <= 0xffff && scanToIndexOfEnabled()
|
|
1479
|
+
? // No temp is minted: a temp would renumber every later variable in the
|
|
1480
|
+
// function and show up as an artifact-size delta that is counter
|
|
1481
|
+
// renumbering rather than this change (the §9.1.4 / §9.2 effect).
|
|
1482
|
+
`${ind} ${j} = input.indexOf(${JSON.stringify(String.fromCharCode(stopCp))}, ${j})` +
|
|
1483
|
+
`; if (${j} < 0) ${j} = input.length`
|
|
1484
|
+
: `${ind} while (${j} < input.length && !(${classCond(`input.charCodeAt(${j})`, shape.stop)})) ${j}++`
|
|
1485
|
+
return {
|
|
1486
|
+
setup: [
|
|
1487
|
+
`${ind}let ${j} = ${start}`,
|
|
1488
|
+
`${ind}if (${start} + ${openLen} <= input.length && (${openChk})) {`,
|
|
1489
|
+
`${ind} ${j} = ${start} + ${openLen}`,
|
|
1490
|
+
scanLine,
|
|
1491
|
+
`${ind}}`,
|
|
1492
|
+
],
|
|
1493
|
+
// An open-until-terminator always completes (stop char or EOF), so a matched
|
|
1494
|
+
// open literal is a full match; `end === start` iff the open didn't match.
|
|
1495
|
+
ok: `${j} > ${start}`,
|
|
1496
|
+
end: j,
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
if (shape.kind === 'string') {
|
|
1501
|
+
const okV = mint('_ok')
|
|
1502
|
+
const endV = mint('_end')
|
|
1503
|
+
const i = mint('_i')
|
|
1504
|
+
const c2 = mint('_c')
|
|
1505
|
+
const hard = stringHardStop(shape)
|
|
1506
|
+
const ltLines = shape.escLineTerm
|
|
1507
|
+
? []
|
|
1508
|
+
: (() => {
|
|
1509
|
+
const c3 = mint('_c')
|
|
1510
|
+
return [
|
|
1511
|
+
`${ind} const ${c3} = input.charCodeAt(${i} + 1)`,
|
|
1512
|
+
`${ind} if (${LINE_TERMINATORS.map(t => `${c3} === ${t}`).join(' || ')}) break`,
|
|
1513
|
+
]
|
|
1514
|
+
})()
|
|
1515
|
+
return {
|
|
1516
|
+
setup: [
|
|
1517
|
+
`${ind}let ${okV} = false`,
|
|
1518
|
+
`${ind}let ${endV} = ${start}`,
|
|
1519
|
+
`${ind}if (${start} < input.length && (${codeAt(start, firstChar)}) === ${shape.quote}) {`,
|
|
1520
|
+
`${ind} let ${i} = ${start} + 1`,
|
|
1521
|
+
`${ind} while (${i} < input.length) {`,
|
|
1522
|
+
`${ind} const ${c2} = input.charCodeAt(${i})`,
|
|
1523
|
+
`${ind} if (${c2} === ${shape.quote}) { ${okV} = true; ${endV} = ${i} + 1; break }`,
|
|
1524
|
+
`${ind} if (${c2} === ${BACKSLASH}) {`,
|
|
1525
|
+
`${ind} if (${i} + 1 >= input.length) break`,
|
|
1526
|
+
...ltLines,
|
|
1527
|
+
`${ind} ${i} += 2`,
|
|
1528
|
+
`${ind} continue`,
|
|
1529
|
+
`${ind} }`,
|
|
1530
|
+
...(hard.length ? [`${ind} if (${classCond(c2, hard)}) break`] : []),
|
|
1531
|
+
`${ind} ${i}++`,
|
|
1532
|
+
`${ind} }`,
|
|
1533
|
+
`${ind}}`,
|
|
1534
|
+
],
|
|
1535
|
+
ok: okV,
|
|
1536
|
+
end: endV,
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// delimited: <open>…<close>, requires the close literal (unclosed ⇒ no match).
|
|
1541
|
+
const j = mint('_j')
|
|
1542
|
+
const endV = mint('_end')
|
|
1543
|
+
const openLen = shape.open.length
|
|
1544
|
+
const closeLen = shape.close.length
|
|
1545
|
+
const openChk = openLen === 1
|
|
1546
|
+
? `${codeAt(start, firstChar)} === ${shape.open[0]}`
|
|
1547
|
+
: litCond(start, shape.open, firstChar)
|
|
1548
|
+
const closeChk = litCond(j, shape.close)
|
|
1549
|
+
// Scanning to a fixed close literal IS `indexOf`, and V8's is SIMD. The
|
|
1550
|
+
// rewrite is exact: the loop stops at the first close literal at or after `j`,
|
|
1551
|
+
// or runs out of room and leaves the token unterminated (no match). `indexOf`
|
|
1552
|
+
// returns that same first index, or -1 for "never occurs" — mapped to
|
|
1553
|
+
// `input.length`, which makes the `j + closeLen <= input.length` guard fail
|
|
1554
|
+
// exactly where the loop's room check did. When it IS found the close literal
|
|
1555
|
+
// matches by construction, so the guard's re-comparison is dropped.
|
|
1556
|
+
const closeStr = String.fromCharCode(...shape.close)
|
|
1557
|
+
const scanLines = scanToIndexOfEnabled()
|
|
1558
|
+
? [
|
|
1559
|
+
`${ind} ${j} = input.indexOf(${JSON.stringify(closeStr)}, ${j}); if (${j} < 0) ${j} = input.length`,
|
|
1560
|
+
`${ind} if (${j} + ${closeLen} <= input.length) ${endV} = ${j} + ${closeLen}`,
|
|
1561
|
+
]
|
|
1562
|
+
: [
|
|
1563
|
+
`${ind} while (${j} + ${closeLen - 1} < input.length && !(${closeChk})) ${j}++`,
|
|
1564
|
+
`${ind} if (${j} + ${closeLen} <= input.length && (${closeChk})) ${endV} = ${j} + ${closeLen}`,
|
|
1565
|
+
]
|
|
1566
|
+
return {
|
|
1567
|
+
setup: [
|
|
1568
|
+
`${ind}let ${j} = ${start}`,
|
|
1569
|
+
`${ind}let ${endV} = ${start}`,
|
|
1570
|
+
`${ind}if (${start} + ${openLen} <= input.length && (${openChk})) {`,
|
|
1571
|
+
`${ind} ${j} = ${start} + ${openLen}`,
|
|
1572
|
+
...scanLines,
|
|
1573
|
+
`${ind}}`,
|
|
1574
|
+
],
|
|
1575
|
+
ok: `${endV} > ${start}`,
|
|
1576
|
+
end: endV,
|
|
1577
|
+
}
|
|
1578
|
+
}
|