regex: fold 5a — capture groups (compile ( ) arms + run_thread groupstart/groupend + search spread)

compile: '(' (ha:317-323) appends inst_groupstart(capture_idx) and
grows jump_idxs per level; ')' (ha:324-334) appends the void
inst_groupend, fixes up the level's pending alternation jumps (#70
range + #58 assert), range-deletes them (#8) and closes the level;
the loop-exit done arm gains the Unmatched-'(' check (ha:277-282).
The anchors' group_level arms, the postfix inst_groupend/groupstart
arms and find_last_groupstart's success path go live unchanged.

run_thread: inst_groupstart (ha:636-652) fill-grows captures to
idx+1 (count-loop spelling of Hare's 3-arg fill-append) and opens
the group with the SIZE_MAX end sentinel via the #20-fixed indexed
struct store; inst_groupend (ha:653-668) closes the innermost
unclosed capture (ha:655's 2-clause for respelled — ww has no
cond;post form) and slices content from the bytesize span through
the addr-of-element pointer.

search: the ha:820 loud bound flips to the real capture spread
(#35/#25); the pad fill self-activates for unset trailing groups.

Tests: ( ) graduate from the metachar-loud table into real-text
error rows (+ ww-added anchor-in-group / Unused-on-groupstart
re-verify rows); hand-built groupstart/groupend arm cases; the 5a
find table (+test.ha:257-275 group/alternation, :499-503/:607 jump
bugs, :610-621 submatch family, :635/:640 alternation-group,
:649-665 nested minus the 5b {m,n} twins, + ww-added multibyte
row) with len(res) pinned per row; submatch content rows
(+test.ha:704-708 + ww-added multibyte) — the 5a acceptance gate.
This commit is contained in:
2026-06-05 00:46:04 +09:00
parent 0e61db1857
commit a2c2bbc6b1
2 changed files with 526 additions and 68 deletions

View File

@@ -35,6 +35,7 @@ import io;
import memio;
import os;
import strings;
import types;
let signalled: i32 = 0;
fn fail() void = { os.exit(signalled + 10); };
@@ -262,23 +263,18 @@ fn fail() void = { os.exit(signalled + 10); };
// mean an arm was half-ported. One pattern per deferred arm so the
// fold that ports an arm consciously deletes its row. Fold 3 flipped
// \ ^ $ | ? * + positive (fold3_* below); fold 4 flipped `[`
// (fold4_* below); the group/repetition three remain.
// (fold4_* below); fold 5a flipped `(`/`)` (fold5_* below — "a("/"a)"
// graduated into fold5_compile_errors with their REAL texts);
// repetition `{` remains.
@test fn compile_metachar_loud() void = {
let pats: [3]str = [
"a(", "a)", "a{",
];
let i: i32 = 0;
for (i < len(pats)) {
match (regex.compile(pats[i])) {
case let e: regex.error => {
if (strings.compare((e: str),
"regex: metacharacter not yet ported") != 0) {
fail();
};
match (regex.compile("a{")) {
case let e: regex.error => {
if (strings.compare((e: str),
"regex: metacharacter not yet ported") != 0) {
fail();
};
case => fail();
};
i += 1;
};
case => fail();
};
};
@@ -422,7 +418,7 @@ type nmexp = struct {
match (regex.compile("a(")) {
case let e: regex.error => {
if (strings.compare(regex.strerror(e),
"regex: metacharacter not yet ported") != 0) {
"Unmatched '('") != 0) {
fail();
};
};
@@ -1760,6 +1756,387 @@ type cscase = struct {
};
};
// ---- fold 5a: capture groups ------------------------------------------
// The fold-5a compile-error surface, exact texts. ")" and "(" are
// Hare's own ERROR fixtures (+test.ha:276/606); "a("/"a)" graduate
// here from the metachar-loud table with their real texts. The
// anchor-in-group and groupstart-postfix rows are ww-added (no Hare
// table row carries these texts): they pin that the fold-3
// verbatim-dead arms (regex.ha:295-297/302-304 anchors,
// 413-414/434-435/453-454 Unused-on-groupstart) FIRE now that `(`
// emits inst_groupstart — the §9a A0 re-verify, as live rows.
@test fn fold5_compile_errors() void = {
let rows: [9]cerow = [
cerow { pat = ")", want = "Unmatched ')'" },
cerow { pat = "(", want = "Unmatched '('" },
cerow { pat = "a)", want = "Unmatched ')'" },
cerow { pat = "a(", want = "Unmatched '('" },
// ww-added: anchor-in-group arms (regex.ha:295-297/302-304)
cerow { pat = "(^a)",
want = "Anchor '^' in capture groups is unsupported" },
cerow { pat = "(a$)",
want = "Anchor '$' in capture groups is unsupported" },
// ww-added: postfix on a bare groupstart
// (regex.ha:413-414/434-435/453-454)
cerow { pat = "(?", want = "Unused '?'" },
cerow { pat = "(*", want = "Unused '*'" },
cerow { pat = "(+", want = "Unused '+'" },
];
let i: i32 = 0;
for (i < len(rows)) {
let p: str = rows[i].pat;
match (regex.compile(p)) {
case let e: regex.error => {
let w: str = rows[i].want;
if (strings.compare((e: str), w) != 0) { fail(); };
};
case => fail();
};
i += 1;
};
};
// run_thread inst_groupstart / inst_groupend driven over HAND-BUILT
// programs (the anchored-route precedent): compile() composition is
// pinned by the find/submatch tables below; these pin the arm
// mechanics — fill-grow to idx+1, the SIZE_MAX open sentinel,
// innermost-unclosed close order, content from the bytesize span,
// and the closed-group re-entry overwrite (the ha:642 assert's
// PASSING direction).
@test fn run_thread_group_arms() void = {
// groupstart (ha:636-652): grows captures to idx+1 (zero-filled
// below idx), stamps start/start_bytesize, opens with
// end = end_bytesize = SIZE_MAX
let insts: []regex.inst = [];
append(insts, ((1: size): regex.inst_groupstart));
append(insts, ('a': regex.inst_lit));
append(insts, (false: regex.inst_match));
let re: regex.regex;
re.insts = insts;
re.n_reps = 0;
let ra: (rune | io.eof) = 'a';
let ts: []thread = [];
append(ts, thread { pc = 0, ... });
let r1: (void | newmatch | nomem) = run_thread(0, &re, "ab", &ts, ra, 2, 3);
if (!(r1 is void)) { fail(); };
if (ts[0].captures.len != 2) { fail(); };
// the fill element below idx is zeroed
if (ts[0].captures[0].end != (0: size)) { fail(); };
if (ts[0].captures[0].content.len != 0) { fail(); };
// the opened group: start stamped, end still the open sentinel
if (ts[0].captures[1].start != (2: size)) { fail(); };
if (ts[0].captures[1].start_bytesize != (3: size)) { fail(); };
if (ts[0].captures[1].end != types.SIZE_MAX) { fail(); };
if (ts[0].captures[1].end_bytesize != types.SIZE_MAX) { fail(); };
// groupstart is non-consuming: pc stepped through it, then the
// lit consumed
if (ts[0].pc != (2: size)) { fail(); };
// groupend (ha:653-668): two open groups — the INNERMOST
// (highest index) closes first; back-to-back groupends close
// inner then outer in ONE call; content = str_bytes[
// start_bytesize:end_bytesize]
let insts2: []regex.inst = [];
let gv: regex.inst_groupend;
let ge: regex.inst = gv;
append(insts2, ge);
append(insts2, ge);
append(insts2, ('x': regex.inst_lit));
append(insts2, (false: regex.inst_match));
let re2: regex.regex;
re2.insts = insts2;
re2.n_reps = 0;
let caps: []capture = [];
append(caps, capture { content = "", start = 1, start_bytesize = 1,
end = types.SIZE_MAX, end_bytesize = types.SIZE_MAX });
append(caps, capture { content = "", start = 2, start_bytesize = 2,
end = types.SIZE_MAX, end_bytesize = types.SIZE_MAX });
let ts2: []thread = [];
append(ts2, thread { pc = 0, captures = caps, ... });
let rx: (rune | io.eof) = 'x';
let r2: (void | newmatch | nomem) = run_thread(0, &re2, "abcd", &ts2, rx, 3, 4);
if (!(r2 is void)) { fail(); };
if (ts2[0].captures[1].end != (3: size)) { fail(); };
if (ts2[0].captures[1].end_bytesize != (4: size)) { fail(); };
if (strings.compare(ts2[0].captures[1].content, "cd") != 0) { fail(); };
if (ts2[0].captures[0].end != (3: size)) { fail(); };
if (ts2[0].captures[0].end_bytesize != (4: size)) { fail(); };
if (strings.compare(ts2[0].captures[0].content, "bcd") != 0) { fail(); };
if (ts2[0].pc != (3: size)) { fail(); };
// closed-group re-entry: groupstart over an already-CLOSED idx
// passes the ha:642 assert (end != SIZE_MAX) and re-opens fresh
let insts3: []regex.inst = [];
append(insts3, ((0: size): regex.inst_groupstart));
append(insts3, ('a': regex.inst_lit));
append(insts3, (false: regex.inst_match));
let re3: regex.regex;
re3.insts = insts3;
re3.n_reps = 0;
let caps3: []capture = [];
append(caps3, capture { content = "ab", start = 0, start_bytesize = 0,
end = 2, end_bytesize = 2 });
let ts3: []thread = [];
append(ts3, thread { pc = 0, captures = caps3, ... });
let r3: (void | newmatch | nomem) = run_thread(0, &re3, "aba", &ts3, ra, 2, 2);
if (!(r3 is void)) { fail(); };
if (ts3[0].captures.len != 1) { fail(); };
if (ts3[0].captures[0].start != (2: size)) { fail(); };
if (ts3[0].captures[0].end != types.SIZE_MAX) { fail(); };
if (ts3[0].captures[0].content.len != 0) { fail(); };
};
// fold-5a find/test rows — the group rows of Hare's own table:
// group/alternation +test.ha:257-275, the jump-bug group rows
// :499-503 minus the `{,1}` form (5b) plus `(x?)?` :607, the
// POSIX-comment submatch-handling family :610-621 (MATCH-only root
// assertions, as Hare; the literally-duplicated (a*) pairs :614-617
// appear once), `a|(b)` :635, `(a|b|c|d|e)f` :640, and the
// nested-group block :649-665 minus the `{0,}`/`{1,}`/`{0,1}` twins
// (5b) and Hare's own TODO-disabled anchor-in-group rows. end == -1
// resolves to rune length (+test.ha:693-697). ncaps additionally pins
// len(res) = last-groupstart payload + 2 (the search length scan +
// pad fill — unset groups pad to zeroed captures, so `a|(b)` over "a"
// still yields 2). The multibyte row is ww-added (B4: every idx
// splits from its bytesize). `(a+|b)*` over "ab" is rob's
// thread-explosion stress row (group split × star respawn ×
// dedup/trim/leftmost pins). Cross-pins test() == (find() matched).
type f5case = struct {
expr: str,
input: str,
matches: bool,
ncaps: i32,
start: size,
sb: size,
end: size,
eb: size,
content: str,
};
@test fn fold5_find_cases() void = {
let rows: [32]f5case = [
// group/alternation (+test.ha:257-275)
f5case { expr = "^(cafe|b)x$", input = "cafex", matches = true,
ncaps = 2, start = 0, sb = 0, end = 5, eb = 5,
content = "cafex" },
f5case { expr = "^(cafe|b)x$", input = "bx", matches = true,
ncaps = 2, start = 0, sb = 0, end = 2, eb = 2,
content = "bx" },
f5case { expr = "^(cafe|b)x$", input = "XXXx",
matches = false, ... },
f5case { expr = "^(Privat|Jagd)(haftpflicht|schaden)versicherungs(police|betrag)$",
input = "Jagdhaftpflichtversicherungsbetrag",
matches = true, ncaps = 4, start = 0, sb = 0,
end = 34, eb = 34,
content = "Jagdhaftpflichtversicherungsbetrag" },
f5case { expr = "^(Privat|Jagd)(haftpflicht|schaden)versicherungs(police|betrag)$",
input = "Jagdhaftpflichtversicherungsbetrug",
matches = false, ... },
f5case { expr = "^(Privat|Jagd)(haftpflicht|schaden)versicherungs(police|betrag)$",
input = "Jagdversicherungspolice",
matches = false, ... },
// jump bugs (+test.ha:499-503 minus `{,1}`; :607)
f5case { expr = "ab?c", input = "ac", matches = true,
ncaps = 1, start = 0, sb = 0, end = 2, eb = 2,
content = "ac" },
f5case { expr = "ab?c|z", input = "ac", matches = true,
ncaps = 1, start = 0, sb = 0, end = 2, eb = 2,
content = "ac" },
f5case { expr = "(ab?c)?", input = "ac", matches = true,
ncaps = 2, start = 0, sb = 0, end = 2, eb = 2,
content = "ac" },
f5case { expr = "(ab?c)*", input = "ac", matches = true,
ncaps = 2, start = 0, sb = 0, end = 2, eb = 2,
content = "ac" },
f5case { expr = "(x?)?", input = "x", matches = true,
ncaps = 2, start = 0, sb = 0, end = 1, eb = 1,
content = "x" },
// submatch handling (+test.ha:610-621) — MATCH-only root
// assertions, as Hare (POSIX submatch semantics diverge)
f5case { expr = "(a|ab)(c|bcd)(d*)", input = "abcd",
matches = true, ncaps = 4, start = 0, sb = 0,
end = 4, eb = 4, content = "abcd" },
f5case { expr = "(a|ab)(bcd|c)(d*)", input = "abcd",
matches = true, ncaps = 4, start = 0, sb = 0,
end = 4, eb = 4, content = "abcd" },
f5case { expr = "(ab|a)(c|bcd)(d*)", input = "abcd",
matches = true, ncaps = 4, start = 0, sb = 0,
end = 4, eb = 4, content = "abcd" },
f5case { expr = "(ab|a)(bcd|c)(d*)", input = "abcd",
matches = true, ncaps = 4, start = 0, sb = 0,
end = 4, eb = 4, content = "abcd" },
f5case { expr = "(a*)(b|abc)(c*)", input = "abc",
matches = true, ncaps = 4, start = 0, sb = 0,
end = 3, eb = 3, content = "abc" },
f5case { expr = "(a*)(abc|b)(c*)", input = "abc",
matches = true, ncaps = 4, start = 0, sb = 0,
end = 3, eb = 3, content = "abc" },
f5case { expr = "(a|ab)(c|bcd)(d|.*)", input = "abcd",
matches = true, ncaps = 4, start = 0, sb = 0,
end = 4, eb = 4, content = "abcd" },
f5case { expr = "(a|ab)(bcd|c)(d|.*)", input = "abcd",
matches = true, ncaps = 4, start = 0, sb = 0,
end = 4, eb = 4, content = "abcd" },
f5case { expr = "(ab|a)(c|bcd)(d|.*)", input = "abcd",
matches = true, ncaps = 4, start = 0, sb = 0,
end = 4, eb = 4, content = "abcd" },
f5case { expr = "(ab|a)(bcd|c)(d|.*)", input = "abcd",
matches = true, ncaps = 4, start = 0, sb = 0,
end = 4, eb = 4, content = "abcd" },
// alternation with a group (+test.ha:635) — the (b) branch
// never runs on "a"; the pad fill still yields 2 captures
f5case { expr = "a|(b)", input = "a", matches = true,
ncaps = 2, start = 0, sb = 0, end = 1, eb = 1,
content = "a" },
// multiple alternation inside a group (+test.ha:640)
f5case { expr = "(a|b|c|d|e)f", input = "ef", matches = true,
ncaps = 2, start = 0, sb = 0, end = 2, eb = 2,
content = "ef" },
// nested groups (+test.ha:649-665 minus the `{…}` twins)
f5case { expr = "((a))", input = "abc", matches = true,
ncaps = 3, start = 0, sb = 0, end = 1, eb = 1,
content = "a" },
f5case { expr = "((a)(b)c)(d)", input = "abcd", matches = true,
ncaps = 5, start = 0, sb = 0, end = 4, eb = 4,
content = "abcd" },
f5case { expr = "((((((((((a))))))))))", input = "a",
matches = true, ncaps = 11, start = 0, sb = 0,
end = 1, eb = 1, content = "a" },
f5case { expr = "(((((((((a)))))))))", input = "a",
matches = true, ncaps = 10, start = 0, sb = 0,
end = 1, eb = 1, content = "a" },
f5case { expr = "(([a-z]+):)?([a-z]+)$", input = "smil",
matches = true, ncaps = 4, start = 0, sb = 0,
end = 4, eb = 4, content = "smil" },
f5case { expr = "^((a)c)?(ab)$", input = "ab", matches = true,
ncaps = 4, start = 0, sb = 0, end = 2, eb = 2,
content = "ab" },
f5case { expr = "(a+|b)*", input = "ab", matches = true,
ncaps = 2, start = 0, sb = 0, end = 2, eb = 2,
content = "ab" },
f5case { expr = "(a+|b)+", input = "ab", matches = true,
ncaps = 2, start = 0, sb = 0, end = 2, eb = 2,
content = "ab" },
// ww-added multibyte group row (B4)
f5case { expr = "(ä|b)x", input = "äx", matches = true,
ncaps = 2, start = 0, sb = 0, end = 2, eb = 3,
content = "äx" },
];
let i: i32 = 0;
for (i < len(rows)) {
let ex: str = rows[i].expr;
let inp: str = rows[i].input;
let c: (regex.regex | regex.error | nomem) = regex.compile(ex);
match (c) {
case let re: regex.regex => {
let fr: (regex.result | nomem) = regex.find(&re, inp);
if (!(fr is regex.result)) { fail(); };
let res: regex.result = fr as regex.result;
if (rows[i].matches) {
if ((len(res): i32) != rows[i].ncaps) { fail(); };
if (res[0].start != rows[i].start) { fail(); };
if (res[0].start_bytesize != rows[i].sb) {
fail();
};
if (res[0].end != rows[i].end) { fail(); };
if (res[0].end_bytesize != rows[i].eb) {
fail();
};
let wc: str = rows[i].content;
if (strings.compare(res[0].content, wc) != 0) {
fail();
};
} else {
if (len(res) != 0) { fail(); };
};
let tr: (bool | nomem) = regex.test(&re, inp);
if (!(tr is bool)) { fail(); };
if ((tr as bool) != (len(res) != 0)) { fail(); };
regex.result_free(res);
regex.finish(&re);
};
case => fail();
};
i += 1;
};
};
// `(a+|b)?` over "ab" (+test.ha:665) matches (0,1) "a" — the
// non-greedy END pins that the optional group stops at the leftmost-
// longest SINGLE term, not the star/plus whole-string sweep above.
@test fn fold5_optional_group() void = {
let c: (regex.regex | regex.error | nomem) = regex.compile("(a+|b)?");
match (c) {
case let re: regex.regex => {
let fr: (regex.result | nomem) = regex.find(&re, "ab");
if (!(fr is regex.result)) { fail(); };
let res: regex.result = fr as regex.result;
if (len(res) != 2) { fail(); };
if (res[0].start != (0: size)) { fail(); };
if (res[0].end != (1: size)) { fail(); };
if (strings.compare(res[0].content, "a") != 0) { fail(); };
regex.result_free(res);
regex.finish(&re);
};
case => fail();
};
};
// submatch rows (+test.ha:704-708 via run_submatch_case :72-100) —
// THE 5a acceptance gate: per-group .content equality through find(),
// root first. The second row is ww-added (B4): a 2-byte ß inside both
// captured groups splits every byte offset the contents are sliced
// from. Targets live in a flat pool indexed by (toff, tcnt), the
// findall_content shape.
type smcase = struct {
expr: str,
input: str,
toff: i32,
tcnt: i32,
};
@test fn fold5_submatches() void = {
let targets: [6]str = [
"aaa bbb ccc", "bbb", "ccc",
"aaa bßb cßc", "bßb", "cßc",
];
let rows: [2]smcase = [
smcase { expr = "aaa ([^ ]*) (...)", input = "aaa bbb ccc",
toff = 0, tcnt = 3 },
// ww-added multibyte variant
smcase { expr = "aaa ([^ ]*) (...)", input = "aaa bßb cßc",
toff = 3, tcnt = 3 },
];
let i: i32 = 0;
for (i < len(rows)) {
let ex: str = rows[i].expr;
let inp: str = rows[i].input;
let c: (regex.regex | regex.error | nomem) = regex.compile(ex);
match (c) {
case let re: regex.regex => {
let fr: (regex.result | nomem) = regex.find(&re, inp);
if (!(fr is regex.result)) { fail(); };
let res: regex.result = fr as regex.result;
if ((len(res): i32) != rows[i].tcnt) { fail(); };
let k: i32 = 0;
for (k < rows[i].tcnt) {
let want: str = targets[rows[i].toff + k];
if (strings.compare(res[k].content,
want) != 0) {
fail();
};
k += 1;
};
regex.result_free(res);
regex.finish(&re);
};
case => fail();
};
i += 1;
};
};
export fn main() i32 = {
signalled = 1; lit_and_match();
signalled = 2; size_aliases_distinct();
@@ -1798,5 +2175,10 @@ export fn main() i32 = {
signalled = 35; fold4_find_cases();
signalled = 36; fold4_findall();
signalled = 37; add_thread_dup_independence();
signalled = 38; fold5_compile_errors();
signalled = 39; run_thread_group_arms();
signalled = 40; fold5_find_cases();
signalled = 41; fold5_optional_group();
signalled = 42; fold5_submatches();
return 0;
};