regex source

← 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.

ItemDefinedWorked exampleCall sites
Group struct97—110 152 158 160
Match struct106—118 128 149 166 199 208 … (7 total)
group fn118—30 32 33 34 35 36 … (20 total)
named fn128—143 145 147 151 154 165
search fn179RGX-0029 14 15 16 24 59 … (15 total)
matches fn189RGX-0037 8 29 64 78 94 … (31 total)
capture fn199RGX-00629 39 40 43 48 53 … (27 total)
capture_all fn208RGX-00777 82 85 89 93 101 … (7 total)
replace_on fn230RGX-008160 161 162 163 164 165 … (14 total)
split_on fn265RGX-00411 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");
}