← regex · API reference · version 0.5.0
5 file(s), 747 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.
Public items
A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
Group struct | 97 | — | 110 152 158 160 |
Match struct | 106 | — | 118 128 149 166 199 208 … (7 total) |
group fn | 118 | — | 30 32 33 34 35 36 … (20 total) |
named fn | 128 | — | 143 145 147 151 154 165 |
search fn | 179 | RGX-002 | 9 14 15 16 24 59 … (15 total) |
matches fn | 189 | RGX-003 | 7 8 29 64 78 94 … (31 total) |
capture fn | 199 | RGX-006 | 29 39 40 43 48 53 … (27 total) |
capture_all fn | 208 | RGX-007 | 77 82 85 89 93 101 … (7 total) |
replace_on fn | 230 | RGX-008 | 160 161 162 163 164 165 … (14 total) |
split_on fn | 265 | RGX-004 | 11 14 186 38 42 43 … (13 total) |
src/regex.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// --- Regex ---
// Regular expressions for loft, backed by the Rust `regex` crate.
//
// Deliberately minimal — regex here is the *small-script* text tool (log
// scanning, CLI fields, quick extraction). You pass the pattern inline;
// compiled patterns are cached in a thread-local table, so repeated calls
// with the same pattern are cheap. Structured parsing — grammars, ASTs,
// recursion — is NOT this library's job; that is loft's `match` patterns over
// a cursor (the `arguments` library's parser is written with them).
//
// The public API is subject-first text methods, named with the conventional
// regex verbs so they read naturally on a `text`:
// * line.search("[0-9]+") -> byte offset of the first match, or null
// * line.matches("[0-9]+") -> true if the pattern matches anywhere
// * line.capture("(\\w+)=(.+)") -> the first match with its groups, or null
// * line.capture_all("[0-9]+") -> every non-overlapping match
// * line.replace_on("[0-9]+", "#") -> the text with the matches replaced
// * for f in line.split_on("[,;]") { … } -> lazy split on the pattern
// The verbs dodge the stdlib literal-text methods deliberately: bare `find`,
// `split` and `replace` on `text` are the stdlib's literal substring-find,
// character-split and literal replace (C95 blocks reusing a name, even as an
// overload), so regex uses `search` (cf. JS `String.search`, Python
// `re.search`), `split_on` and `replace_on`. `matches`, `capture` and
// `capture_all` are collision-free and keep their bare names.
//
// Every offset is a BYTE offset on a code-point boundary (@PLN192): the engine
// matches by character, so a span never cuts a character where a hand-written
// `s[a..b]` can. Slice with it; compare it against `size()`, never `len()`.
//
// A `Match` holds no copy of the subject. Its texts are built as slices of
// the subject and read as values, the shape under which the compiler keeps
// them views of the subject on `--native` (`(R-DecodeView)`, @C141); the
// interpreter copies and is the oracle. So `m.group(1)` is Python's spelling
// without Python's copy.
// --- Internal natives (bridge to the Rust `regex` crate) ---
// True if `pattern` matches anywhere in `input`.
fn is_match(pattern: text, input: text) -> boolean;
#native
// Byte offset of the START of the first match, or null. Named distinctly
// from stdlib `text.find` so unqualified calls inside this module resolve
// here, not to the (reverse-argument) text method.
fn match_start(pattern: text, input: text) -> integer;
#native
// The START of the first match at or after byte `from`, with the whole input
// as context — `^` and `\b` see the bytes before `from` — or null.
fn match_start_at(pattern: text, input: text, from: integer) -> integer;
#native
// The END of that same match, or null.
fn match_end_at(pattern: text, input: text, from: integer) -> integer;
#native
// The spans of the first match at or after `from`: `[s0, e0, s1, e1, …]`, the
// whole match first, then every group in pattern order, a group that did not
// take part as `-1, -1`; empty when nothing matched or the pattern is invalid.
// The ONE span-answering native: every `Match` is built from its answer.
fn spans(pattern: text, input: text, from: integer) -> vector<integer>;
#native
// The group names of `pattern`, each preceded by `\n`: one unnamed group is
// `"\n"`, no group at all is `""`.
fn group_names(pattern: text) -> text;
#native
// `input` with the first `limit` matches replaced by `template` (all when
// `limit` is 0); unchanged when the pattern is invalid.
fn replace_native(pattern: text, input: text, template: text, limit: integer) -> text;
#native
// The parser's message for a pattern that does not compile, handed out ONCE
// per pattern per process; "" for a valid or an already reported one.
fn pattern_error(pattern: text) -> text;
#native
// A pattern that does not compile answers a miss (@RGX-003) AND, the first
// time it is used, one warning naming the pattern and the parser's message —
// the channel the stdlib gives an unguarded `÷ 0` (@C80): the value stays a
// value, the fault is said once.
fn report(pattern: text) {
e = pattern_error(pattern);
if e != "" {
log_warn("regex: the pattern `{pattern}` does not compile: {e}", "regex", 0);
}
}
// --- The match ---
// One capture group of a match. `text` is null when the group did not take
// part (an alternation branch not taken, an optional group that matched
// nothing); `start` and `end` are then -1.
pub struct Group {
pub text: text?,
pub start: integer,
pub end: integer,
}
// A match of a pattern in a subject: the matched text, its byte span, and its
// groups in pattern order (group 1 first — `text` IS group 0). `names` holds
// one entry per group, "" for an unnamed one, so `named` is a lookup.
pub struct Match {
pub text: text,
pub start: integer,
pub end: integer,
pub groups: vector<Group>,
pub names: vector<text>,
}
// Group `i` of the match (1-based, `group(0)` is the whole match), or null
// when it did not take part, there is no such group, or there was no match:
// the receiver is nullable so `line.capture(p).group(1)` reads in one go, a
// miss answering null all the way down.
pub fn group(self: Match?, i: integer) -> text? {
if self == null || i < 0 { return null; }
if i == 0 { return self.text; }
g = self.groups[i - 1];
if g == null { return null; }
g.text
}
// The group called `name`, or null when the pattern has no such name, the
// group did not take part, or there was no match.
pub fn named(self: Match?, name: text) -> text? {
if self == null || name == "" { return null; }
for n in self.names {
if n == name {
g = self.groups[n#index];
if g == null { return null; }
return g.text;
}
}
null
}
// The names of a pattern's groups as a vector, "" for an unnamed group.
fn names_of(pattern: text) -> vector<text> {
nm = group_names(pattern);
if nm == "" { return []; }
nm[1..].split('\n')
}
// The first match of `pattern` in `self` at or after byte `from`, built from
// the ONE span native: every text in it is a slice of `self`.
fn capture_from(self: text, pattern: text, from: integer) -> Match? {
sp = spans(pattern, self, from);
if len(sp) < 2 { return null; }
gs: vector<Group> = [];
i = 2;
while i + 1 < len(sp) {
s = sp[i]?;
e = sp[i + 1]?;
if s < 0 {
gs += [Group { text: null, start: -1, end: -1 }];
} else {
gs += [Group { text: self[s..e], start: s, end: e }];
}
i += 2;
}
s0 = sp[0]?;
e0 = sp[1]?;
Match { text: self[s0..e0], start: s0, end: e0, groups: gs, names: names_of(pattern) }
}
// --- Public API: subject-first text methods (call as line.verb(pattern)) ---
// Byte offset of the first match of `pattern` in the text, or null when there
// is no match (or the pattern is invalid). The conventional regex-search verb
// — named `search`, not `find`, because bare `find` on text is the stdlib's
// literal substring search. Call as line.search("[0-9]+").
// Example: @RGX-001 — why this is `search` and not `find`, and what a bare
// `find(pattern, input)` answers instead.
// Example: @RGX-002 — the answer is a BYTE offset (slice with it, compare it to
// `size()` not `len()`), and a miss is null.
pub fn search(self: text, pattern: text) -> integer? {
report(pattern);
match_start(pattern, self)
}
// True if `pattern` matches anywhere in the text. `self` registers this as
// BOTH a method (`line.matches(p)`) and a free function (`matches(line, p)`).
// Collision-free on `text` (the stdlib has `contains`, not `matches`).
// Example: @RGX-003 — a pattern that never compiled answers false, exactly like
// text that simply did not match — and says so once.
pub fn matches(self: text, pattern: text) -> boolean {
report(pattern);
is_match(pattern, self)
}
// The first match of `pattern` in the text with its capture groups, or null
// when there is none (or the pattern is invalid). Call as
// m = line.capture("^(\\w+)=(.+)$"); if m != null { key = m.group(1)?; }
// Example: @RGX-005 — a group that did not take part is null, not "".
// Example: @RGX-006 — every offset is a byte on a code-point boundary.
pub fn capture(self: text, pattern: text) -> Match? {
report(pattern);
self.capture_from(pattern, 0)
}
// Every non-overlapping match of `pattern` in the text, in order, each with
// its groups; empty when there is none. An empty match advances one
// character, so `a*` on `"baa"` answers three matches: `""`, `"aa"`, `""`.
// Example: @RGX-007 — the walk idiom that replaces `re.sub` with a callback.
pub fn capture_all(self: text, pattern: text) -> vector<Match> {
report(pattern);
out: vector<Match> = [];
pos = 0;
n = size(self);
while pos <= n {
m = self.capture_from(pattern, pos);
if m == null { break; }
s = m.start?;
e = m.end?;
pos = if e > s { e } else { e + 1 };
out += [m];
}
out
}
// The text with its matches of `pattern` replaced by `template`, in which
// `$1` / `${name}` stand for a group, `$0` for the whole match and `$$` for a
// dollar. `limit` bounds how many matches are replaced, from the left; 0
// (the default) replaces every one. Named `replace_on` beside `split_on`:
// bare `replace` on text is the stdlib's literal replace.
// Example: @RGX-008 — a template is not a format string: `{` is literal there.
pub fn replace_on(self: text, pattern: text, template: text, limit: integer = 0) -> text {
report(pattern);
replace_native(pattern, self, template, limit)
}
// (internal) The split loop. Distinct name so unqualified calls inside this
// module resolve here, not to stdlib `text.split` (character separator).
// Yields each piece lazily. The subject is searched FROM a position, never
// re-sliced: a slice would restart `^` and `\b` at every piece boundary, so
// `"abab".split_on("^ab")` answered three pieces where one match is correct.
fn split_iter(pattern: text, input: text) -> iterator<text> {
pos = 0;
n = input.size();
while true {
s = match_start_at(pattern, input, pos);
if s < 0 {
yield input[pos..n];
break;
}
e = match_end_at(pattern, input, pos);
if e <= s {
e = s + 1;
}
yield input[pos..s];
pos = e;
}
}
// Split the text on matches of `pattern`, yielding each piece lazily through
// an `iterator<text>` (reuses loft's coroutine channel — nothing is
// materialised). Named `split_on`, not `split`, because bare `split` on text
// is the stdlib character-separator split. Call as
// for piece in line.split_on("[,;]") { }.
// Example: @RGX-004 — it yields the gaps between matches, so a separator at
// either end leaves an empty piece and unmatched text comes back as ONE piece.
pub fn split_on(self: text, pattern: text) -> iterator<text> {
report(pattern);
yield from split_iter(pattern, self);
}
tests/auto_trigger.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Tier-1 trigger: the text-method form with NO `use regex;`. The parser
// auto-loads regex via its declared `[triggers] enabled` — the `matches` /
// `search` / `split_on` text-methods derived from this package's source.
fn main() {
assert("abc123".matches("[0-9]+"), "matches auto-loads regex (no use)");
assert(!"abcdef".matches("[0-9]+"), "no digits -> no match");
assert("abc123".search("[0-9]+") == 3, "search auto-loads");
out = "";
for p in "a,b,c".split_on(",") {
out = "{out}{p}.";
}
assert(out == "a.b.c.", "split_on auto-loads (got {out})");
}
tests/captures.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// @PLN192 — `capture`, `capture_all`, `group`, `named`, `replace_on`, and `split_on`
// searched from a position. Every cell hand-computes its value; the interpreter and
// `--native` must agree on all of them. The axes: the subject (ASCII, multibyte before
// and inside the match, empty, a match at byte 0 and at the end, an empty match), the
// pattern (no group, one, an optional group that did not take part, nested, alternation,
// a named group, `(?i)`, `\p{L}`, `^` and `\b` against a second match, a broken one),
// the count (0, 1, many), and `replace_on`'s limit (0, 1, more than the matches).
//
// A cell that asserts a HIT discharges the `Match?` with `?? panic(…)`, so every later
// read is on a non-null `Match`; `group` and `named` take the nullable receiver, so the
// chained spelling `line.capture(p).group(1)` is exercised as well.
use regex::*;
fn main() {
test_capture_groups();
test_capture_misses();
test_capture_all();
test_code_points();
test_named_groups();
test_replace_on();
test_split_on_keeps_the_context();
}
// --- capture: the first match with its groups ---
fn test_capture_groups() {
m = "key=value".capture("^(\\w+)=(.+)$") ?? panic("the line matches");
assert(m.text == "key=value" and m.start == 0 and m.end == 9, "the whole match is group 0: {m.text} {m.start}..{m.end}");
assert(len(m.groups) == 2, "two groups, got {len(m.groups)}");
assert(m.group(0) == "key=value", "group(0) is the whole match");
assert(m.group(1) == "key" and m.group(2) == "value", "the two groups: {m.group(1)} {m.group(2)}");
assert(m.groups[0].start == 0 and m.groups[0].end == 3, "group 1 spans 0..3");
assert(m.groups[1].start == 4 and m.groups[1].end == 9, "group 2 spans 4..9");
assert(m.group(3) == null and m.group(-1) == null, "no such group is null");
// the chained spelling: a miss answers null all the way down
assert("key=value".capture("^(\\w+)=(.+)$").group(1) == "key", "chained: the group of a hit");
assert("nothing".capture("^(\\w+)=(.+)$").group(1) == null, "chained: the group of a miss is null");
// no group at all: a Match with an empty groups vector
n = "order 1234".capture("[0-9]+") ?? panic("digits present");
assert(n.text == "1234" and n.start == 6 and n.end == 10, "no-group pattern: {n.text}");
assert(len(n.groups) == 0 and n.group(1) == null, "and no groups");
// nested groups, in pattern order
o = "2026-10-14".capture("((\\d+)-(\\d+))-(\\d+)") ?? panic("a date");
assert(o.group(1) == "2026-10" and o.group(2) == "2026" and o.group(3) == "10" and o.group(4) == "14",
"nested groups in pattern order: {o.group(1)} {o.group(2)} {o.group(3)} {o.group(4)}");
// alternation: the branch not taken is a group that did not take part
p = "cat".capture("(dog)|(cat)") ?? panic("cat");
assert(p.group(1) == null and p.group(2) == "cat", "the untaken branch is null");
assert(p.groups[0].start == -1 and p.groups[0].end == -1, "and its span is -1, -1");
// an optional group that matched nothing is null, not ""
q = "ab".capture("a(x)?b") ?? panic("ab");
assert(q.text == "ab" and q.group(1) == null, "an optional group that did not take part is null");
// a match at the end, and an empty match
r = "hello!".capture("!$") ?? panic("the bang");
assert(r.start == 5 and r.end == 6, "a match at the end: {r.start}..{r.end}");
s = "b".capture("a*") ?? panic("a* matches empty");
assert(s.text == "" and s.start == 0 and s.end == 0, "an empty match at 0");
}
fn test_capture_misses() {
assert("abc".capture("[0-9]+") == null, "no digits: null");
assert("".capture("x") == null, "an empty subject: null");
assert("abc".capture("[") == null, "a broken pattern: null (and one warning, @RGX-003)");
}
// --- capture_all: every non-overlapping match ---
fn test_capture_all() {
all = "a1 b22 c333".capture_all("([a-z])([0-9]+)");
assert(len(all) == 3, "three matches, got {len(all)}");
assert(all[0].text == "a1" and all[1].text == "b22" and all[2].text == "c333", "in order");
assert(all[2].group(2) == "333" and all[2].start == 7 and all[2].end == 11, "the last one's group and span");
none = "abc".capture_all("[0-9]");
assert(len(none) == 0, "no match: an empty vector");
one = "x9".capture_all("[0-9]");
assert(len(one) == 1 and one[0].start == 1, "one match");
// non-overlapping: `aa` on `aaa` is one match
aa = "aaa".capture_all("aa");
assert(len(aa) == 1 and aa[0].end == 2, "non-overlapping");
// an empty match advances one character
star = "baa".capture_all("a*");
assert(len(star) == 3, "a* on baa: three matches, got {len(star)}");
assert(star[0].text == "" and star[1].text == "aa" and star[2].text == "", "empty, aa, empty");
// the walk idiom (@RGX-007): a callback replace, nothing copied twice
s = "one two three";
out = "";
last = 0;
for m in s.capture_all("\\b[a-z]+\\b") {
out += s[last..m.start] + m.text.to_uppercase();
last = m.end;
}
out += s[last..];
assert(out == "ONE TWO THREE", "the walk idiom upper-cased every word: {out}");
}
// --- code points: offsets are bytes on code-point boundaries ---
fn test_code_points() {
t = "héllo wörld";
m = t.capture("w.r") ?? panic("wör");
assert(m.start == 7 and m.end == 11, "the byte span of wör is 7..11, got {m.start}..{m.end}");
assert(m.text == "wör", "and the text is the three characters");
assert(t[m.start..m.end] == m.text, "slicing the subject with the span gives the same text");
assert(size(m.text) == 4 and len(m.text) == 3, "four bytes, three characters");
e = "😀x".capture(".") ?? panic("a dot");
assert(e.start == 0 and e.end == 4 and e.text == "😀", "a dot is one code point of four bytes");
w = "café au lait".capture("\\w+") ?? panic("a word");
assert(w.text == "café", "\\w is Unicode: é is a word character");
l = "123 ünï".capture("\\p{{L}}+") ?? panic("letters");
assert(l.text == "ünï", "\\p{{L}} takes the letters");
i = "HeLLo".capture("(?i)hello") ?? panic("case-folded");
assert(i.text == "HeLLo", "(?i) folds case");
// no normalisation: a precomposed é and e + U+0301 are different texts
composed = "é";
decomposed = "e\u{0301}";
assert(len(composed) == 1 and len(decomposed) == 2, "one and two code points");
assert(composed.matches("^.$") and !decomposed.matches("^.$"), "the engine does not normalise");
// a pattern that could match inside a code point does not compile
assert("abc".capture("(?-u:\\xFF)") == null, "a byte pattern over text is refused");
}
// --- named groups ---
fn test_named_groups() {
h = "HTTP/1.1 200".capture("HTTP/(?<maj>\\d)\\.(?<min>\\d)") ?? panic("the header matches");
assert(h.named("maj") == "1" and h.named("min") == "1", "named groups: {h.named(\"maj\")}.{h.named(\"min\")}");
assert(h.group(1) == "1" and h.group(2) == "1", "and by position");
assert(h.named("patch") == null, "an unknown name is null");
assert(len(h.names) == 2 and h.names[0] == "maj" and h.names[1] == "min", "names in pattern order");
assert("nope".capture("HTTP/(?<maj>\\d)").named("maj") == null, "chained: a miss is null");
mixed = "a-b".capture("(?<first>[a-z])-([a-z])") ?? panic("a-b");
assert(mixed.names[0] == "first" and mixed.names[1] == "", "an unnamed group has the empty name");
assert(mixed.named("") == null, "and the empty name finds nothing");
plain = "ab".capture("(a)(b)") ?? panic("ab");
assert(len(plain.names) == 2 and plain.named("a") == null, "no names: two empty entries");
}
// --- replace_on ---
fn test_replace_on() {
assert("a1b22c333".replace_on("[0-9]+", "#") == "a#b#c#", "every match replaced");
assert("a1b22c333".replace_on("[0-9]+", "#", 1) == "a#b22c333", "limit 1: the first only");
assert("a1b22c333".replace_on("[0-9]+", "#", 2) == "a#b#c333", "limit 2");
assert("a1b22c333".replace_on("[0-9]+", "#", 9) == "a#b#c#", "a limit past the matches is every match");
assert("key=value".replace_on("(\\w+)=(\\w+)", "$2=$1") == "value=key", "positional groups in the template");
assert("2026-10-14".replace_on("(?<y>\\d+)-(?<m>\\d+)-(?<d>\\d+)", "${{d}}/${{m}}/${{y}}") == "14/10/2026", "named groups in the template");
assert("cost 5".replace_on("[0-9]+", "$$$0") == "cost $5", "$$ is a dollar, $0 the whole match");
assert("abc".replace_on("[0-9]", "#") == "abc", "no match: unchanged");
assert("abc".replace_on("[", "#") == "abc", "a broken pattern: unchanged");
assert("héllo".replace_on("é", "e") == "hello", "a multibyte match replaced");
assert("a b".replace_on(" ", "{{") == "a{{b", "a template is not a format string (@RGX-008)");
}
// --- split_on searched from a position: ^ and \b keep the whole subject as context ---
fn test_split_on_keeps_the_context() {
assert(pieces("abab", "^ab") == "[][ab]", "^ matches once: {pieces(\"abab\", \"^ab\")}");
assert(pieces("abab", "\\bab") == "[][ab]", "\\b is not a boundary inside abab: {pieces(\"abab\", \"\\\\bab\")}");
assert(pieces("a,b;;c", "[,;]") == "[a][b][][c]", "the gaps, the empty one included");
assert(pieces("abc", "[0-9]") == "[abc]", "no match: one piece");
assert(pieces("", ",") == "[]", "empty input: one empty piece");
assert(pieces("héllo wörld", " ") == "[héllo][wörld]", "multibyte pieces");
}
fn pieces(input: text, pattern: text) -> text {
out = "";
for p in input.split_on(pattern) { out = "{out}[{p}]"; }
out
}
tests/regex.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Regex Phase 0: matches / search / split_on, as text methods (matches also free).
use regex::*;
fn main() {
// --- matches: text method (subject.matches(pattern)) ---
assert("abc123".matches("[0-9]+"), "digits present -> match");
assert(!"abcdef".matches("[0-9]+"), "no digits -> no match");
// ...and the free form (subject-first, via `both`)
assert(regex::matches("abc123", "[0-9]+"), "free matches(input, pattern)");
// --- find: free fn (pattern-first) AND search method (subject-first) ---
assert("abc123".search("[0-9]+") == 3, "search -> offset 3");
assert("abc123".search("[0-9]+") == 3, "method search -> offset 3");
assert(!"abcdef".search("[0-9]+"), "search no match -> null");
// --- anchored pattern ---
assert("foobar".matches("^foo"), "^foo matches foobar");
assert(!"barfoo".matches("^foo"), "^foo does not match barfoo");
// --- invalid pattern fails safely (no raise) ---
assert(!"anything".matches("[unclosed"), "invalid pattern -> false");
assert(!"anything".search("("), "invalid pattern -> null");
// --- cache: many repeated calls with the same pattern stay correct ---
ok = true;
for _i in 0..200 {
if !"hello".matches("[a-z]+") {
ok = false;
}
}
assert(ok, "cached repeated calls remain correct");
// --- split: free fn (pattern-first) ---
parts = "";
count = 0;
for piece in "a,b;c".split_on("[,;]") {
parts = "{parts}{piece}|";
count = count + 1;
}
assert(count == 3, "split_on -> 3 pieces (got {count})");
assert(parts == "a|b|c|", "split_on pieces = a|b|c| (got {parts})");
// --- split: split_on method (subject-first) ---
mparts = "";
for piece in "x,y,z".split_on(",") {
mparts = "{mparts}{piece}.";
}
assert(mparts == "x.y.z.", "method split_on pieces (got {mparts})");
}
fn test_matches_method() {
assert("abc123".matches("[0-9]+"), "digits present");
assert(!"xyz".matches("[0-9]+"), "no digits");
}
fn test_search_method() {
assert("hello world".search("world") == 6, "world at offset 6");
assert(!"hello there".search("world"), "no world -> null");
}
fn test_split_on_method() {
out = "";
n = 0;
for p in "one two three".split_on(" +") {
out = "{out}{p}.";
n = n + 1;
}
assert(n == 3, "whitespace-run split -> 3 (got {n})");
assert(out == "one.two.three.", "split on ' +' (got {out})");
}
fn test_free_functions() {
// the pattern-first free forms still work
assert("abc123".search("[0-9]+") == 3, "search");
assert(regex::matches("abc123", "[0-9]+"), "free matches subject-first");
c = 0;
for _p in "a,b".split_on(",") {
c = c + 1;
}
assert(c == 2, "split_on count");
}
fn test_invalid_pattern_safe() {
assert(!"x".matches("("), "invalid -> no match");
assert(!"x".search("["), "invalid -> null find");
}
tests/worked-examples.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for the `regex` library (@PLN141). Each `test_*` below is a
// REAL, run-every-CI demonstration of a contract the signature cannot state; the
// function documents itself by CITING the tag with `// Example: @RGX-00N`, and
// `check_doc_drift.sh examples` fails if a citation goes dangling (no test carries
// the tag). These are teaching examples, distinct from the per-function
// assertion suite in `regex.loft`.
//
// Every entry point here is `text` in and `integer`/`boolean` out, so the type
// checker can see none of what follows: which spelling actually reaches this
// library, which UNIT the offset is in, and that a broken pattern answers the
// same thing as a pattern that simply did not match.
use regex::*;
// @RGX-001 — the spelling decides which library you called, and the wrong one
// still compiles. `find` and `split` are stdlib `text` METHODS (literal
// substring search, character split), and loft resolves a method spelling before
// a library's free function — so a bare `find(pattern, input)` does not reach
// this library at all. It type-checks, because the stdlib's `find` takes the
// same two texts, and it answers by looking for the literal text `"[0-9]+"`
// inside `"abc123"` — the arguments read the other way round — so it reports "not
// found" for a pattern that plainly matches. That is why this library's
// verbs are `search` / `split_on`, called subject-first on the text, and why it
// has no free `find` / `split`: one would be reachable only through the qualifier.
fn test_the_spelling_decides_which_library_you_called() {
// The recommended form: subject-first, on the text.
assert("abc123".search("[0-9]+") == 3, "search finds the digits at byte 3");
// A bare `find` compiles and is NOT this library — the stdlib method wins, and
// it reads its arguments the other way round (needle in haystack).
bare = find("[0-9]+", "abc123");
assert(bare == null,
"bare find() looked for the literal \"[0-9]+\" INSIDE \"abc123\" and found nothing");
assert(bare != "abc123".search("[0-9]+"),
"so the two spellings answer differently on the same arguments");
// `matches` has no stdlib namesake, so its bare form is safe.
assert(matches("abc123", "[0-9]+"), "matches is collision-free and works bare");
assert("abc123".matches("[0-9]+"), "and as a method");
}
// @RGX-002 — `search` answers a BYTE offset, and a miss is null. The offset is
// right for slicing, because loft's text slicing is byte-indexed too — but `len()`
// counts CHARACTERS, so comparing an offset against `len()` is a units error that
// only shows up on non-ASCII input, silently and late. `size()` is the byte count
// and the one to compare against. The miss is typed: the return is `integer?`, so
// an answer cannot index or slice until it is discharged (`?? -1`, `?`, a null
// test) — the compiler is what keeps a null offset from counting from the END of
// the text as a negative index would.
fn test_the_offset_is_bytes_and_the_miss_is_null() {
t = "héllo wörld";
assert(len(t) == 11, "eleven characters");
assert(size(t) == 13, "thirteen bytes — two of them are two-byte characters");
at = t.search("wörld") ?? -1;
assert(at == 7, "the answer is the BYTE offset, not the character index 6");
assert(at < len(t), "which happens to be true here...");
assert(at < size(t), "...but size() is the bound that is true in general");
// The offset is exactly what byte-indexed slicing wants.
assert(t[0..at] == "héllo ", "slicing with it lands on a character boundary");
// A miss is null, and the type says so: `integer?`, which the compiler makes a
// caller discharge before it can index with the answer.
miss = t.search("[0-9]+");
assert(miss == null, "no digits — the answer is null");
assert(!miss, "so `if !offset` is a valid guard");
assert((miss ?? -1) < 0, "and so is a `< 0` test after a discharge");
}
// @RGX-003 — a broken pattern is not an error, it is a miss. An invalid regex
// (an unclosed class, a stray quantifier) does not raise and does not report
// itself: `search` answers the same null it answers for text that simply does not
// contain the pattern, and `matches` answers the same false. So a typo in a
// pattern — especially one built by interpolation from configuration or user
// input — presents as "nothing matched today", which is indistinguishable from a
// quiet input and survives every test written against matching input. If a
// pattern comes from outside the program, prove it matches something known before
// trusting a negative answer from it.
fn test_a_broken_pattern_reports_a_miss_not_an_error() {
good_miss = "abc".search("[0-9]+"); // valid pattern, no digits present
broken = "abc".search("["); // unclosed character class
assert(good_miss == null, "a genuine miss is null");
assert(broken == null, "and so is a pattern that never compiled");
assert(broken == good_miss, "the caller cannot tell the two apart");
assert(!"abc".matches("[0-9]+"), "the boolean door: a genuine miss is false");
assert(!"abc".matches("["), "a broken pattern is false as well");
// The canary: check the pattern against input it MUST match.
suspect = "[0-9]+";
assert("0".matches(suspect), "a pattern proved against a known sample is one you can trust");
}
// @RGX-004 — `split_on` yields pieces, not fields, and it never yields none. It
// is a lazy `iterator<text>` over the gaps BETWEEN matches, so a separator at
// either end produces an empty piece rather than being trimmed, and text with no
// match at all comes back as a single piece — the whole input. That last one is
// the case a caller usually gets wrong: a split that "failed" still gives you one
// element, so testing the piece count for zero never fires, and code that assumes
// two pieces reads the whole line as its first field.
fn test_split_yields_the_gaps_including_the_empty_ones() {
assert(wex_pieces("a,b,c") == "[a][b][c]", "the ordinary case: three pieces");
assert(wex_pieces("a,b,") == "[a][b][]", "a TRAILING separator leaves an empty piece");
assert(wex_pieces(",a") == "[][a]", "and a leading one leaves an empty piece first");
assert(wex_pieces("abc") == "[abc]", "no match at all is ONE piece — the whole input");
assert(wex_pieces("") == "[]", "even empty input yields one (empty) piece");
// So a count is never 0, and "did it split?" is a count > 1 question.
assert(wex_count("abc") == 1, "an unsplit line still has a piece");
assert(wex_count("a,b") == 2, "a split line has more than one");
}
// Render a split as `[piece][piece]…` so the expected value can be written out in
// full rather than asserted one index at a time.
fn wex_pieces(input: text) -> text {
out = "";
for p in input.split_on(",") { out = "{out}[{p}]"; }
out
}
// How many pieces a split yields.
fn wex_count(input: text) -> integer {
n = 0;
for _ in input.split_on(",") { n += 1; }
n
}
// @RGX-005 — a group that did not take part is null, not "". An optional group
// that matched nothing, or the branch of an alternation that was not taken, has
// no text and no span: `group(i)` answers null and the `Group`'s `start` / `end`
// are -1. A group that matched the EMPTY text is the other case — `""` with a
// real span — and the two are told apart only because the first is null.
fn test_a_group_that_did_not_take_part_is_null() {
m = "ab".capture("a(x)?b") ?? panic("ab matches");
assert(m.group(1) == null, "the optional group matched nothing: null");
assert(m.groups[0].start == -1 and m.groups[0].end == -1, "and has no span");
e = "ab".capture("a(x*)b") ?? panic("ab matches");
assert(e.group(1) == "", "a group that matched the empty text is \"\"…");
assert(e.groups[0].start == 1 and e.groups[0].end == 1, "…with a real, empty span");
alt = "cat".capture("(dog)|(cat)") ?? panic("cat matches");
assert(alt.group(1) == null and alt.group(2) == "cat", "the untaken branch is null");
}
// @RGX-006 — every offset is a BYTE on a code-point boundary. The engine matches
// by character, so `start` and `end` never fall inside a multi-byte character, and
// slicing the subject with them gives exactly `text`. `len` of the match counts
// its characters, `size` its bytes — the same split `@RGX-002` describes for
// `search`, now with both ends of the span in hand.
fn test_offsets_are_bytes_on_code_point_boundaries() {
t = "héllo wörld";
m = t.capture("w.r") ?? panic("wör matches");
assert(m.start == 7 and m.end == 11, "bytes 7..11, not characters 6..9");
assert(t[m.start..m.end] == m.text, "the span slices the subject to the match");
assert(len(m.text) == 3 and size(m.text) == 4, "three characters, four bytes");
d = "😀x".capture(".") ?? panic("a dot matches");
assert(d.end == 4 and d.text == "😀", "one code point, four bytes");
}
// @RGX-007 — a callback replace is a WALK. There is no `replace_on` with a
// function (a closure cannot cross a placed library), and none is needed: walk
// `capture_all`, copy the gaps from the subject's own spans, and build the result.
// Four lines, and nothing is copied twice — the gaps are slices, the match is a view.
fn test_the_walk_idiom_replaces_a_callback() {
s = "one two three";
out = "";
last = 0;
for m in s.capture_all("\\b[a-z]+\\b") {
out += s[last..m.start] + m.text.to_uppercase();
last = m.end;
}
out += s[last..];
assert(out == "ONE TWO THREE", "every word upper-cased: {out}");
}
// @RGX-008 — a replacement template is NOT a format string. `$1` and `${name}`
// stand for a group, `$0` for the whole match and `$$` for a dollar; a `{` in it is
// a literal brace (in loft source it is written `{{`, because the SOURCE literal is
// a format string, and the template the engine sees holds one brace).
fn test_a_template_is_not_a_format_string() {
assert("key=value".replace_on("(\\w+)=(\\w+)", "$2=$1") == "value=key", "groups by number");
assert("a b".replace_on(" ", "{{") == "a{{b", "a brace is a brace");
assert("cost 5".replace_on("[0-9]+", "$$$0") == "cost $5", "$$ is a dollar");
}