Lowent Manual←↑→

regex — Pike VM regular expressions without backtracking

Source
lib/regex.low
Layer
L0 — pure computation (the caller’s scratch)
Capabilities
none

Checks and finds what shape a string has. Use it for input validation (did only digits arrive), finding patterns in logs, and simple tokenising. A pattern is compiled once into a program, and the same program matches many inputs.

use regex as rx .

let n option u64 . be rx.compile "a{2,4}b" prog st .
guard is_some n . else return 1 .
let m option u64 . be rx.match_at prog "aaab" 0 cl nl mk .

The program (slice u64), the compiler state and the matcher’s workspace are all the caller’s slices. So it is effects none with no hidden allocation, reentrancy is free, and it runs inside the VM/native cross-check.

Why a Pike VM is safe. Common regex engines backtrack. At a fork (|, *) they follow one path to the end and come back to try another when stuck. When forks nest, paths to try multiply 2, 4, 8…, and one pattern like (a*)*b can take minutes on a few dozen bytes of input — that is ReDoS (regular expression denial of service). A Pike VM never goes back. It treats the pattern as an NFA and holds every “place it could be now” in a thread list (not OS threads, but values recording which instruction of the program it stands on), advancing the whole list one step at a time together for each input byte. The same place is held only once, so the list never exceeds the number of instructions. Hence exponential blow-up is impossible in principle for any pattern and input — one match from a fixed start is O(input × program).

Design and boundaries#

Syntax accepted

Properties look back after consuming. Property sets have hundreds to thousands of ranges, so expanding them into a byte automaton yields thousands of instructions — unmanageable in a design where the program buffer belongs to the caller. Instead, after consuming one code point it looks back to check the property. UTF-8 is self-synchronising and can be read backwards, and the check consumes no bytes, so the grounds for linearity stay. The tables are held by unicode. Unknown property names are rejected — answering with a broader table would be a silently wrong match.

Not built — properties inside classes ([\p{L}0-9]; classes are byte bitmaps), case-insensitive matching ((?i); needs folding tables), and backreferences and lookaround (closer to never being built — linearity dies the moment they arrive). Invalid patterns and short buffers are all rejected as values (none).

Data structures#

A whole program lives in one program buffer (slice u64). prog[0] is the instruction count, prog[1] the class count, followed by instructions of three words each, [op a b]. Code grows from the front, and character class bitmaps (256 bits = 4 words) grow backwards from the tail — when the two meet, compilation gives none. The bitmap of class k is at prog[len − 4·(k+1) .. len − 4·k).

Code · namea · bMeaning
0 CHARbyte valueconsume that byte and advance
1 ANY—consume any byte except newline (10)
2 CLASSclass number kconsume a byte in bitmap k
3 SPLITbranch 1 · branch 2run both branches (branch 1 first = greedy)
4 JMPtargetunconditional jump
5 MATCH—reaching here is a match
6 BOL · 7 EOL—^ passes only at position 0, $ only at len s
8 SAVEslot numberrecord a capture position (consumes no byte)
9 CPPROPproperty codeis the code point just consumed of that property (consumes no byte)

Table 50.1 — Instruction codes

Property codes are 0=L · 1=N · 2=P · 3=Z · 4=M · 5=Lu · 6=Ll · 7=Nd · 8=Lt · 9=Lm · 10=Lo · 11=Nl · 12=No, and negation adds 16 (\P{L} = 16). Because the negation offset was generous, the encoding did not change by a single bit when the tables grew from 8 to 13.

Compiler state st is four slots (instruction count · class count · pattern position · failure flag), initialised by compile, so only length 4 or more must be ensured. Matcher scratch is the current and next thread lists clist and nlist, the deduplication stamps marks, and the start position arrays cstart and nstart that search also takes. All are slice u64, each at least the instruction count long.

Ops at a glance#

opSignatureWhen it cannot
compileproc (pat slice u8, prog mut slice u64, st mut slice u64) → option u64none on syntax error or short buffer
match_atproc (prog slice u64, s slice u8, at u64, clist, nlist, marks) → option u64none for no match · empty program · short scratch
findproc (prog slice u64, s slice u8, clist, nlist, marks) → option u64none if no match anywhere
searchproc (prog slice u64, s slice u8, clist, nlist, marks, cstart, nstart) → option u64none for no match anywhere · empty program · short scratch
test_atproc (prog slice u64, s slice u8, clist, nlist, marks) → boolnever fails (false is the answer)
class_hasfn (prog slice u64, k u64, b u64) → boolnever fails

