From 0199508fa09f6251ca8406a8caa14e121b028580 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Sat, 18 Jul 2026 00:11:37 -0500 Subject: [PATCH] fix(parser): prev of binds unary-or-tighter, and rejects a non-name operand (#634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prev of was the only `of` in the language that did not bind unary-or-tighter. The operand parser called parse_expression where every other `of` uses parse_unary (the OF handler's documented rule), so `prev of x + 1` parsed as `prev of (x + 1)` and silently returned null — the whole trailing expression was swallowed. It is now `(prev of x) + 1`, matching SPEC.md's Application rule (item 2), which prev was the lone exception to. Every occurrence in the suite was written parenthesized — `(prev of x)` — so the unparenthesized form the spec's own rule requires was never exercised. Secondary silent-tolerance, from the parser's own comment ("only IDENT operands are meaningful"): a non-name operand — a literal, an index/dot, or a parenthesised expression — has no assignment history to look back through, yet returned null rather than erroring. It is now a clean parse error, so the program does not run instead of propagating a null. Interactions verified unchanged: the `at` suffix (`prev of x at L`), the bare `prev` identifier fallback with postfix (`prev[0]`, `prev.k`), and test_temporal / test_soft_keyword_idents all still pass. parse_unary needed a forward declaration before parse_primary. Tests: SK19/SK19b/SK19c in test_soft_keyword_idents.eigs assert the unparenthesized +/*/- forms (fail before the fix, which returned null); a new [70b] runner block asserts `prev of (x+1)` is a parse error and does not run. SPEC.md's temporal section documents the operand rule. Also backfills the CHANGELOG [Unreleased] Fixed entries for #630, #632, #631, which merged without them. Suite 3086/3086 release, 3090/3090 ASan+UBSan (detect_leaks=1, tally 0); doc examples 71/71, doc-drift clean. --- CHANGELOG.md | 25 +++++++++++++++++++++++++ docs/SPEC.md | 6 ++++++ src/parser.c | 20 +++++++++++++++++++- tests/run_all_tests.sh | 23 +++++++++++++++++++++++ tests/test_soft_keyword_idents.eigs | 10 ++++++++++ 5 files changed, 83 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a2c5cd..d13e8c08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,31 @@ All notable changes to EigenScript are documented here. being recomputed by render and click separately. ### Fixed +- **`prev of` now binds unary-or-tighter like every other `of` (#634).** + `prev of x + 1` parsed as `prev of (x + 1)` — the operand parser used + `parse_expression` where every other `of` uses `parse_unary` — and + silently returned null. It is now `(prev of x) + 1`, matching the spec's + Application rule. `prev of` also requires a variable name now: a non-name + operand (literal, index/dot, parenthesised expr) has no assignment history + and is a clean parse error instead of a silent null. +- **Source line numbers wrapped at 65536 (#630).** The `OP_LINE` bytecode + operand was 16-bit, so every line the VM tracked was `line % 65536`. Two + assignments 65536 lines apart collapsed onto one stamp and `what is x at L` + returned the wrong value at rc=0; runtime-error/stack-trace lines and the + trace-tape line context were also silently wrong past line 65535 (the + regime generated programs reach). Widened to 32-bit; no opcode number or + on-disk format changed. +- **`relu`/`leaky_relu`/`softmax`/`log_softmax` returned a silent null on a + scalar (#632).** `tensor_to_flat` reports 0 dims for a `VAL_NUM`, and the + four did `if (!flat) return make_null()`, so the null poisoned downstream + arithmetic far from the cause. Each now has a scalar fast path (the + degenerate element-wise case), matching `sqrt`/`exp`/`log`. +- **A modal dialog blocked the mouse but not the keyboard (#631).** Keyboard + focus never consulted `_ui.modal_stack`, so Tab could reach a widget behind + an open dialog and Enter/Space would activate it — a confirmation dialog + could fire the destructive action it guards. `dispatch` now rebinds to the + top modal before routing keys, and `push_modal`/`pop_modal` scope the focus + list to the dialog (which also makes a dialog's own children Tab-able, #575). - **README's `unobserved` example showed the inert half of a real optimisation (#655).** Reported by email with a full repro. The section paired a dict-field example (`game.px is game.px + game.vx * DT`) with diff --git a/docs/SPEC.md b/docs/SPEC.md index 666b4296..5dcc9f90 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1150,6 +1150,12 @@ x` is the value x held before its latest assignment. `what is x at ` reads the value x had at a source line. (History recording turns on automatically when a program contains a temporal query.) +`prev of` takes a **variable name** — it looks back through that binding's +history — and binds like any other `of` (tighter than arithmetic, per the +Application rule above): `prev of x + 1` is `(prev of x) + 1`. A non-name +operand (a literal, an index/dot, or a parenthesised expression) has no +trajectory and is a parse error. + ```eigenscript score is 10 score is 25 diff --git a/src/parser.c b/src/parser.c index 2e5ccbe7..028191f9 100644 --- a/src/parser.c +++ b/src/parser.c @@ -566,6 +566,7 @@ static void compound_to_op(TokType t, char op[4]) { } static ASTNode* parse_expression(Parser *p); +static ASTNode* parse_unary(Parser *p); /* #634: parse_primary's `prev of` operand */ static ASTNode* parse_statement(Parser *p); static int chain_too_deep(Parser *p); @@ -731,7 +732,24 @@ static ASTNode* parse_primary(Parser *p) { p_advance(p); if (p_cur(p)->type == TOK_OF) { p_advance(p); - ASTNode *expr = parse_expression(p); + /* #634: unary-or-tighter, like every other `of` (parse_unary at + * the OF handler) — so `prev of x + 1` is `(prev of x) + 1`, not + * `prev of (x + 1)` (which silently returned null). The `at` + * suffix below still takes a full expression. */ + ASTNode *expr = parse_unary(p); + /* #634: prev looks back through a variable's assignment history, so + * the operand must be a bare name. A literal, index, dot, or + * parenthesised expression has no trajectory — it used to return + * null silently (the parser's own "only IDENT operands are + * meaningful" comment). Raise instead of tolerating it. */ + if (expr && expr->type != AST_IDENT) { + fprintf(stderr, + "Parse error line %d: 'prev of' requires a variable name\n", + t->line); + eigs_record_first_error(t->line, + "'prev of' requires a variable name"); + g_parse_errors++; + } ASTNode *at_expr = NULL; if (p_cur(p)->type == TOK_AT) { p_advance(p); diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 4d56b746..610f0d0f 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -2386,6 +2386,29 @@ else fi echo "" +# [70b] prev-of operand must be a bare name (#634). 'prev of' looks back +# through a variable's assignment history, so a non-name operand (a literal, +# a parenthesised expression, an index/dot) has no trajectory. It used to +# return null silently; now it is a clean parse error, and the program does +# not run. (The precedence half — `prev of x + 1` = `(prev of x) + 1` — is +# asserted in test_soft_keyword_idents.eigs SK19.) +echo "[70b] prev-of requires a variable name (#634)" +PRV_FILE=$(mktemp /tmp/eigs_prev634_XXXX.eigs) +printf 'x is 5\nx is 9\nr is prev of (x + 1)\nprint of "should-not-run"\n' > "$PRV_FILE" +PRV_OUT=$(./eigenscript "$PRV_FILE" &1); PRV_RC=$? +TOTAL=$((TOTAL + 1)) +if [ "$PRV_RC" -ne 0 ] \ + && echo "$PRV_OUT" | grep -q "requires a variable name" \ + && ! echo "$PRV_OUT" | grep -q "should-not-run"; then + PASS=$((PASS + 1)) + echo " PASS: prev of a non-name is a parse error, program does not run" +else + FAIL=$((FAIL + 1)) + echo " FAIL: prev-of non-name (rc=$PRV_RC)" + echo "$PRV_OUT" | head -3 +fi +rm -f "$PRV_FILE" + # Temporal correctness under JIT/OSR: a deep loop's `at`/`state_at` must not # freeze at the OSR point (OP_LINE must stamp g_trace_current_line in the JIT). check_eigs_suite "JIT temporal at/state_at under OSR (g_trace_current_line)" test_jit_temporal_osr.eigs "All tests passed" 2 diff --git a/tests/test_soft_keyword_idents.eigs b/tests/test_soft_keyword_idents.eigs index 60a175d0..4e73993a 100644 --- a/tests/test_soft_keyword_idents.eigs +++ b/tests/test_soft_keyword_idents.eigs @@ -74,4 +74,14 @@ sk_v is 1 sk_v is 5 assert of [(prev of sk_v) == 1, "SK18 prev of stays temporal with prev bound"] +# SK19 (#634): prev of binds unary-or-tighter like every other `of`, so +# trailing arithmetic is NOT absorbed. Before the fix `prev of sk_w + 1` +# parsed as `prev of (sk_w + 1)` and silently returned null. sk_w's value +# before its last assignment is 10. +sk_w is 10 +sk_w is 20 +assert of [(prev of sk_w + 1) == 11, "SK19 prev of binds tighter than + (unparenthesized)"] +assert of [(prev of sk_w * 2) == 20, "SK19b prev of binds tighter than *"] +assert of [(prev of sk_w - 3) == 7, "SK19c prev of binds tighter than -"] + print of "soft keyword idents: all passed"