Skip to the content
W03NotesEngines from nothing

Comb, a regex engine that never backtracks

Comb is a reg­u­lar ex­pres­sion engine writ­ten from an empty file. It reads JavaScript’s own pat­tern syntax and gives the same matches and the same groups as the browser’s RegExp, but it runs every way a pat­tern could match at once, in one pass over the text. A pat­tern that keeps the browser busy for sec­onds, and twice as long for each letter more, takes Comb a frac­tion of a mil­lisec­ond. Type a pat­tern below, and both en­gines answer.

PostedKindNotesSeriesEngines from nothing

    ·

    Instructions·
    Threads at most·
    Thread steps·
    Comb·

    Comb runs in the page as you type. Your browser’s RegExp runs in a worker, so a pat­tern that takes it a long time cannot stop the page, and is called off after a few sec­onds. The ver­dict com­pares every match and every group of the two.

    Every way at once

    Comb com­piles a pat­tern to a small pro­gram. Most in­struc­tions read one char­ac­ter: a letter, any char­ac­ter, a class like [a-z]. The rest decide where to go: split tries two ways, jump goes back for a loop, save notes where a group starts or ends.

    A back­track­ing engine, the kind in every browser, fol­lows one way through the pro­gram, and when it fails it goes back to the last split and tries the other. Comb fol­lows every way to­gether. Each way is a thread, and all threads read the same char­ac­ter before any reads the next. A thread that cannot read its char­ac­ter dies, and at a split it be­comes two. This is the Pike VM, after Rob Pike, who wrote it for the editor sam, on an idea of Ken Thompson’s from the six­ties.

    The figure runs the pat­tern above on its text. Each row is an in­struc­tion, each column a place in the text, and each dot a thread that stood there. The darker the dot, the more the pat­tern prefers that thread.

    ·

    The same answer as the browser

    When sev­eral threads can match, a back­track­ing engine re­turns the one it found first, so its answer de­pends on order. In (a|ab)(c|bcd) on “abcd” it takes a before ab, and then has to take bcd: the match is the whole “abcd”, not the shorter “abc”. Comb keeps its threads in that same order. At a split the pre­ferred way goes first, and a new at­tempt at the next place in the text goes behind every thread al­ready run­ning.

    Two threads at the same in­struc­tion at the same place will do the same from there on, so Comb keeps only the first to arrive, the one the pat­tern prefers. That rule is why the work cannot grow past the length of the pro­gram times the length of the text. It is also why the groups come out as the browser’s do: the thread that is kept is the one a back­track­ing engine would have fin­ished with.

    Where JavaScript is particular

    Most of the code is about de­tails the spec­i­fi­ca­tion sets and other en­gines do dif­fer­ently. At the start of each pass of a loop, the spec­i­fi­ca­tion clears the groups inside it, so ((a)|b)+ on “ab” ends with the inner group empty. A loop over some­thing that can match noth­ing stops when a pass moves nowhere, so (a*)* ter­mi­nates. Comb com­piles a mark at the start of such a pass and a check at its end. Whether a pass has moved yet is then part of what a thread is, so two threads at one in­struc­tion are only the same if they agree on it.

    The web also keeps an older gram­mar alive. A lone { or ] is a char­ac­ter, \101 is an octal “A”, and [\d-z] is a class of digits, a dash and a “z”. Comb reads all of it the way the browser does.

    The first run of the corpus below found a bug the hand-writ­ten tests had missed. When the first at­tempt died at once, as it does for a pat­tern that starts with $, Comb stopped in­stead of trying the next place in the text, so $ on its own never matched.

    Held against

    A seeded gen­er­a­tor writes random pat­terns from every part of the gram­mar Comb reads: classes, es­capes, groups, lazy and greedy loops, as­ser­tions, the old gram­mar, and pat­terns that are not valid at all. Each one runs on a random text with random flags, in Comb and in the browser’s RegExp, and every match and every group must be the same. A pat­tern the browser re­jects, Comb must reject too. Run it here against your browser’s engine:

    Patterns·
    With a match·
    Answered differently·

    Each dot is a pat­tern: violet when both en­gines agree, red when they do not.

    The same corpus runs from the com­mand line against the in Node:

    $ node web/notes/comb/corpus.mjs
    200,000 random patterns against V8 12.4.254.21-node.33, in 11.8 s
    199,842 answered the same, 60,225 of them with a match
    158 refused: the pattern holds a backreference
    0 answered differently

    A re­fused pat­tern is one where the gen­er­a­tor wrote \8 after eight groups, which makes it a back­ref­er­ence.

    The pattern that stops a browser

    ^(a+)+$ on a run of a’s that ends in a b cannot match. A back­track­ing engine only finds that out by trying every way to split the a’s be­tween the inner and the outer loop, and each a dou­bles the number of ways. Comb steps three threads for each letter, what­ever the length. Press Race to time both on this device, two a’s longer at each step.

    Your browser stops after a few seconds at one length.

    From the com­mand line, in Node:

    $ node web/notes/comb/race.mjs
    /^(a+)+$/ on n a's and a b, V8 12.4.254.21-node.33
           n   V8 (ms)  Comb (ms)  Comb threads stepped
          12      0.15      0.04                    36
          14      0.11      0.05                    42
          16      0.29      0.06                    48
          18      1.22      0.06                    54
          20      4.74      0.07                    60
          22        22      0.08                    66
          24        76      0.03                    72
          26       368      0.04                    78
          28      1829      0.07                    84
          30      6896      0.04                    90
    Comb on a hundred thousand a's and a b: 122 ms, 300,000 threads stepped

    This is not a cu­rios­ity. A pat­tern like it in a web server is a denial of ser­vice: one re­quest with a long enough string holds a thread for as long as the at­tacker likes. Cloudflare’s net­work dropped most of its traf­fic for about half an hour when a fire­wall rule with a pat­tern of this kind went live, and Stack Overflow went down the same way on a pat­tern that trimmed spaces, whose time grew with the square of the length rather than dou­bling.

    What it does not have

    Comb leaves out what needs back­track­ing. A back­ref­er­ence such as (a+)\1 asks for the same text twice, and match­ing with back­ref­er­ences is NP-hard, so no known al­go­rithm matches them in linear time. Lookaround such as (?=…) asks a second ques­tion at one place in the text. Comb re­fuses both and says why, as Go’s regexp and Rust’s regex crate do. It also reads pat­terns with­out the u and v flags only. Its tests check each re­fusal.

    has a linear engine of its own behind a flag, --enable-experimental-regexp-engine-on-excessive-backtracks, which Node lists with its de­fault off. It hands a pat­tern over after too many steps back, on the same idea as Comb.

    The source is in the port­fo­lio’s repos­i­tory under web/notes/comb/: comb.js for the parser, the com­piler, the ma­chine and the corpus, corpus.mjs and race.mjs for the com­mands, comb.test.mjs for the tests.