A zero-dependency Node library and CLI that parses a regular expression into a typed AST, renders it as a deterministic railroad-diagram SVG, and flags catastrophic-backtracking risk from nested quantifiers or overlapping alternation.
So far this ships four pieces: a recursive-descent parser that turns a
pattern string into a plain-object AST without using the platform's own
RegExp engine to interpret the pattern, a renderer that turns that AST
into a deterministic railroad-diagram SVG, a static analysis pass that
scores a parsed pattern's catastrophic-backtracking risk, and a library
function plus CLI that tie the three together.
npm installNo runtime dependencies — everything is built on the Node standard library.
To use the regexplain command from anywhere, link it globally:
npm linkimport { parse, parseRegExp } from './src/index.js';
const ast = parse('(foo|bar)+baz{2,4}[^x-z]?', 'gi');
// ast.type === 'Pattern'
// ast.flags === 'gi'
// ast.body is a Sequence of the top-level terms
const fromNative = parseRegExp(/\d{3}-\d{4}/);parse(source, flags) accepts a pattern body (no surrounding slashes) and an
optional flag string, and returns a Pattern AST node. parseRegExp(regexp)
is a convenience wrapper that takes a native RegExp instance.
The parser understands:
- literals and escape sequences (
\n,\t,\xNN,\uNNNN,\u{NNNN}) - groups: capturing, non-capturing (
?:), named (?<name>), lookahead (?=,?!) and lookbehind (?<=,?<!) - quantifiers:
*,+,?,{n},{n,},{n,m}, and their lazy (?) variants - alternation (
|) - character classes, including ranges, negation, and nested class escapes
(
\d,\w,\s) - anchors (
^,$) and boundaries (\b,\B) - backreferences, both numbered (
\1) and named (\k<name>)
Invalid patterns (unbalanced groups, out-of-order quantifier ranges,
quantifiers with nothing to repeat, unknown flags) raise a
RegexSyntaxError with the offending position.
See src/ast.js for the full set of node shapes.
import { parse, renderSVG } from './src/index.js';
const svg = renderSVG(parse('(foo|bar)+baz{2,4}[^x-z]?', 'gi'));
// svg is a complete, self-contained `<svg>...</svg>` stringrenderSVG(patternNode, options) takes the Pattern AST node produced by
parse() (not a raw pattern string) and returns an SVG document as a
string. It accepts no dependencies beyond the Node standard library: the
layout is computed by a small recursive algorithm that walks the AST once,
and every shape is written out as plain SVG markup (<rect>, <line>,
<text>, <circle>).
Rendering is deterministic and side-effect free: the same AST always
produces byte-identical SVG, and no network or filesystem access happens
during rendering. options.padding (default 16) controls the outer
margin in pixels.
The diagram follows standard railroad-diagram conventions:
- literals,
., anchors, character-class escapes, character classes and backreferences are drawn as labelled boxes - alternation (
|) branches into parallel horizontal tracks that rejoin - quantifiers (
*,+,?,{n,m}) draw a loop-back path above the repeated element when it can repeat, and a bypass path below it when it is optional; non-default bounds (e.g.{2,4}) and lazy quantifiers are labelled - groups are drawn as a dashed box around their contents, labelled with
their kind (
group 1,non-capturing,lookahead ?=, ...)
Any text taken from the pattern itself (literal characters, character-class
contents, group names) is XML-escaped before being written into the SVG, so
a pattern containing <, &, or quote characters cannot break out of the
generated markup.
import { parse, analyzeBacktrackingRisk } from './src/index.js';
const result = analyzeBacktrackingRisk(parse('(a+)+'));
// result.severity === 'critical'
// result.score is a 0-100 aggregate
// result.findings is an array of { type, severity, message }analyzeBacktrackingRisk(patternNode) takes the Pattern AST node produced
by parse() and returns { score, severity, findings }. It is a static
heuristic over the AST, not a simulator: it never runs the pattern against
input, so it can only flag shapes known to cause trouble in a
backtracking engine, with a plain-language reason for each one. It looks
for two of the most common causes of catastrophic or high-order polynomial
backtracking:
- nested repetition - a quantifier whose repeated body contains
another quantifier that can also repeat, e.g.
(a+)+or(a*)*. Flagged ascriticalwhen both quantifiers are unbounded,highwhen only one is, andmediumwhen both are bounded (e.g.(a{2,3}){2,3}). - ambiguous alternation under repetition - a repeated group whose
branches can match the same input, e.g.
(a|a)*or(a|ab)+, so the engine must try every matching branch on each repetition before it can rule a position out.
severity is the single worst finding (none, low, medium, high,
or critical); score sums each finding's weight, capped at 100, for
ranking patterns against each other. Because it approximates rather than
exactly resolves what a construct can match, negated character classes,
\D/\W/\S, and backreferences are all treated as "could match
anything" when checking for overlap - this favours flagging a pattern that
turns out to be safe over missing one that is not.
import { analyse } from './src/index.js';
const result = analyse('(a+)+');
// result.ast - the Pattern AST node
// result.risk - the analyzeBacktrackingRisk() result
// result.svg - the rendered railroad-diagram SVG string
// result.report - a plain-text summary of the risk findingsanalyse(input, options) parses input (a pattern body string, or a native
RegExp instance), renders it, and scores its backtracking risk in one
call. options.flags sets the flag letters when input is a string (a
RegExp's own .flags are used instead); options.svg is forwarded to
renderSVG().
$ npx regexplain check '(a+)+'
pattern: (a+)+
flags: (no flags)
risk: critical (score 40/100)
1 finding(s):
[critical] nested-quantifier: A "+" quantifier repeats inside the body of an outer "+" quantifier - ...
diagram written to /path/to/regexplain.svg<pattern> may be a bare pattern body ('\d{3}-\d{4}') or a full regex
literal including flags ('/\d{3}-\d{4}/g'). Options:
--flags <letters>— flag letters to use (ignored for a/.../flagsliteral)--out <path>— where to write the SVG (defaultregexplain.svg)--no-svg— skip writing the SVG file-h,--help— show usage
Exits non-zero with a message on stderr if the pattern cannot be parsed.
test/golden.test.mjs runs a fixed corpus of patterns (test/fixtures/corpus.mjs,
covering literals, every group kind, quantifiers, character classes, anchors,
backreferences, and both flagged risk shapes) through analyse() and compares
the resulting SVG and plain-text report byte-for-byte against checked-in
snapshots under test/fixtures/golden/. This catches unintended drift in the
renderer's markup or the risk scorer's wording that per-feature unit tests
wouldn't notice because they only assert on small substrings.
After an intentional change to the renderer or risk scorer, regenerate the snapshots and review the diff before committing it:
npm run golden:updateBuilt autonomously and gated on passing tests: every change here only ships
after npm test passes.