Table 50.2 — Ops of regex — all effects none

Names starting with rx_ are compiler and matcher internals, invisible from outside. Scratch parameters are all mut slice u64.

Ops in detail#

Using it#

Escapes are two layers deep. Lowent string literal escapes are a closed set of fourteen (chapter 3), and \d or \w are not among them (others are E-STR-ESCAPE). To write regex \d in source, write "\\d" — the literal turns \\ into one backslash, and the regex compiler reads those two bytes \d as the digit class.

module ex_regex .

use regex as rx .

proc demo input prog mut slice u64 . . input st mut slice u64 . .
  input cl mut slice u64 . . input nl mut slice u64 . . input mk mut slice u64 . .
  output u64 . effects none .
do
  guard ge (len prog) 32 . else return 90 .

  rem "ab.d*" on "abcdddx": . consumes c, greedy d* consumes ddd, end = 6
  let n option u64 . be rx.compile "ab.d*" prog st .
  guard is_some n . else return 1 .
  let m option u64 . be rx.match_at prog "abcdddx" 0 cl nl mk .
  guard is_some m . else return 2 .
  guard eq (some_value m) 6 . else return 3 .

  rem as a regex this is [a-c]+z\d --- in source, "\\d"
  let n2 option u64 . be rx.compile "[a-c]+z\\d" prog st .
  guard is_some n2 . else return 4 .
  let m2 option u64 . be rx.match_at prog "abz7" 0 cl nl mk .
  guard is_some m2 . else return 5 .
  guard eq (some_value m2) 4 . else return 6 .

  rem "ab$" only at the end --- find gives the leftmost start
  let n3 option u64 . be rx.compile "ab$" prog st .
  guard is_some n3 . else return 7 .
  let f option u64 . be rx.find prog "xxab" cl nl mk .
  guard is_some f . else return 8 .
  guard eq (some_value f) 2 . else return 9 .
  let g option u64 . be rx.find prog "abx" cl nl mk .
  guard eq (is_some g) false . else return 10 .
  return 42 .
end

search and counted repetition have the same shape.

proc demo_search input prog mut slice u64 . . input st mut slice u64 . .
  input cl mut slice u64 . . input nl mut slice u64 . . input mk mut slice u64 . .
  input cs mut slice u64 . . input ns mut slice u64 . .
  output u64 . effects none .
do
  guard ge (len prog) 32 . else return 90 .
  let n option u64 . be rx.compile "b+c" prog st .
  guard is_some n . else return 1 .
  let a option u64 . be rx.find prog "xxbbbc" cl nl mk .
  guard is_some a . else return 2 .
  let b option u64 . be rx.search prog "xxbbbc" cl nl mk cs ns .
  guard is_some b . else return 3 .
  guard eq (some_value a) (some_value b) . else return 4 .
  guard eq (some_value b) 2 . else return 5 .

  let n3 option u64 . be rx.compile "a{2,4}b" prog st .
  guard is_some n3 . else return 9 .
  let m3 option u64 . be rx.match_at prog "aaab" 0 cl nl mk .
  guard is_some m3 . else return 10 .
  guard eq (some_value m3) 4 . else return 11 .
  rem below the lower bound is not a match
  let f option u64 . be rx.match_at prog "ab" 0 cl nl mk .
  guard eq (is_some f) false . else return 12 .
  return 42 .
end

The caller provides, for example, prog of 32 slots, st of 4, and cl, nl, mk (and cs, ns for search) of 32 each — anything above the instruction count compile returned. {m,n} expands, so the instruction count grows with the repetition count.

A backtracking engine would wander through 264 paths giving (a*)*b sixty-four as, but a Pike VM finishes linearly with its thread list.

let n option u64 . be rx.compile "(a*)*b" prog st .
guard is_some n . else return 1 .
let f option u64 . be rx.match_at prog
  "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 0 cl nl mk .
guard eq (is_some f) false . else return 2 .

Counter-examples#

CallResult
rx.compile "(ab" prog stnone — unclosed group
rx.compile "*a" prog stnone — repetition with nothing before it
rx.compile "abcdefghij" tiny st (tiny of 8 slots)none — code meets bitmaps
rx.match_at prog s 0 short short shortnone — scratch shorter than the instruction count
rx.compile "a{4,2}" prog st · "a{}" · "a{2" · "a{65}"none — bad counts (the limit of 64 stops blow-up, since repetition expands)
rx.compile "\d" prog stE-STR-ESCAPE before running — write "\\d"

Table 50.3 — What is rejected as a value, and what at translation

Cautions#