feat(stdlib): add Regex.* — a linear-time regular-expression engine (#18)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 26s
ci / build-and-test (push) Successful in 1m6s
commit-lint / conventional-commits (push) Successful in 4s
docs / build-and-deploy (push) Successful in 18s

A regular-expression library with PCRE/PECL-compatible syntax, implemented as a
Thompson NFA / Pike VM so a bad pattern from a modder can NEVER cause
catastrophic backtracking — matching is O(n·m), never exponential. `(a+)+$` on
40 non-matching chars, `(a*)*b`, `(.*a){20}b` all run in microseconds; a 50 KB
input scans in ~7 ms.

The engine (runtime/native/regex.ludic + regex_vm.ludic, ~700 lines of Ludic, no
C) parses a pattern to a small bytecode program — an unanchored lazy `.*?` prefix
makes a plain search match anywhere — and the VM runs every alive thread in
lockstep per input byte, deduped by program counter and carrying capture slots
(save/restore, leftmost-greedy priority). Supported: literals, `.`, classes
`[...]` (ranges, negation, `\d \w \s` and their negations), anchors `^ $`,
alternation `|`, capturing and `(?:…)` groups, and `* + ? {n} {n,} {n,m}` in
greedy or lazy form, plus the common escapes; numbered capture groups. Errors are
values — an invalid pattern compiles to null, never a crash. Backreferences and
look-around are out of scope for a linear engine, and on the degenerate case of a
nullable subpattern under an unbounded quantifier positions may differ from a
backtracking engine (the price of the linear-time guarantee) — documented.

Surface (Regex.*, aliased in emit_call.ludic to the regex_* functions):
compile / valid / matches / test / find / exec / next / replace / group /
group_count / start / end / ok.

The runtime is spliced on demand: the parser sets a flag when it sees `Regex.`
and maybe_splice_runtime imports the engine — so it costs nothing in a program
that doesn't use it and works in a plain tool (not just an ECS game).

Verified against Python's `re` as an oracle: a 20k-case grammar fuzzer agrees
100% on realistic patterns (0 / 15000 with capture groups) and 99.8% on group-0
spans across the full pathological grammar, the residual being the documented
nullable-quantifier case. examples/library/regex.ludic asserts the behaviour
(wired into `x test`, now 60 passed); docs: a Regex section + 13 per-symbol
pages, inventory + coverage green. Seed reseeded; the C-free bootstrap fixpoint
holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-31 12:23:19 +03:00
parent 9ed0070039
commit b798e3024e
22 changed files with 15079 additions and 13041 deletions

View file

@ -185,6 +185,24 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "write") { bare = "save" }
if (meth == "read") { bare = "load" }
}
# Regex.* -> the regex_* engine functions (spliced from runtime/native/regex*.ludic
# when a program mentions Regex.*). Each is a plain alias; the engine functions
# are ordinary Ludic, so the generic call path resolves them to @fn_regex_*.
if (ns == "Regex") {
if (meth == "compile") { bare = "regex_compile"; push(labels, "pattern") }
if (meth == "valid") { bare = "regex_valid"; push(labels, "pattern") }
if (meth == "matches") { bare = "regex_matches"; push(labels, "text"); push(labels, "pattern") }
if (meth == "test") { bare = "regex_test"; push(labels, "text"); push(labels, "re") }
if (meth == "find") { bare = "regex_find"; push(labels, "text"); push(labels, "pattern") }
if (meth == "exec") { bare = "regex_exec"; push(labels, "text"); push(labels, "re") }
if (meth == "next") { bare = "regex_next"; push(labels, "text"); push(labels, "re"); push(labels, "from") }
if (meth == "replace") { bare = "regex_replace"; push(labels, "text"); push(labels, "pattern"); push(labels, "replacement") }
if (meth == "group") { bare = "regex_group"; push(labels, "match"); push(labels, "n") }
if (meth == "group_count") { bare = "regex_group_count"; push(labels, "match") }
if (meth == "start") { bare = "regex_start"; push(labels, "match"); push(labels, "n") }
if (meth == "end") { bare = "regex_end"; push(labels, "match"); push(labels, "n") }
if (meth == "ok") { bare = "regex_ok"; push(labels, "match") }
}
if (bare == null) { perr(`unknown builtin {ns}.{meth}`) }
reorder_named(e, labels)
let id = node(E_ID); id.s = bare; e.a = id

View file

@ -160,7 +160,9 @@ function p_primary() -> Node {
function p_postfix() -> Node {
var e = p_primary()
while true {
if is_op(".") { pi = pi + 1; let m = node(E_MEMBER); m.a = e; m.s = eat_id(); e = m }
if is_op(".") { pi = pi + 1; let m = node(E_MEMBER); m.a = e; m.s = eat_id(); e = m
if e.a.kind == E_ID and e.a.s == "Regex" { g_uses_regex = true } # splice the regex runtime on demand
}
else { if is_op("[") { pi = pi + 1; let lo = expr()
if is_op("..") { pi = pi + 1; let sl = node(E_SLICE); sl.a = e; sl.b = lo; sl.c = expr(); eat_op("]"); e = sl } # s[a..b] substring
else { let ix = node(E_INDEX); ix.a = e; ix.b = lo; eat_op("]"); e = ix } }
@ -371,6 +373,7 @@ function path_join(dir: pointer, rel: pointer) -> pointer {
var loaded_paths: []pointer
var cur_dir: pointer
var g_uses_regex: bool = false # a program mentioned Regex.* -> splice the regex runtime
function already_loaded(full: pointer) -> bool {
var i = 0
@ -507,11 +510,21 @@ function do_import(rel: pointer) -> void {
# a game (has systems/components) links the Ludic runtime; auto-splice it the
# way the C compiler does. Tools (a `main` block, no ECS) get nothing.
function maybe_splice_runtime() -> void {
if not has_ecs() { return }
let saved = cur_dir
cur_dir = ""
do_import("runtime/native/core.ludic")
cur_dir = saved
# a game (has systems/components) links the Ludic runtime.
if has_ecs() {
cur_dir = ""
do_import("runtime/native/core.ludic")
cur_dir = saved
}
# any program that uses Regex.* gets the regex engine spliced in (it is
# self-contained — only compiler intrinsics — so it works in a plain tool too).
if g_uses_regex {
cur_dir = ""
do_import("runtime/native/regex.ludic")
do_import("runtime/native/regex_vm.ludic")
cur_dir = saved
}
}
function parse_program() -> void {
@ -529,6 +542,7 @@ function parse_program() -> void {
g_events = new []Node
g_onlisten = new []Node
g_toggled_layers = new []pointer
g_uses_regex = false
loaded_paths = new []pointer
skipnl()
g_game_name = "Ludic"

File diff suppressed because it is too large Load diff