diff --git a/GAPS.md b/GAPS.md index fb08c5d..dc52ccc 100644 --- a/GAPS.md +++ b/GAPS.md @@ -34,3 +34,9 @@ Each entry should name the gap, the workaround used, and a thought on the upstre - **Symptom:** `chr of 97` returns `"a"`, but there's no `ord of "a"` to go the other way. This makes char-class compilation awkward — without numeric codes, ranges and bitmaps are hard to encode efficiently. - **Workaround:** Now unnecessary. - **Upstream fix:** Shipped alongside gap #1 — `ord of s` returns byte 0..255, or -1 for empty / non-string. + +### load_file cross-module write asymmetry — FILED upstream 2026-07-03 (EigenScript#373) +- **Encountered:** verifying #5 (engine internals clobbering caller globals) +- **Symptom:** whether a lib function's bare `name is` clobbers a caller global depends on whether the global was declared **before** the `load_file` (clobbered) or after (insulated). Made #5 look like a non-repro — its example used the safe order — while the before-order corrupted the engine's own state from caller globals. +- **Workaround:** `local` on every function-internal first assignment (the #5 fix, `tests/test_s9_scope.eigs`); the discipline is load-order-proof. +- **Upstream:** EigenScript#373 — remove or document the asymmetry. diff --git a/lib/regex.eigs b/lib/regex.eigs index e5d3e3e..c656e55 100644 --- a/lib/regex.eigs +++ b/lib/regex.eigs @@ -28,7 +28,7 @@ load_file of "lib/regex_vm.eigs" # ---- re_compile: pattern → program ---- # Returns null on parse error. define re_compile(pattern) as: - ast is regex_parse of pattern + local ast is regex_parse of pattern if ast == null: return null return regex_compile_ast of ast @@ -36,11 +36,11 @@ define re_compile(pattern) as: # ---- re_match: anchored full-string match ---- # Returns 1 if pattern matches the entire string, else 0. define re_match(args) as: - prog is args[0] - text is args[1] + local prog is args[0] + local text is args[1] if prog == null: return 0 - caps is regex_vm_run of [prog, text, 0, 1] + local caps is regex_vm_run of [prog, text, 0, 1] if caps == null: return 0 return 1 @@ -49,8 +49,8 @@ define re_match(args) as: # Returns [start, end, g1s, g1e, g2s, g2e, ...] or null. Non-participating # groups report -1, -1. define re_search(args) as: - prog is args[0] - text is args[1] + local prog is args[0] + local text is args[1] if prog == null: return null return regex_vm_run of [prog, text, 0, 0] @@ -76,9 +76,9 @@ define re_replace(args) as: break local ms is caps[0] local me is caps[1] - out is out + text[pos:ms] + rep + local out is out + text[pos:ms] + rep if me > ms: - pos is me + local pos is me else: if me < n: out is out + text[me:me + 1] @@ -90,15 +90,15 @@ define re_replace(args) as: # ---- re_find_all: all non-overlapping matches ---- # Returns a list of capture lists (same shape as re_search results). define re_find_all(args) as: - prog is args[0] - text is args[1] - results is [] + local prog is args[0] + local text is args[1] + local results is [] if prog == null: return results - pos is 0 - n is len of text + local pos is 0 + local n is len of text loop while pos <= n: - caps is regex_vm_run of [prog, text, pos, 0] + local caps is regex_vm_run of [prog, text, pos, 0] if caps == null: return results append of [results, caps] diff --git a/lib/regex_compile.eigs b/lib/regex_compile.eigs index bf88ba3..8fd5afa 100644 --- a/lib/regex_compile.eigs +++ b/lib/regex_compile.eigs @@ -19,13 +19,13 @@ # ---- _emit: append one instruction and return its index ---- define _emit(prog, inst) as: - idx is len of prog + local idx is len of prog append of [prog, inst] return idx # ---- _compile_node: recursively emit for an AST node ---- define _compile_node(node, prog) as: - tag is node[0] + local tag is node[0] if tag == "lit": _emit of [prog, ["char", node[1]]] return 0 @@ -38,7 +38,7 @@ define _compile_node(node, prog) as: if tag == "empty": return 0 if tag == "concat": - children is node[1] + local children is node[1] for i in range of (len of children): _compile_node of [children[i], prog] return 0 @@ -70,7 +70,7 @@ define _compile_node(node, prog) as: _emit of [prog, ["assert_end"]] return 0 if tag == "group": - idx is node[1] + local idx is node[1] _emit of [prog, ["save", 2 * idx]] _compile_node of [node[2], prog] _emit of [prog, ["save", 2 * idx + 1]] @@ -90,12 +90,12 @@ define _compile_node(node, prog) as: # jmp L1 # L3: define _compile_star(child, prog, greedy) as: - l1 is len of prog - split_idx is _emit of [prog, ["split", -1, -1]] - l2 is len of prog + local l1 is len of prog + local split_idx is _emit of [prog, ["split", -1, -1]] + local l2 is len of prog _compile_node of [child, prog] _emit of [prog, ["jmp", l1]] - l3 is len of prog + local l3 is len of prog if greedy == 1: prog[split_idx][1] is l2 prog[split_idx][2] is l3 @@ -114,10 +114,10 @@ define _compile_star(child, prog, greedy) as: # split L2, L1 (try exit first) # L2: define _compile_plus(child, prog, greedy) as: - l1 is len of prog + local l1 is len of prog _compile_node of [child, prog] - split_idx is _emit of [prog, ["split", -1, -1]] - l2 is len of prog + local split_idx is _emit of [prog, ["split", -1, -1]] + local l2 is len of prog if greedy == 1: prog[split_idx][1] is l1 prog[split_idx][2] is l2 @@ -136,10 +136,10 @@ define _compile_plus(child, prog, greedy) as: # L1: # L2: define _compile_quest(child, prog, greedy) as: - split_idx is _emit of [prog, ["split", -1, -1]] - l1 is len of prog + local split_idx is _emit of [prog, ["split", -1, -1]] + local l1 is len of prog _compile_node of [child, prog] - l2 is len of prog + local l2 is len of prog if greedy == 1: prog[split_idx][1] is l1 prog[split_idx][2] is l2 @@ -160,27 +160,27 @@ define _compile_quest(child, prog, greedy) as: # L_END: # 2-way is the same with the recursion collapsed. define _compile_alt(branches, prog) as: - n is len of branches + local n is len of branches if n == 1: _compile_node of [branches[0], prog] return 0 # Emit split placeholder; first target is next pc, second is fixed up # after we know where the rest goes. - split_idx is _emit of [prog, ["split", -1, -1]] - first_pc is len of prog + local split_idx is _emit of [prog, ["split", -1, -1]] + local first_pc is len of prog prog[split_idx][1] is first_pc _compile_node of [branches[0], prog] - jmp_idx is _emit of [prog, ["jmp", -1]] - rest_pc is len of prog + local jmp_idx is _emit of [prog, ["jmp", -1]] + local rest_pc is len of prog prog[split_idx][2] is rest_pc # Compile remaining branches as a sub-alt. - rest is [] - i is 1 + local rest is [] + local i is 1 loop while i < n: append of [rest, branches[i]] i is i + 1 _compile_alt of [rest, prog] - end_pc is len of prog + local end_pc is len of prog prog[jmp_idx][1] is end_pc return 0 @@ -190,18 +190,18 @@ define _compile_alt(branches, prog) as: # VM can grab both pieces atomically; null patterns still propagate as null # from the parser, before we ever get here. define regex_compile_ast(ast) as: - prog is [] + local prog is [] _compile_node of [ast, prog] _emit of [prog, ["match"]] - n_slots is 0 - i is 0 + local n_slots is 0 + local i is 0 loop while i < (len of prog): - inst is prog[i] + local inst is prog[i] if inst[0] == "save": if inst[1] >= n_slots: n_slots is inst[1] + 1 i is i + 1 - out is {} + local out is {} out["code"] is prog out["n_slots"] is n_slots return out diff --git a/lib/regex_parse.eigs b/lib/regex_parse.eigs index d4b5ac7..8758321 100644 --- a/lib/regex_parse.eigs +++ b/lib/regex_parse.eigs @@ -39,7 +39,7 @@ define _peek(st) as: # ---- _advance: consume and return current char ---- define _advance(st) as: - c is _peek of st + local c is _peek of st st["pos"] is st["pos"] + 1 return c @@ -116,13 +116,13 @@ define _posix_ranges(name) as: # parseable without forcing an escape. define _parse_class(st) as: _advance of st - neg is 0 + local neg is 0 if (_peek of st) == "^": _advance of st neg is 1 - ranges is [] + local ranges is [] loop while 1: - c is _peek of st + local c is _peek of st if c == "": return null if c == "]": @@ -133,9 +133,9 @@ define _parse_class(st) as: # POSIX class: [:name:] — expand to ranges and continue. _advance of st _advance of st - name is "" + local name is "" loop while 1: - d is _peek of st + local d is _peek of st if d == "": return null if d == ":": @@ -145,14 +145,14 @@ define _parse_class(st) as: if (_peek of st) != "]": return null _advance of st - prs is _posix_ranges of name + local prs is _posix_ranges of name if prs == null: return null for pr in prs: append of [ranges, pr] continue - lo_c is _advance of st - lo is ord of lo_c + local lo_c is _advance of st + local lo is ord of lo_c if lo < 0: return null if (_peek of st) == "-": @@ -162,8 +162,8 @@ define _parse_class(st) as: append of [ranges, [45, 45]] _advance of st break - hi_c is _advance of st - hi is ord of hi_c + local hi_c is _advance of st + local hi is ord of hi_c if hi < 0: return null if hi < lo: @@ -180,14 +180,14 @@ define _parse_class(st) as: # st["groups"] *before* recursing so nested groups get higher indices than the # enclosing one, matching what users expect from r[2*i+2..2*i+3] in the result. define _parse_atom(st) as: - c is _peek of st + local c is _peek of st if c == "": return null if c == "(": _advance of st - idx is st["groups"] + local idx is st["groups"] st["groups"] is st["groups"] + 1 - inner is _parse_alt of st + local inner is _parse_alt of st if inner == null: return null if (_peek of st) != ")": @@ -207,7 +207,7 @@ define _parse_atom(st) as: return _parse_class of st if c == "\\": _advance of st - e is _advance of st + local e is _advance of st if e == "": return null if e == "w": @@ -229,9 +229,9 @@ define _parse_atom(st) as: # ---- _read_digits: consume a (possibly empty) run of ASCII digits ---- define _read_digits(st) as: - out is "" + local out is "" loop while 1: - c is _peek of st + local c is _peek of st if c == "": break if c >= "0": @@ -255,25 +255,25 @@ define _read_digits(st) as: # Invalid or unclosed specs are parse errors, as in glibc; bounds cap at 255. define _parse_curly(st, atom) as: _advance of st - lo_s is _read_digits of st + local lo_s is _read_digits of st if lo_s == "": return null - lo is num of lo_s - hi is lo - unbounded is 0 + local lo is num of lo_s + local hi is lo + local unbounded is 0 if (_peek of st) == ",": _advance of st if (_peek of st) == "}": unbounded is 1 else: - hi_s is _read_digits of st + local hi_s is _read_digits of st if hi_s == "": return null hi is num of hi_s if (_peek of st) != "}": return null _advance of st - lazy is 0 + local lazy is 0 if (_peek of st) == "?": _advance of st lazy is 1 @@ -284,8 +284,8 @@ define _parse_curly(st, atom) as: return null if hi < lo: return null - parts is [] - i is 0 + local parts is [] + local i is 0 loop while i < lo: append of [parts, atom] i is i + 1 @@ -296,11 +296,11 @@ define _parse_curly(st, atom) as: append of [parts, ["star", atom]] else: # Optional tail, innermost-first: m−n nested (e …)? levels. - tail is null - k is hi - lo + local tail is null + local k is hi - lo loop while k > 0: if tail == null: - inner is atom + local inner is atom else: inner is ["concat", [atom, tail]] if lazy == 1: @@ -320,10 +320,10 @@ define _parse_curly(st, atom) as: # Greedy variants prefer to consume; lazy variants prefer to stop. # Encoded by the order of split targets in the compiler. define _parse_quant(st) as: - atom is _parse_atom of st + local atom is _parse_atom of st if atom == null: return null - c is _peek of st + local c is _peek of st if c == "*": _advance of st if (_peek of st) == "?": @@ -348,16 +348,16 @@ define _parse_quant(st) as: # ---- _parse_concat: zero or more quantified atoms, stop at |, ), EOF ---- define _parse_concat(st) as: - parts is [] + local parts is [] loop while 1: - c is _peek of st + local c is _peek of st if c == "": break if c == "|": break if c == ")": break - q is _parse_quant of st + local q is _parse_quant of st if q == null: return null append of [parts, q] @@ -369,13 +369,13 @@ define _parse_concat(st) as: # ---- _parse_alt: one or more concats separated by | ---- define _parse_alt(st) as: - first is _parse_concat of st + local first is _parse_concat of st if first == null: return null - branches is [first] + local branches is [first] loop while (_peek of st) == "|": _advance of st - nxt is _parse_concat of st + local nxt is _parse_concat of st if nxt == null: return null append of [branches, nxt] @@ -385,11 +385,11 @@ define _parse_alt(st) as: # ---- regex_parse: entry point ---- define regex_parse(pattern) as: - st is {} + local st is {} st["src"] is pattern st["pos"] is 0 st["groups"] is 0 - ast is _parse_alt of st + local ast is _parse_alt of st if ast == null: return null if (_peek of st) != "": diff --git a/lib/regex_vm.eigs b/lib/regex_vm.eigs index cafc659..4408c48 100644 --- a/lib/regex_vm.eigs +++ b/lib/regex_vm.eigs @@ -35,9 +35,9 @@ # Lists are reference-typed; without a copy, two threads at different pcs # would share captures and the second `save` would clobber the first. define _clone_saves(saves) as: - out is [] - i is 0 - n is len of saves + local out is [] + local i is 0 + local n is len of saves loop while i < n: append of [out, saves[i]] i is i + 1 @@ -51,8 +51,8 @@ define _addthread(tlist, seen, pc, saves, sp, n, prog, mstart) as: if seen[pc] == 1: return 0 seen[pc] is 1 - inst is prog[pc] - op is inst[0] + local inst is prog[pc] + local op is inst[0] if op == "jmp": _addthread of [tlist, seen, inst[1], saves, sp, n, prog, mstart] return 0 @@ -61,7 +61,7 @@ define _addthread(tlist, seen, pc, saves, sp, n, prog, mstart) as: _addthread of [tlist, seen, inst[2], saves, sp, n, prog, mstart] return 0 if op == "save": - new_saves is _clone_saves of saves + local new_saves is _clone_saves of saves new_saves[inst[1]] is sp _addthread of [tlist, seen, pc + 1, new_saves, sp, n, prog, mstart] return 0 @@ -84,30 +84,30 @@ define _addthread(tlist, seen, pc, saves, sp, n, prog, mstart) as: # threads in this step may have queued higher-priority nlist work whose # later MATCH overrides the current one. define _try_match_at(prog, text, start, n_slots) as: - n is len of text - plen is len of prog - clist is [] - nlist is [] - cseen is zeros of plen - nseen is zeros of plen - init_saves is [] - s is 0 + local n is len of text + local plen is len of prog + local clist is [] + local nlist is [] + local cseen is zeros of plen + local nseen is zeros of plen + local init_saves is [] + local s is 0 loop while s < n_slots: append of [init_saves, -1] s is s + 1 _addthread of [clist, cseen, 0, init_saves, start, n, prog, start] - sp is start - matched is 0 - matched_sp is -1 - matched_saves is null + local sp is start + local matched is 0 + local matched_sp is -1 + local matched_saves is null loop while sp <= n: - i is 0 + local i is 0 loop while i < (len of clist): - t is clist[i] - pc is t[0] - saves is t[1] - inst is prog[pc] - op is inst[0] + local t is clist[i] + local pc is t[0] + local saves is t[1] + local inst is prog[pc] + local op is inst[0] if op == "match": matched is 1 matched_sp is sp @@ -116,29 +116,29 @@ define _try_match_at(prog, text, start, n_slots) as: break if op == "char": if sp < n: - c is char_at of [text, sp] + local c is char_at of [text, sp] if c == inst[1]: _addthread of [nlist, nseen, pc + 1, saves, sp + 1, n, prog, start] if op == "any": if sp < n: - c is char_at of [text, sp] + local c is char_at of [text, sp] if c != "\n": _addthread of [nlist, nseen, pc + 1, saves, sp + 1, n, prog, start] if op == "class": if sp < n: - c is char_at of [text, sp] - code is ord of c - ranges is inst[2] - in_range is 0 - j is 0 + local c is char_at of [text, sp] + local code is ord of c + local ranges is inst[2] + local in_range is 0 + local j is 0 loop while j < (len of ranges): - r is ranges[j] + local r is ranges[j] if code >= r[0]: if code <= r[1]: in_range is 1 break j is j + 1 - take is in_range + local take is in_range if inst[1] == 1: if in_range == 1: take is 0 @@ -164,9 +164,9 @@ define _try_match_at(prog, text, start, n_slots) as: # Caller already knows `start` (the position passed to _try_match_at) and # `end` (returned end_sp). Group spans interleave as g1s, g1e, g2s, g2e, ... define _build_result(start, end, saves) as: - out is [start, end] - i is 0 - n is len of saves + local out is [start, end] + local i is 0 + local n is len of saves loop while i < n: append of [out, saves[i]] i is i + 1 @@ -181,35 +181,35 @@ define _build_result(start, end, saves) as: # byte-identical to the loop version, but O(n*|prog|), restoring the Pike-VM # linear-time guarantee. define _search(prog, text, start, n_slots) as: - n is len of text - plen is len of prog - clist is [] - nlist is [] - cseen is zeros of plen - nseen is zeros of plen - sp is start - matched is 0 - m_start is -1 - m_end is -1 - m_saves is null + local n is len of text + local plen is len of prog + local clist is [] + local nlist is [] + local cseen is zeros of plen + local nseen is zeros of plen + local sp is start + local matched is 0 + local m_start is -1 + local m_end is -1 + local m_saves is null loop while sp <= n: # Seed a new start thread (lowest priority) until a match is locked in; # a start to the right of an existing match can never be leftmost. if matched == 0: - init_saves is [] - s is 0 + local init_saves is [] + local s is 0 loop while s < n_slots: append of [init_saves, -1] s is s + 1 _addthread of [clist, cseen, 0, init_saves, sp, n, prog, sp] - i is 0 + local i is 0 loop while i < (len of clist): - t is clist[i] - pc is t[0] - saves is t[1] - mstart is t[2] - inst is prog[pc] - op is inst[0] + local t is clist[i] + local pc is t[0] + local saves is t[1] + local mstart is t[2] + local inst is prog[pc] + local op is inst[0] if op == "match": matched is 1 m_start is mstart @@ -219,29 +219,29 @@ define _search(prog, text, start, n_slots) as: break if op == "char": if sp < n: - c is char_at of [text, sp] + local c is char_at of [text, sp] if c == inst[1]: _addthread of [nlist, nseen, pc + 1, saves, sp + 1, n, prog, mstart] if op == "any": if sp < n: - c is char_at of [text, sp] + local c is char_at of [text, sp] if c != "\n": _addthread of [nlist, nseen, pc + 1, saves, sp + 1, n, prog, mstart] if op == "class": if sp < n: - c is char_at of [text, sp] - code is ord of c - ranges is inst[2] - in_range is 0 - j is 0 + local c is char_at of [text, sp] + local code is ord of c + local ranges is inst[2] + local in_range is 0 + local j is 0 loop while j < (len of ranges): - r is ranges[j] + local r is ranges[j] if code >= r[0]: if code <= r[1]: in_range is 1 break j is j + 1 - take is in_range + local take is in_range if inst[1] == 1: if in_range == 1: take is 0 @@ -266,22 +266,22 @@ define _search(prog, text, start, n_slots) as: # ---- regex_vm_run: entry point ---- define regex_vm_run(args) as: - prog is args[0] - text is args[1] - start is args[2] - anchored is args[3] - code is prog["code"] - n_slots is prog["n_slots"] - n is len of text + local prog is args[0] + local text is args[1] + local start is args[2] + local anchored is args[3] + local code is prog["code"] + local n_slots is prog["n_slots"] + local n is len of text if anchored == 1: - result is _try_match_at of [code, text, start, n_slots] + local result is _try_match_at of [code, text, start, n_slots] if result == null: return null if result[0] != n: return null return _build_result of [start, result[0], result[1]] # Search: single linear pass with an implicit .*? prefix (leftmost match). - result is _search of [code, text, start, n_slots] + local result is _search of [code, text, start, n_slots] if result == null: return null return _build_result of [result[0], result[1], result[2]] diff --git a/tests/test_s9_scope.eigs b/tests/test_s9_scope.eigs new file mode 100644 index 0000000..c9c695d --- /dev/null +++ b/tests/test_s9_scope.eigs @@ -0,0 +1,42 @@ +# Regression for #5: engine internals must not clobber caller globals. +# EigenScript's `is` updates an existing outer binding, so any engine +# working variable assigned without `local` overwrites a same-named +# module variable in the calling script. +# Common working names used inside the engine — declared BEFORE the +# engine loads: a function from a load_file'd module CAN write a caller +# global that existed at load time (declared-after globals happen to be +# insulated by the env layering — do not rely on that here). +pos is 42 +c is "keep-c" +n is 17 +i is 3 +j is 4 +out is "keep-out" +parts is "keep-parts" +branches is "keep-branches" +ranges is "keep-ranges" +results is "keep-results" + +load_file of "lib/regex.eigs" + +prog is re_compile of "[a-z]+(x|y)*" +m is re_search of [prog, " abcxy "] +fa is re_find_all of [prog, "abc xyz"] + +define check(label, ok) as: + if ok == 1: + print of f"{label} OK" + else: + print of f"{label} FAIL" + +check of ["S9 engine ran", (m != null) and ((len of fa) == 2)] +check of ["S9 pos preserved", pos == 42] +check of ["S9 c preserved", c == "keep-c"] +check of ["S9 n preserved", n == 17] +check of ["S9 i preserved", i == 3] +check of ["S9 j preserved", j == 4] +check of ["S9 out preserved", out == "keep-out"] +check of ["S9 parts preserved", parts == "keep-parts"] +check of ["S9 branches preserved", branches == "keep-branches"] +check of ["S9 ranges preserved", ranges == "keep-ranges"] +check of ["S9 results preserved", results == "keep-results"]