Skip to content

Stdlib · tier 1

regex

regular expressions (multi-engine)

official_libraries.md · 166 lines · 5 min read

use regex

Three engines with different guarantees, chosen by construction via sub-namespace: RE2 (default, linear time, no ReDoS), PCRE (backtracking, complete features, ReDoS-prone), HyperScan (multi-pattern + streaming, its own surface). A common core of operations (is_match/find/captures/replace/split) plus the per-engine extras. A literal pattern compiles in comptime (validates + embeds; invalid = compile error); a runtime pattern compiles at runtime. The engine is heavy and volatile, hence tier 1; the RE2 algorithm is frozen (tier 0), packaged in a lib that evolves.

The engine is part of the construction, not a parameter afterward. regex.compile is the shortcut for the default (RE2); the others come from the sub-namespaces. RE2 and PCRE return the same Regex type (common core); HyperScan works differently and has its own surface.

regex.compile(pattern) → Regex via RE2 (the default)
regex.re2.compile(pattern) → Regex via RE2 (explicit)
regex.pcre.compile(pattern) → Regex via PCRE
regex.hyperscan.compile(pats) → Scanner via HyperScan (see "HyperScan", below)

Signature (identical in regex.compile/re2.compile/pcre.compile; only the engine that validates and matches changes):

fn compile(pattern: string) -> Result[Regex, RegexError] // RE2 (default)
fn compile_with(pattern: string, opts: Options) -> Result[Regex, RegexError]
alias RegexError = error{
Malformed, // invalid pattern syntax
Unsupported, // feature absent in the chosen engine (backref/lookaround in RE2)
TooLarge // pattern exceeds the engine's limits (RE2 has a program cap)
}

A literal pattern compiles in comptime: it validates (an invalid literal becomes a compile error, not a runtime Err) and embeds the compiled program in the binary (zero compilation cost at runtime). That is why a literal can be .or_panic() without risk: comptime already proved it matches, or the build failed. A runtime pattern compiles at runtime and returns Result. (It mirrors the const/string story, §16: the literal is comptime-in-source; the binding decides.)

const WORD := regex.compile("[a-z_][a-z0-9_]*").or_panic() // comptime: validates + embeds; never panics
user_re := regex.compile(padrao_do_usuario) catch |e| { return e } // runtime: Result handled normally

A feature not supported by the engine follows the same bar: a known pattern gives a compile error; a runtime pattern gives Err(Unsupported). (regex.compile("(a)\\1"), a backreference, fails in RE2; regex.pcre.compile accepts it.)

decl Regex { ... }
fn (r: Regex) is_match(text: string) -> bool
fn (r: Regex) find(text: string) -> Optional[Match] // 1st match
fn (r: Regex) find_all(text: string) -> Iterator[Match] // all (lazy)
fn (r: Regex) captures(text: string) -> Optional[Captures] // 1st match with groups
fn (r: Regex) captures_all(text: string) -> Iterator[Captures]
fn (r: Regex) replace(text: string, replacement: string) -> string // only the 1st
fn (r: Regex) replace_all(text: string, replacement: string) -> string // all
fn (r: Regex) split(text: string) -> Iterator[string]
fn (r: Regex) pattern() -> string // the source pattern

In replacement, groups are referenced by $1 (numbered) and ${name} (named); $$ is a literal $.

Each Match carries the matched slice (view of the input) and the byte range:

decl Match {
pub text: string // the matched slice (slice/view of the input)
pub start: usize // starting byte offset
pub end: usize // ending byte offset (exclusive)
}
decl Captures { ... }
fn (c: Captures) get(i: usize) -> Optional[Match] // numbered group (0 = the whole match)
fn (c: Captures) get(name: string) -> Optional[Match] // named group, dispatch by type (§13)
fn (c: Captures) len() -> usize // number of groups
fn (c: Captures) iter() -> Iterator[Optional[Match]] // groups in order (none = non-participating)

