regex: fold 4 — bracket expressions (handle_bracket + run_thread charset arm)

Port of ref/hare/regex/regex.ha:135-225 (handle_bracket, whole),
265-275 (in_bracket dispatch), 313-314 (the `[` flip), 249-252 (the
bracket state quad) and 704-737 (the consuming charset arm). `[`
graduates from the fold-2a loud set; `(` `)` `{` are the last
three loud metachars. The POSIX-class arm keeps its DETECTION
verbatim but loud-aborts its BODY (charclass_map stays #25-blocked;
falling through to the literal arm would silently compile
[[:alpha:]] as a 9-literal charset). is_consuming_inst already
covered inst_charset.

Spelling divergences, all site-documented: the dispatch propagates
via the explicit D13 match, not `?` (compile's 64B sret return is
the #38b loud-stop; the fold-3 find_last_groupstart precedent);
charset's declaration moves BELOW its member types (cstage sizes a
tagged alias with forward-declared members at a degenerate 8B —
ww-core #69, wwstage is correct); run_thread binds the charset
structurally, not via the alias (alias-typed slice locals mis-scale
their index reads in wwstage — ww-core #68).

Tests: Hare's own bracket block (+test.ha:278-345, the group and
POSIX rows excluded with their loud arms) as the 72-row find/test
table incl. multibyte literal+range brackets and an unanchored
[ab]+ composition row; charsets-table content pins (lit/range
discrimination, first-char ]/[ literals, literal dashes, multibyte
codepoints); program-shape pins ([abc] / ^[abc]$ / [^ab] /
[ab][cd] / [abc]*); exact-text error rows (Unmatched '[' ×3 incl
the escape interaction, descending [z-a]); findall composition.
The [[:alpha:]] abort text is unpinnable in-process (it kills the
runner) — source-audited until the POSIX fold.
This commit is contained in:
2026-06-04 20:38:42 +09:00
parent 0055ac2cd3
commit 95fea97868
2 changed files with 576 additions and 36 deletions

View File

@@ -261,11 +261,11 @@ fn fail() void = { os.exit(signalled + 10); };
// silently compile a wrong program, and any OTHER error text would
// 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); the bracket/group/
// repetition four remain.
// \ ^ $ | ? * + positive (fold3_* below); fold 4 flipped `[`
// (fold4_* below); the group/repetition three remain.
@test fn compile_metachar_loud() void = {
let pats: [4]str = [
"a[", "a(", "a)", "a{",
let pats: [3]str = [
"a(", "a)", "a{",
];
let i: i32 = 0;
for (i < len(pats)) {
@@ -419,7 +419,7 @@ type nmexp = struct {
// through a REAL compile() error, completing the exported error
// surface end to end.
@test fn strerror_identity() void = {
match (regex.compile("a[")) {
match (regex.compile("a(")) {
case let e: regex.error => {
if (strings.compare(regex.strerror(e),
"regex: metacharacter not yet ported") != 0) {
@@ -1078,6 +1078,11 @@ fn instsig(v: regex.inst) i64 = {
};
case let g: regex.inst_groupstart => return 7000 + ((g: size): i64);
case regex.inst_groupend => return 8000;
case let c: regex.inst_charset => {
// fold 4: 10xxx positive / 11xxx negated, + charset index
if (c.is_positive) { return 10000 + (c.idx: i64); };
return 11000 + (c.idx: i64);
};
case => return 9999;
};
};
@@ -1351,6 +1356,357 @@ type cerow = struct {
};
};
// ---- fold 4: bracket expressions --------------------------------------
// Emitted-program pins for the `[..]` arm: the charset inst lands where
// a literal would (and composes with fold-3's postfix/anchors), the
// charsets table grows one entry per bracket, negation rides
// is_positive. Derived by hand-executing regex.ha:265-275 + 313-314 +
// handle_bracket.
@test fn fold4_programs() void = {
let sigs: [17]i64 = [
// "[abc]": skip, charset 0 positive, match(false)
2000, 10000, 6000,
// "^[abc]$": anchored both ends — charset, match(TRUE)
10000, 6001,
// "[^ab]": skip, charset 0 NEGATED, match(false)
2000, 11000, 6000,
// "[ab][cd]": two brackets — charset 0, charset 1
2000, 10000, 10001, 6000,
// "[abc]*": the fold-3 a* shape with charset as the term
2000, 4004, 10000, 5001, 6000,
];
let rows: [5]pgmcase = [
pgmcase { expr = "[abc]", soff = 0, scnt = 3 },
pgmcase { expr = "^[abc]$", soff = 3, scnt = 2 },
pgmcase { expr = "[^ab]", soff = 5, scnt = 3 },
pgmcase { expr = "[ab][cd]", soff = 8, scnt = 4 },
pgmcase { expr = "[abc]*", soff = 12, scnt = 5 },
];
let i: i32 = 0;
for (i < len(rows)) {
let ex: str = rows[i].expr;
let c: (regex.regex | regex.error | nomem) = regex.compile(ex);
match (c) {
case let re: regex.regex => {
if (re.insts.len != rows[i].scnt) { fail(); };
let k: i32 = 0;
for (k < rows[i].scnt) {
if (instsig(re.insts[k])
!= sigs[rows[i].soff + k]) {
fail();
};
k += 1;
};
regex.finish(&re);
};
case => fail();
};
i += 1;
};
};
// cssig — flatten a charset element: lits as 1_000_000 + codepoint,
// ranges as start*10000 + end (codepoints stay < 10000 in these rows),
// class items can't exist (compile()'s POSIX arm is loud).
// The element binds structurally, not via the `charset` alias — an
// alias-typed slice local's index read mis-scales in wwstage (#68).
fn cssig(cs: [](charset_lit_item | charset_range_item |
charset_class_item), k: size) i64 = {
let cur: (charset_lit_item | charset_range_item |
charset_class_item) = cs[k];
match (cur) {
case let l: charset_lit_item =>
return 1000000 + ((l: rune): i64);
case let range: charset_range_item =>
return (range.0: i64) * 10000 + (range.1: i64);
case => return -1;
};
};
type cscase = struct {
expr: str,
eoff: i32,
ecnt: i32,
};
// charsets-table content pins: literal vs range element discrimination,
// the first-char `]`/`[` literal rules, literal dashes, and multibyte
// codepoints in both element kinds (regex.ha:172-221 state machine).
@test fn fold4_charsets() void = {
let exp: [15]i64 = [
// "[abc]"
1000097, 1000098, 1000099,
// "[]ab]" — first-char ] is a literal
1000093, 1000097, 1000098,
// "[[ab]" — [ inside a bracket is a literal
1000091, 1000097, 1000098,
// "[a-c]"
970099,
// "[-a-c]" — leading literal dash
1000045, 970099,
// "[a-c-]" — trailing literal dash
970099, 1000045,
// "[ä-ö]" — multibyte range, codepoints 228..246
2280246,
];
let rows: [7]cscase = [
cscase { expr = "[abc]", eoff = 0, ecnt = 3 },
cscase { expr = "[]ab]", eoff = 3, ecnt = 3 },
cscase { expr = "[[ab]", eoff = 6, ecnt = 3 },
cscase { expr = "[a-c]", eoff = 9, ecnt = 1 },
cscase { expr = "[-a-c]", eoff = 10, ecnt = 2 },
cscase { expr = "[a-c-]", eoff = 12, ecnt = 2 },
cscase { expr = "[ä-ö]", eoff = 14, ecnt = 1 },
];
let i: i32 = 0;
for (i < len(rows)) {
let ex: str = rows[i].expr;
let c: (regex.regex | regex.error | nomem) = regex.compile(ex);
match (c) {
case let re: regex.regex => {
if (re.charsets.len != 1) { fail(); };
let cs0: [](charset_lit_item | charset_range_item |
charset_class_item) = re.charsets[0];
if ((len(cs0): i32) != rows[i].ecnt) { fail(); };
let k: i32 = 0;
for (k < rows[i].ecnt) {
if (cssig(cs0, (k: size))
!= exp[rows[i].eoff + k]) {
fail();
};
k += 1;
};
regex.finish(&re);
};
case => fail();
};
i += 1;
};
};
// The fold-4 compile-error surface, exact texts (regex.ha:267 / 211).
// The `[[:alpha:]]` POSIX-class arm is a loud ABORT, not an error —
// unpinnable in-process (it kills the runner); its boundary is
// source-audited (handle_bracket's class arm) until the POSIX fold.
@test fn fold4_compile_errors() void = {
let rows: [4]cerow = [
cerow { pat = "a[", want = "Unmatched '['" },
cerow { pat = "[abc", want = "Unmatched '['" },
cerow { pat = "[z-a]",
want = "Descending bracket expression range '[z-a]'" },
// the escape arm must not eat `[`: "\[" is a literal, the
// SECOND `[` opens an unterminated bracket
cerow { pat = "\\[[", want = "Unmatched '['" },
];
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;
};
};
// fold-4 find/test rows — Hare's own bracket block (+test.ha:278-345)
// minus the group row (`(` is loud) and the POSIX-class rows (loud
// abort), plus multibyte riders (literal and range brackets over
// 2-byte runes, idx != bytesize) and an unanchored composition row.
// MATCH 0 -1 resolves to (0, runelen, input) per +test.ha:693-697;
// every input is ASCII unless noted. Cross-pins test() == find().
@test fn fold4_find_cases() void = {
let rows: [72]fcase = [
fcase { expr = "^test[abc]$", input = "testa", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testa" },
fcase { expr = "^test[abc]$", input = "testb", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testb" },
fcase { expr = "^test[abc]$", input = "testc", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testc" },
fcase { expr = "^test[abc]$", input = "testd", matches = false, ... },
fcase { expr = "^test[abc]*$", input = "test", matches = true,
start = 0, sb = 0, end = 4, eb = 4, content = "test" },
fcase { expr = "^test[abc]*$", input = "testa", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testa" },
fcase { expr = "^test[abc]*$", input = "testaaa", matches = true,
start = 0, sb = 0, end = 7, eb = 7, content = "testaaa" },
fcase { expr = "^test[abc]*$", input = "testabc", matches = true,
start = 0, sb = 0, end = 7, eb = 7, content = "testabc" },
fcase { expr = "^test[abc]?$", input = "test", matches = true,
start = 0, sb = 0, end = 4, eb = 4, content = "test" },
fcase { expr = "^test[abc]?$", input = "testa", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testa" },
fcase { expr = "^test[abc]+$", input = "testa", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testa" },
fcase { expr = "^test[abc]+$", input = "test", matches = false, ... },
fcase { expr = "^test[]abc]$", input = "test]", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "test]" },
fcase { expr = "^test[[abc]$", input = "test[", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "test[" },
fcase { expr = "^test[^abc]$", input = "testd", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testd" },
fcase { expr = "^test[^abc]$", input = "test!", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "test!" },
fcase { expr = "^test[^abc]$", input = "testa", matches = false, ... },
fcase { expr = "^test[^abc]$", input = "testb", matches = false, ... },
fcase { expr = "^test[^abc]$", input = "testc", matches = false, ... },
fcase { expr = "^test[^]abc]$", input = "test]", matches = false, ... },
fcase { expr = "^test[^abc[]$", input = "test[", matches = false, ... },
fcase { expr = "^test[^abc]*$", input = "testd", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testd" },
fcase { expr = "^test[^abc]*$", input = "testqqqqq", matches = true,
start = 0, sb = 0, end = 9, eb = 9, content = "testqqqqq" },
fcase { expr = "^test[^abc]*$", input = "test", matches = true,
start = 0, sb = 0, end = 4, eb = 4, content = "test" },
fcase { expr = "^test[^abc]*$", input = "testc", matches = false, ... },
fcase { expr = "^test[^abc]?$", input = "test", matches = true,
start = 0, sb = 0, end = 4, eb = 4, content = "test" },
fcase { expr = "^test[^abc]?$", input = "testd", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testd" },
fcase { expr = "^test[^abc]?$", input = "testc", matches = false, ... },
fcase { expr = "^test[^abc]+$", input = "testd", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testd" },
fcase { expr = "^test[^abc]+$", input = "testddd", matches = true,
start = 0, sb = 0, end = 7, eb = 7, content = "testddd" },
fcase { expr = "^test[^abc]+$", input = "testc", matches = false, ... },
fcase { expr = "^test[^abc]+$", input = "testcccc", matches = false, ... },
fcase { expr = "^test[a-c]$", input = "testa", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testa" },
fcase { expr = "^test[a-c]$", input = "testb", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testb" },
fcase { expr = "^test[a-c]$", input = "testc", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testc" },
fcase { expr = "^test[a-c]$", input = "testd", matches = false, ... },
fcase { expr = "^test[a-c]$", input = "test!", matches = false, ... },
fcase { expr = "^test[a-c]$", input = "test-", matches = false, ... },
fcase { expr = "^test[-a-c]$", input = "test-", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "test-" },
fcase { expr = "^test[a-c-]$", input = "test-", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "test-" },
fcase { expr = "^test[a-c]*$", input = "test", matches = true,
start = 0, sb = 0, end = 4, eb = 4, content = "test" },
fcase { expr = "^test[a-c]*$", input = "testa", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testa" },
fcase { expr = "^test[a-c]*$", input = "testabb", matches = true,
start = 0, sb = 0, end = 7, eb = 7, content = "testabb" },
fcase { expr = "^test[a-c]*$", input = "testddd", matches = false, ... },
fcase { expr = "^test[a-c]?$", input = "test", matches = true,
start = 0, sb = 0, end = 4, eb = 4, content = "test" },
fcase { expr = "^test[a-c]?$", input = "testb", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testb" },
fcase { expr = "^test[a-c]?$", input = "testd", matches = false, ... },
fcase { expr = "^test[a-c]+$", input = "test", matches = false, ... },
fcase { expr = "^test[a-c]+$", input = "testbcbc", matches = true,
start = 0, sb = 0, end = 8, eb = 8, content = "testbcbc" },
fcase { expr = "^test[a-c]+$", input = "testd", matches = false, ... },
fcase { expr = "^test[^a-c]$", input = "testa", matches = false, ... },
fcase { expr = "^test[^a-c]$", input = "testb", matches = false, ... },
fcase { expr = "^test[^a-c]$", input = "testc", matches = false, ... },
fcase { expr = "^test[^a-c]$", input = "testd", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testd" },
fcase { expr = "^test[^a-c]$", input = "test!", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "test!" },
fcase { expr = "^test[^a-c]$", input = "test-", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "test-" },
fcase { expr = "^test[^-a-c]$", input = "test-", matches = false, ... },
fcase { expr = "^test[^a-c-]$", input = "test-", matches = false, ... },
fcase { expr = "^test[^a-c-]*$", input = "test", matches = true,
start = 0, sb = 0, end = 4, eb = 4, content = "test" },
fcase { expr = "^test[^a-c-]*$", input = "test--", matches = false, ... },
fcase { expr = "^test[^a-c-]*$", input = "testq", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testq" },
fcase { expr = "^test[^a-c-]?$", input = "test", matches = true,
start = 0, sb = 0, end = 4, eb = 4, content = "test" },
fcase { expr = "^test[^a-c-]?$", input = "testq", matches = true,
start = 0, sb = 0, end = 5, eb = 5, content = "testq" },
fcase { expr = "^test[^a-c-]?$", input = "test-", matches = false, ... },
fcase { expr = "^test[^a-c-]+$", input = "test", matches = false, ... },
fcase { expr = "^test[^a-c-]+$", input = "testb", matches = false, ... },
fcase { expr = "^test[^a-c-]+$", input = "testddd", matches = true,
start = 0, sb = 0, end = 7, eb = 7, content = "testddd" },
// multibyte riders: 2-byte runes in a literal bracket and a
// codepoint range — idx != bytesize in every field (B4)
fcase { expr = "^x[äö]$", input = "xä", matches = true,
start = 0, sb = 0, end = 2, eb = 3, content = "xä" },
fcase { expr = "^x[äö]$", input = "xq", matches = false, ... },
fcase { expr = "^[à-ö]$", input = "á", matches = true,
start = 0, sb = 0, end = 1, eb = 2, content = "á" },
fcase { expr = "^[à-ö]$", input = "x", matches = false, ... },
// unanchored leftmost-longest composition with fold-3's `+`
fcase { expr = "[ab]+", input = "xxabyyba", matches = true,
start = 2, sb = 2, end = 4, eb = 4, content = "ab" },
];
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) != 1) { 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;
};
};
// findall composition: charset × split (`+`) × the 2c machinery —
// non-overlapping greedy matches over mixed input.
@test fn fold4_findall() void = {
let c: (regex.regex | regex.error | nomem) = regex.compile("[ab]+");
match (c) {
case let re: regex.regex => {
let fr: ([]regex.result | nomem) =
regex.findall(&re, "xxabyyba");
if (!(fr is []regex.result)) { fail(); };
let results: []regex.result = fr as []regex.result;
if (len(results) != 2) { fail(); };
if (results[0][0].start != (2: size)) { fail(); };
if (results[0][0].end != (4: size)) { fail(); };
if (strings.compare(results[0][0].content, "ab") != 0) {
fail();
};
if (results[1][0].start != (6: size)) { fail(); };
if (results[1][0].end != (8: size)) { fail(); };
if (strings.compare(results[1][0].content, "ba") != 0) {
fail();
};
regex.result_freeall(results);
regex.finish(&re);
};
case => fail();
};
};
export fn main() i32 = {
signalled = 1; lit_and_match();
signalled = 2; size_aliases_distinct();
@@ -1383,5 +1739,10 @@ export fn main() i32 = {
signalled = 29; find_last_groupstart_cases();
signalled = 30; shift_direct();
signalled = 31; fold3_find_cases();
signalled = 32; fold4_programs();
signalled = 33; fold4_charsets();
signalled = 34; fold4_compile_errors();
signalled = 35; fold4_find_cases();
signalled = 36; fold4_findall();
return 0;
};