regex
stdlib/extra/regex.hk: regular expressions, matched by a Pike VM.
One left-to-right pass over the input drives a set of live threads, and at each position an instruction runs at most once. That dedup is the whole design: matching costs the pattern times the text and no input can make it explode, and (a+)+$ against a long run of as therefore finishes in the time its length suggests and never the time a backtracker would take.
That guarantee is here for HANKI.md §23's reason and no tradition. The step budget (--max-steps) is bytecode-only, which leaves linear time as an AOT binary's one bound between a hostile pattern and unbounded work. A matcher is also the one decoder whose pattern is as likely to be untrusted as its input: every grep-shaped tool takes the pattern from its user.
The price is fixed and worth naming: no backreferences and no lookaround. Both make the language non-regular and cost the guarantee, and both are therefore refused at compile time with an error that says which one and why, and never read as something else.
Syntax
Written out here: every implementation differs at the edges.
abc the scalars a, b, c, in order . any one scalar [a-z0-9_] a class; [^…] inverts it. A ] in first position is a literal ], and a - last is a literal dash \d \w \s digit / word / whitespace, with \D \W \S inverted \n \t \r \0 the control escapes this language can spell \x a literal x, for any metacharacter e1|e2 ordered alternation: the earlier branch wins e e+ e? repetition; e{n}, e{n,}, e{n,m} give it a count e? any quantifier followed by ? is lazy: shortest first (e) a capturing group, numbered by opening parenthesis from 1 (?:e) a group that only groups ^ $ the start and the end of the whole input, never a line \b \B a word boundary and its negation
Matching is over Unicode scalars and never bytes: . is one scalar, and a class range compares scalars, which UTF-8 permits by comparing their text (byte order is code point order). [α-ω] therefore works. The \d/\w/\s shorthands are ASCII, as they are everywhere else in this stdlib. \s is space, tab, newline and carriage return; the form feed and vertical tab are absent because a Hanki string literal cannot spell them.
Bounds
Two, both inherent and neither configurable, and both reported as ordinary compile errors and never as a fault: a pattern may nest 128 deep (json.hk's tradition) and compile to 10000 instructions. Between them they stop a pattern exhausting the host stack on the AOT tier, which no other bound does. Neither bounds matching, which the engine already bounds.
Not in this version
Named groups, a case-insensitive flag, and anchored-search variants are additions for later. Compile is a pure function, and a caller who wants a pattern folded at build time can bind it in meta; there is no separate comptime path to learn.
Replacements
replace and replace_all take a Replacement and no string, built by Regex.replacement against the pattern it will be used with:
$0 the whole match $n the nth group; the digits are read as one number, which puts $12 in reach of a twelve-group pattern $$ a literal dollar
Anything else is literal text. A group that took no part in the match expands to the empty string, as it does everywhere else.
The two steps are what leave replace total. A reference to a group the pattern does not have is a typo in a string the caller wrote, and the only place that can catch it is the one that knows the group count. It is therefore refused when the replacement is built, and never by giving every call site a Result to unwrap. Both steps are pure, and a literal pattern and a literal replacement fold from a top-level binding and the typo becomes a build error.
RegexError
struct RegexError
at: int
reason: string
end
Why a pattern would not compile. at is the byte offset the problem was found at, which lets a caller point at it.
impl Display<RegexError>
to_string
def to_string(self) -> string
Renders as <reason> at offset <at>.
RegexError(at=2, reason="nothing to repeat").to_string() => "nothing to repeat at offset 2"
impl Eq<RegexError>
eq?
def eq?(self, other: Self) -> bool
Equal when both the offset and the reason match.
RegexError(at=1, reason="x").eq?(RegexError(at=1, reason="x")) => true
Match
struct Match
start: int
stop: int
text: string
end
Where a match landed: the half-open byte range [start, stop) and the text it covers.
impl Eq<Match>
eq?
def eq?(self, other: Self) -> bool
Equal when the range and the text both match.
Match(start=0, stop=1, text="a").eq?(Match(start=0, stop=1, text="a")) => true
impl Display<Match>
to_string
def to_string(self) -> string
Renders as <text>@<start>..<stop>.
Match(start=1, stop=3, text="bc").to_string() => "bc@1..3"
Captures
struct Captures
groups: List<Option<Match>>
end
Every group of one match. groups.get(0) is the whole match; group n is the nth ( in the pattern, counted by opening parenthesis.
impl Captures
get
def get(self, index: int) -> Option<Match>
Group index, or None when the index is out of range or that group took no part in the match.
compile("(\\d+)-(\\d+)").map(|r| r.captures("7-42").and_then(|c| c.get(2)).map(|m| m.text)) => Ok(Some("42"))
length
prop length(self) -> int
How many groups this has, including group 0.
compile("(a)(b)").map(|r| r.captures("ab").map(|c| c.length)) => Ok(Some(3))
_ScalarRange
struct _ScalarRange
lo: string
hi: string
end
One inclusive scalar range. Scalars compare as single-scalar strings: UTF-8 byte order is code point order, and lo <= c <= hi reads the same on both.
_CharClass
struct _CharClass
ranges: List<_ScalarRange>
negated: bool
end
A [...] set: the ranges it admits, and whether membership is inverted.
_Anchor
type _Anchor
AtStart
AtEnd
AtWordBoundary
AtNotWordBoundary
end
A zero-width assertion.
_Quantified
struct _Quantified
inner: _Node
min: int
max: Option<int>
lazy: bool
end
A quantified node: inner repeated min to max times, max None meaning unbounded. lazy prefers the shortest match.
_Node
type _Node
ReEmpty
ReLiteral(string)
ReAny
ReSet(_CharClass)
ReConcat(List<_Node>)
ReAlternate(List<_Node>)
ReRepeat(_Quantified)
ReGroup(Option<int>, _Node)
ReAnchor(_Anchor)
end
_Inst
type _Inst
OpChar(string)
OpClass(_CharClass)
OpAny
OpSplit(int, int)
OpJump(int)
OpSave(int)
OpAssert(_Anchor)
OpAccept
end
Regex
opaque Regex
_program: List<_Inst>
_group_count: int
end
A compiled pattern, ready to match many inputs.
_Part
type _Part
RepText(string)
RepGroup(int)
end
One piece of a compiled replacement: text to emit as written, or the group to expand there.
Replacement
opaque Replacement
_parts: List<_Part>
end
A replacement compiled against a particular pattern, ready to expand against many matches. Built by Regex.replacement, which is where a reference to a group the pattern does not have is refused, which lets replace and replace_all take one of these and be total.
maxdepth
def _max_depth() -> int
How deeply a pattern may nest before compiling is refused.
maxprogram_length
def _max_program_length() -> int
How long a compiled program may get before compiling is refused.
_Step
struct _Step
text: string
width: int
end
One scalar and the bytes it occupies.
scalarat
def _scalar_at(s: string, i: int) -> Option<_Step>
The scalar starting at byte offset i, or None at or past the end. slice rounds a mid-scalar stop down, and a too-short window comes back empty and the width grows until the whole scalar fits.
scalarbefore
def _scalar_before(s: string, i: int) -> Option<string>
The scalar ending at byte offset i. slice rounds a mid-scalar start down to the scalar's first byte, and one window is enough.
_absent?
def _absent?<T>(o: Option<T>) -> bool
digitranges
def _digit_ranges() -> List<_ScalarRange>
wordranges
def _word_ranges() -> List<_ScalarRange>
spaceranges
def _space_ranges() -> List<_ScalarRange>
inclass?
def _in_class?(k: _CharClass, c: string) -> bool
wordscalar?
def _word_scalar?(c: string) -> bool
_Parsed
struct _Parsed
node: _Node
next: int
groups: int
end
A parsed subtree: the node, the offset just past it, and how many capturing groups have been allocated up to and including it.
parsealternate
def _parse_alternate(p: string, start: int, groups: int, depth: int) -> Result<_Parsed, RegexError>
e1|e2|…: the top of the grammar, and what a group's body parses as.
parseconcat
def _parse_concat(p: string, start: int, groups: int, depth: int) -> Result<_Parsed, RegexError>
A run of quantified atoms, ending at |, ), or the end of the pattern.
parserepeat
def _parse_repeat(p: string, start: int, groups: int, depth: int) -> Result<_Parsed, RegexError>
An atom and the quantifier that may follow it.
parsequantifier
def _parse_quantifier(p: string, atom: _Parsed) -> Result<_Parsed, RegexError>
_Bounds
struct _Bounds
min: int
max: Option<int>
next: int
end
The {n} / {n,} / {n,m} bounds, read from the opening brace.
parsecounted
def _parse_counted(p: string, brace: int) -> Result<_Bounds, RegexError>
_Digits
struct _Digits
value: int
next: int
end
readdigits
def _read_digits(p: string, start: int) -> _Digits
digitvalue
def _digit_value(c: string) -> int
finishquantifier
def _finish_quantifier(p: string, atom: _Parsed, after: int, min: int, max: Option<int>) -> Result<_Parsed, RegexError>
Wrap atom in the quantifier that follows it, and refuse a second one - a** is a typo, and possessive a*+ is not a thing this engine has.
parseatom
def _parse_atom(p: string, i: int, groups: int, depth: int) -> Result<_Parsed, RegexError>
parsegroup
def _parse_group(p: string, open_at: int, groups: int, depth: int) -> Result<_Parsed, RegexError>
_lookbehind?
def _lookbehind?(p: string, open_at: int) -> bool
(?<= and (?<! look behind; (?<name> is a named group, and saying "lookaround" about it would send the reader off after the wrong thing.
nolookaround
def _no_lookaround(at: int) -> RegexError
closegroup
def _close_group(p: string, open_at: int, body_at: int, slot: Option<int>, groups: int, depth: int) -> Result<_Parsed, RegexError>
parseescape
def _parse_escape(p: string, at: int, groups: int) -> Result<_Parsed, RegexError>
_shorthand
def _shorthand(c: string) -> Option<_CharClass>
\d / \w / \s and their negations, as a class.
escapeliteral
def _escape_literal(p: string, at: int) -> Result<_Step, RegexError>
The scalar an escape stands for. A letter or digit that means nothing is an error and never itself, which stops a pattern matching the wrong text unannounced.
escapeother
def _escape_other(p: string, at: int, c: string) -> Result<_Step, RegexError>
The escapes that are not one of the four control spellings: a digit is a backreference, a letter that means nothing is a typo, and anything else - a metacharacter or punctuation, stands for itself.
_alphanumeric?
def _alphanumeric?(c: string) -> bool
parseclass
def _parse_class(p: string, open_at: int, groups: int) -> Result<_Parsed, RegexError>
_ClassScan
struct _ClassScan
ranges: List<_ScalarRange>
next: int
end
classranges
def _class_ranges(p: string, start: int, open_at: int) -> Result<_ClassScan, RegexError>
_ClassItem
struct _ClassItem
shorthand: Option<_CharClass>
text: string
next: int
end
One class member: either a shorthand set or a single scalar.
classitem
def _class_item(p: string, i: int) -> Result<_ClassItem, RegexError>
classrange
def _class_range(p: string, lo: _ClassItem, acc: List<_ScalarRange>) -> Result<_ClassScan, RegexError>
A scalar, and the -hi that may follow it. A - right before the closing bracket is a literal dash and no open range.
_size
def _size(n: _Node) -> int
repeatsize
def _repeat_size(q: _Quantified) -> int
_split
def _split(a: int, b: int, lazy: bool) -> _Inst
_emit
def _emit(n: _Node, pc: int) -> List<_Inst>
emitsequence
def _emit_sequence(xs: List<_Node>, pc: int) -> List<_Inst>
emitalternate
def _emit_alternate(xs: List<_Node>, pc: int) -> List<_Inst>
e1|e2|…: a split into the first branch and everything after it, the first branch jumping clear of the rest. Ordered: the earlier branch wins.
emitrepeat
def _emit_repeat(q: _Quantified, pc: int) -> List<_Inst>
emitcopies
def _emit_copies(inner: _Node, pc: int, count: int) -> List<_Inst>
emitstar
def _emit_star(inner: _Node, pc: int, lazy: bool) -> List<_Inst>
emitoptionals
def _emit_optionals(inner: _Node, pc: int, count: int, lazy: bool) -> List<_Inst>
count nested optionals: (x(x)?)? and never x?x?, which leaves the bounded tail of x{n,m} unable to skip an inner copy and take an outer one.
compile
def compile(pattern: string) -> Result<Regex, RegexError>
Compile pattern, or say why it cannot be. Compiling once and matching many times is what to reach for: re-reading the pattern per input would be the dominant cost of any scan over more than a line.
compile("^\\d+$").map(|r| r.matches?("2026")) => Ok(true)
compile("a(").map(|_| "ok").map_err(|e| e.reason) => Err("unclosed group")
escape
def escape(s: string) -> string
Quote every metacharacter in s, which makes the result a pattern matching s alone. The direction a value takes when it was never written as a pattern: a path in a generated pattern, a filename in an alternation, whatever a stranger typed into a search box.
The quoted set is this engine's metacharacters, \\ . ^ $ | ? * + ( ) [ ] { }, and no list borrowed from another. That is not fussiness: a backslash before a letter or digit is a compile error here (\\q is an unknown escape, \\1 a refused backreference), and quoting more than this would produce patterns that do not compile. Quoting less would produce patterns that compile and match the wrong text, which is worse.
escape("hello") => "hello"
escape("") => ""
escape("a.c") => "a\\.c"
escape("/tmp/run.42.log") => "/tmp/run\\.42\\.log"
escape(".*+?") => "\\.\\*\\+\\?"
compile(escape("a.c")).map(|r| r.matches?("a.c")) => Ok(true)
compile(escape("a.c")).map(|r| r.matches?("abc")) => Ok(false)
_metacharacter?
def _metacharacter?(c: string) -> bool
Every scalar _parse_atom and the quantifier parser treat as syntax, plus the two closers that are only syntax inside a class or a repeat but cost nothing to quote and would matter if either grew.
_assemble
def _assemble(root: _Node, groups: int) -> Result<Regex, RegexError>
_Thread
struct _Thread
pc: int
slots: Map<int, int>
end
_ThreadList
struct _ThreadList
threads: List<_Thread>
seen: Set<int>
end
The threads live at one input position, plus the program counters already added there. The dedup IS the linear-time guarantee: without it a position could run the same instruction once per path that reaches it.
emptylist
def _empty_list() -> _ThreadList
addthread
def _add_thread(program: List<_Inst>, list: _ThreadList, pc: int, slots: Map<int, int>, input: string, pos: int) -> _ThreadList
Follow every zero-width step from pc and park the threads that need input on list. An explicit stack and no recursion: a long chain of splits would otherwise cost host stack proportional to the program.
_holds?
def _holds?(a: _Anchor, input: string, pos: int) -> bool
wordbefore?
def _word_before?(input: string, pos: int) -> bool
wordat?
def _word_at?(input: string, pos: int) -> bool
_consumes?
def _consumes?(program: List<_Inst>, pc: int, s: string) -> bool
Whether the instruction at pc consumes the scalar s.
_run
def _run(program: List<_Inst>, input: string, from: int) -> Option<Map<int, int>>
One left-to-right pass from from, returning the leftmost-first match's slots. Threads run in priority order, and the first OpAccept reached wins and the lower-priority threads behind it are dropped.
parsereplacement
def _parse_replacement(spec: string, groups: int) -> Result<List<_Part>, RegexError>
Compile spec against a pattern with groups capture groups.
$0 is the whole match and $n the nth group; $$ is a literal dollar. The digits after a $ are read as one number, which puts $12 in reach of a pattern with twelve groups, and is why a reference has to be checked and never assumed to be one digit. Everything else is literal text.
Two things are refused here and never at expansion time: a $ that no digit and no second $ follows, and a reference past the pattern's last group. Both are typos in a string the caller wrote, and this is the one place that knows the group count.
_expand
def _expand(parts: List<_Part>, caps: Captures) -> string
parts expanded against one match's groups. A group that took no part in the match expands to the empty string: a valid reference to something that is not there, which is a different thing from the reference errors _parse_replacement refuses.
_advance
def _advance(input: string, m: Match) -> Option<int>
Where the next scan starts after a match ending at m, or None when the scan is done. An empty match advances one scalar, and a pattern that can match nothing still terminates. Shared by every left-to-right walk, since getting this rule wrong in one of them is how such a walk hangs.
slotmatch
def _slot_match(slots: Map<int, int>, input: string, index: int) -> Option<Match>
impl Regex
matches?
def matches?(self, input: string) -> bool
Whether the pattern occurs anywhere in input.
compile("\\bcat\\b").map(|r| r.matches?("the cat sat")) => Ok(true)
compile("\\bcat\\b").map(|r| r.matches?("concatenate")) => Ok(false)
find
def find(self, input: string) -> Option<Match>
The leftmost match, or None.
compile("\\d+").map(|r| r.find("a12b345").map(|m| m.text)) => Ok(Some("12"))
compile("\\d+").map(|r| r.find("abc")) => Ok(None)
find_all
def find_all(self, input: string) -> List<Match>
Every non-overlapping match, left to right. An empty match advances the scan by one scalar, and a pattern that can match nothing still terminates.
compile("\\d+").map(|r| r.find_all("a1b22c333").map(|m| m.text)) => Ok(["1", "22", "333"])
capturesall
def _captures_all(self, input: string) -> List<Captures>
find_all's walk, with each match's groups and not the match itself. Private because replace_all is what needs it: the expansion has to see the slots of the match it is expanding against, and the whole-match walk cannot supply them.
captures
def captures(self, input: string) -> Option<Captures>
The leftmost match with its capture groups.
compile("(\\w+)@(\\w+)").map(|r| r.captures("ada@example").and_then(|c| c.get(1)).map(|m| m.text)) => Ok(Some("ada"))
replacement
def replacement(self, spec: string) -> Result<Replacement, RegexError>
Compile spec as a replacement for this pattern.
$0 is the whole match and $n the nth group; $$ is a literal dollar, and everything else is literal text. The digits after a $ are read as one number, which puts $12 in reach of a twelve-group pattern.
Checking here and never at replace time is what leaves replace total: a reference to a group the pattern does not have is a typo in a string the caller wrote, and this is the only place that knows the group count. compile and this are both pure, and a literal pattern and a literal replacement both fold from a top-level binding and the typo becomes a build error.
compile("(\\w+)").and_then(|r| r.replacement("[$1]").map(|p| r.replace("hi", p))) => Ok("[hi]")
compile("(a)").and_then(|r| r.replacement("$2")).map(|_| "built") => Err(RegexError(at=0, reason="no group 2 in this pattern"))
compile("(a)").and_then(|r| r.replacement("$x")).map(|_| "built") => Err(RegexError(at=0, reason="expected a group number or a second $"))
replace
def replace(self, input: string, replacement: Replacement) -> string
input with the first match replaced by replacement expanded against that match's groups.
compile("(\\d+)").and_then(|r| r.replacement("[$1]").map(|p| r.replace("a1b2", p))) => Ok("a[1]b2")
compile("(\\w+) (\\w+)").and_then(|r| r.replacement("$2 $1").map(|p| r.replace("hello world", p))) => Ok("world hello")
replace_all
def replace_all(self, input: string, replacement: Replacement) -> string
input with every match replaced, each expansion against its own match's groups.
compile("(\\d)").and_then(|r| r.replacement("<$1>").map(|p| r.replace_all("a1b2", p))) => Ok("a<1>b<2>")
compile("\\s+").and_then(|r| r.replacement(" ").map(|p| r.replace_all("a b c", p))) => Ok("a b c")
split
def split(self, input: string) -> List<string>
input cut at every match, the matches themselves dropped.
compile(",\\s*").map(|r| r.split("a, b,c")) => Ok(["a", "b", "c"])
_gaps
def _gaps(ms: List<Match>, input: string) -> List<string>
The text between successive matches: one more piece than there are matches, which is what split returns and what replace_all joins the replacement between.
sizeof_source
def _size_of_source(p: string) -> int