get is one name with two argument types (§13): caps.get(1) (numbered) and caps.get("ano") (named). An optional group that did not participate in the match is none, hence the Optional.

re := regex.compile("(?P<ano>\\d{4})-(?P<mes>\\d{2})").or_panic()
match re.captures("2026-06") { // captures → Optional
none => { return }
caps => { ano := caps.get("ano").or_panic().text } // "2026"
}

Options (Unicode opt-in, ASCII-fast default)

Section titled “Options (Unicode opt-in, ASCII-fast default)”
decl Options {
pub unicode: bool // true: classes/`.` over codepoints + Unicode tables; false: ASCII/bytes
pub case_insensitive: bool
pub multi_line: bool // ^/$ match at line breaks
pub dot_all: bool // `.` matches '\n'
pub swap_greed: bool // inverts greedy/lazy
}

unicode is false by default (ASCII-fast, consistent with unicode/strings being opt-in/heavy): . and \w/\d operate on bytes. Turning on unicode, . matches a codepoint and the classes use the Unicode tables (which then weigh). You choose the cost at construction, instead of the language choosing for you.

HyperScan: multi-pattern and streaming (its own surface)

Section titled “HyperScan: multi-pattern and streaming (its own surface)”

HyperScan has distinct semantics: matching N patterns simultaneously over a stream, with a callback per hit (an IDS/DPI tool, not general regex). Hence Scanner, not Regex, and it is the streaming engine (its reason to exist):

decl Scanner { ... }
fn hyperscan.compile(patterns: []string) -> Result[Scanner, RegexError] // many patterns → one Scanner
fn hyperscan.compile_ids(patterns: []Pattern) -> Result[Scanner, RegexError] // with an id per pattern
decl Pattern { pub expr: string; pub id: u32; pub opts: Options }
fn (s: Scanner) scan(text: string, on_match: fn(MatchEvent)) // over a string
fn (s: Scanner) scan_stream(r: Readable, on_match: fn(MatchEvent)) -> error{Io} // over a stream (streaming)
decl MatchEvent {
pub id: u32 // which pattern matched (the Pattern's id; index in the list when without id)
pub start: usize // offset in the stream
pub end: usize
}

scan_stream over a Readable is the match over a stream: HyperScan matches incrementally without materializing the input. (RE2 does partial streaming, incremental match/no-match, without full captures, noted as a future direction in Regex; PCRE is string-only, does not stream.)


  • Multi-engine, with the engine chosen at construction (regex.re2/pcre/hyperscan; regex.compile is RE2). The three have different guarantees, and a single Regex pretending they are the same breaks (backref compiles in PCRE, fails in RE2). A common core plus per-engine extras, not a denominator that lies.
  • RE2 default, with a linear guarantee (no ReDoS): an untrusted input pattern is safe. The honest cost is going without backreference/lookaround (Unsupported). It is the safety differential, so it is the default.
  • PCRE, with complete features but ReDoS-prone: backref/lookaround/recursion, at the cost of exponential blow-up. Only with a trusted pattern. Same API; what changes is what compiles and the time guarantee.
  • HyperScan, its own surface (multi-pattern + streaming, Scanner/callback), because it has other semantics (matching N over a stream), not general regex. It is where the match over Readable lives.
  • Configurable Unicode at construction, ASCII-fast by default: Options.unicode turns on codepoints + tables; the default is ASCII/bytes. The tables only weigh when you opt in (opt-in, like unicode).
  • Comptime if known: a literal compiles in comptime (validates + embeds; invalid = compile error), so .or_panic() on a literal never panics; runtime compiles at runtime (Result).
  • Captures by type dispatch: get(i)/get(name), one name and two argument types (§13). Each Match carries the slice plus the byte range; a non-participating group is none.
  • No allocator in the signatures (colorless); find_all/split/captures_all are lazy Iterator; replace allocates via the context’s @mm.