diff --git a/.gitignore b/.gitignore index 5a1a0110..e6ae5e62 100644 --- a/.gitignore +++ b/.gitignore @@ -48,7 +48,6 @@ tests/tmp_mod_373*.eigs build/ # Generated LSP stdlib index (make lsp / build.sh lsp, #590) src/lsp_stdlib_index.h -src/lsp_builtin_index.h src/eigsdap # Python bytecode caches. tests/test_doc_examples.py is IMPORTED (not just diff --git a/CHANGELOG.md b/CHANGELOG.md index cba44969..0c7d0721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ All notable changes to EigenScript are documented here. ### Breaking changes +- **`report` and `report_value` are reserved observer forms (#1102): they + cannot be bound or used as values, and `report of name` / `report_value of name` + require an identifier operand (parentheses around the identifier are allowed).** + Every binding position now fails before the source unit runs, with parse + diagnostic `E005`, including parameters, loops, comprehensions, destructuring, + catches, and imports. Non-identifier operands also fail with `E005`; assign an + expression to a variable before reporting its trajectory. Name/slot observer + behavior is unchanged, and dict keys may still use these words. The shared + parser enforces the rule for files, REPL units, dynamic loads/eval, and hosts; + lint and LSP carry the same code. CLI `-e [args...]` now accepts a + source string through the file execution path. + The builtin reference table and LSP Function completions omit both forms. + Two new token kinds (`TOK_REPORT`, `TOK_REPORT_VALUE`) shift the tokenizer's + identifier vocabulary ids by +2, visible in `tests/test_corpus.eigs` output; + consumers that key corpora on raw token ids (iLambdaAi) must rebuild them. + The VM retains the `report` builtin for existing bytecode that resolves + its name through `vm_run_bytecode`; its value-only behavior is unchanged. + - **File resolution is independent of the process working directory (#1056).** `load_file` and `import` search the containing file's directory, the existing `eigs_modules` walk, the nearest `eigs.json` project root, then the stdlib diff --git a/Makefile b/Makefile index 2114c2a5..642ef500 100644 --- a/Makefile +++ b/Makefile @@ -284,11 +284,11 @@ $(SRC_DIR)/lsp_stdlib_index.h: $(wildcard lib/*.eigs) tools/gen_lsp_stdlib_index bash tools/gen_lsp_stdlib_index.sh # Builtin half of the same idea (#742): names from the registration seams + -# ext_names.h, hover detail from the signature comments. Also a build -# artifact, never committed. +# ext_names.h, hover detail from the signature comments. Reserved report +# forms are excluded using lexer.c. Commit the generated header for review. $(SRC_DIR)/lsp_builtin_index.h: $(SRC_DIR)/builtins.c $(SRC_DIR)/builtins_host.c \ $(SRC_DIR)/hash.c $(SRC_DIR)/ext_store.c $(SRC_DIR)/ext_names.h \ - tools/gen_lsp_builtin_index.sh + $(SRC_DIR)/lexer.c tools/gen_lsp_builtin_index.sh bash tools/gen_lsp_builtin_index.sh # Real file targets (#825): rebuilt when their sources, any header, the diff --git a/docs/BUILTINS.md b/docs/BUILTINS.md index 191f46b1..ea246e51 100644 --- a/docs/BUILTINS.md +++ b/docs/BUILTINS.md @@ -269,21 +269,33 @@ Query a binding's assignment history. Always on for top-level bindings; | Name | Signature | Description | |------|-----------|-------------| -| `report` | `report of value` | Classify change trajectory: "improving", "diverging", "stable", "equilibrium", "oscillating", "converged" — or "moving" when a full window matches none of them (#735) | | `observe` | `observe of value` | Return [status, entropy, dH, prev_dH] snapshot | | `classify` | `classify of t` or `classify of [t, "entropy"]` | Classify a trajectory snapshot (from `trajectory of x`, #421): value-channel label by default, entropy-channel with `"entropy"`. Raises `type_mismatch` on a non-snapshot — a bare value never silently classifies | +**`report` and `report_value` are reserved** (#1102). They cannot be bound or +used as first-class values. Non-identifier operands, including `report of 5` +and `report_value of (x + 0.0)`, are compile-time `E005` errors; assign the +expression to a variable first. Dict keys such as `d.report` remain legal. +See [OBSERVER.md](OBSERVER.md) for their trajectory classifications. + +The VM retains the old `report` registry entry for bytecode compatibility: +`vm_run_bytecode` can still resolve the string `"report"` with `GET_NAME` +and `CALL` it, returning `"equilibrium"` for data or `"opaque"` for a callable. +This entry is absent from the source builtin table and LSP Function completions; +it does not make `report` a callable name in source. `report_value` has no +runtime builtin registration. + **`report`, `report_value`, `observe`, and `trajectory` on a plain variable are observer special forms** (decided in #459): like the predicates and interrogatives, `report of x` / `report_value of x` / `observe of x` / `trajectory of x` are resolved by the compiler to the named *binding's* slot -trajectory — an operation on the name, not the value — so a user rebinding of -these names does not change them (`--lint` W013 warns on the shadowing -attempt). `trajectory of x` (#421) snapshots the slot's observer windows into +trajectory — an operation on the name, not the value. The report words are +reserved; a user rebinding of `observe` or `trajectory` does not change their +name-keyed forms (`--lint` W013 warns on those shadowing attempts). `trajectory of x` (#421) snapshots the slot's observer windows into a plain dict (`kind`/`rel`/`raw`/`dh`/`entropy`/…) that survives a call boundary, for `classify` to read on the other side — the binding slot itself is binding-identity and a passed value arrives with no history. The non-ident -forms (`report of (x + 0.0)`, `observe of expr`) are ordinary calls to the +forms of `observe` / `trajectory` (`observe of expr`) are ordinary calls to the value-path builtins. `dispatch` is deliberately NOT in this set — it is a plain builtin and a user rebinding wins (see Lists above). diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 7e144314..614864d7 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -439,6 +439,11 @@ stops moving, which would make a runaway look like the calmest possible trajectory — so the observer treats the ceiling as evidence of divergence, not of rest (#861). Keep the runaway above going and the verdict holds: +`report` and `report_value` are reserved observer forms: unlike ordinary +functions in Python or JavaScript, they cannot be rebound or passed as values. +Their `of` operand must be a variable name (parentheses allowed); other operands +and all binding attempts fail before the source unit runs with `E005`. + ```eigenscript r is 1.0 i is 0 diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md index 7fc2d580..37aaedc4 100644 --- a/docs/DIAGNOSTICS.md +++ b/docs/DIAGNOSTICS.md @@ -248,6 +248,7 @@ a code's meaning never changes, and retired codes are not reused. | `E002` | error | Parse error (parser). `--lint --json` reports the first one. | | `E003` | error | Undefined name — **no binding on any path** (#404). A name is read somewhere but bound nowhere: not by any assignment in any scope, not a parameter/binder (function/lambda params, `for`/comprehension variables, `catch` names, list-pattern names, `import` module names), not a builtin, and not a top-level name of a file pulled in by a literal `load_file` (resolved with the runtime's own resolution chain, transitively). The runtime raises `undefined variable` the moment such a path executes; `E003` surfaces it statically, including on cold branches. See "Name resolution (`E003`)" below for the exact model and escapes. | | `E004` | error | Compile error — the file parses and still cannot be turned into bytecode (`break` outside a loop, an expression past the nesting limit, a constant pool past 65536; the full set is under **Compile errors** above). `--lint` compiles the unit and discards the chunk, so a green lint means *at minimum* that the file builds (#927); `--lint --json` reports the first compile error. Not suppressible, like `E002`: an allow-list applies to lint findings, not to a file the compiler refuses. | +| `E005` | error | Reserved observer form misuse: `report` / `report_value` used as a binding or first-class value, or given a non-identifier operand. Emitted by the parser at the offending word, before any statement in that source unit runs; `--lint --json` and LSP preserve this code and token location. Not suppressible. Use `report of x` / `report_value of x` (parentheses around `x` allowed); assign expressions to a variable first. | | `E100` | error | Uncaught runtime error (category code; see note below). | | `W001` | warning | Unused variable. | | `W002` | warning | Unused function parameter. | @@ -261,13 +262,13 @@ a code's meaning never changes, and retired codes are not reused. | `W010` | warning | Duplicate dict key. | | `W011` | warning | `name is ...` used in a condition (likely meant `==`). | | `W012` | warning | Assignment shadows a builtin. The builtin set is derived from `register_builtins()` itself plus the extension names (`ext_names.h`) — never a hand list (#459: the old hand-copied array was ~120 names behind the binary, so shadowing `dispatch`, `chr`, `eval`, … lint'd clean). | -| `W013` | warning | Function definition shadows a builtin (same registry-derived set as `W012`). For the observer special forms (`report`, `report_value`, `observe`) this warning is load-bearing: their name-keyed forms are compiler-resolved and a rebinding does NOT change them (#459) — the shadowing function is only reachable through non-ident argument forms. | +| `W013` | warning | Function definition shadows a builtin (same registry-derived set as `W012`). For the shadowable observer special forms (`observe`, `trajectory`) this warning is load-bearing: their name-keyed forms are compiler-resolved and a rebinding does NOT change them (#459) — the shadowing function is only reachable through non-ident argument forms. `report` and `report_value` are reserved and instead rejected with `E005`. | | `W014` | warning | Bare trajectory predicate in a loop condition reads the last-observed binding, but the body assigns two or more bindings — name it (` of `). | | `W015` | warning | A function assigns (without `local`) over a module-level **function** name, clobbering it via mutate-outward so later ` of ...` calls fail — add `local` or rename. (Scoped to function clobbering; benign module-variable reuse is not flagged — the general local-discipline fence is tracked by #870. `_`-prefixed names are skipped as intentional module state.) | | `W016` | warning | Bare trajectory predicate **outside a loop condition** (`if stable:`, `ok is converged`, `return diverging`) reads the last-observed binding — an invisible alias (#247/#262) — write ` of `. Loop conditions are exempt: the single-assign `loop while not converged` form is the documented idiom, and the ambiguous multi-assign case is `W014`. Any explicit subject counts as named, including `stable of (x + 0.0)`; deliberate bare reads carry `# lint: allow W016`. | | `W017` | warning | Bare 1-element literal arg list: `f of [x]` passes **one argument** — the element, not the list (#405; the pre-#405 rule meant the opposite, so the form reads ambiguously). Write `f of x` for one argument, or `f of ([x])` (#355) to pass a 1-element list. Doubles as the #405 migration audit: `--lint` over a consumer repo surfaces every behavior-changed call site. | | `W018` | warning | A `catch`-bound error's `.kind` is compared (`==`/`!=`) against a string that is a **near-miss** of a real kind — a case variant (`"IO"`) or a single-character typo (`"index_rage"`), or a kind renamed out from under the handler — so the branch is dead code that silently never fires (#469). Kinds are a closed set (below). Zero-false-positive by construction: only near-misses of a closed kind fire, and only off a catch-bound variable — an exactly-valid kind, and a genuinely custom `throw {kind: "..."}` value many edits from every builtin, both stay silent. | -| `W019` | warning | An interrogative used as a **bare statement** — `why is "..."`, `what is x`, `prev of y` at statement level — evaluates and **discards** its result: a silent no-op (#583). Question words (`what/who/when/where/why/how`) cannot be assigned with `is` — the "assignment" is the interrogative expression form — so when a same-named binding exists in scope the statement is almost certainly a mistaken assignment (the real hit: a catch handler "reassigning" a `local why` that silently kept its stale value). An interrogative inside an expression (`print of (why is x)`, `r is prev of y`) is never flagged. Extended by #736 to the observer **query** forms over an ident — `report of x`, `report_value of x`, `observe of x`, `trajectory of x` at statement level — which are the same silent no-op through the other door: they print nothing and raise nothing, so silence is indistinguishable from "the observer had nothing to say". Zero false positives by construction: over an ident these are compiler-resolved special forms that never reach a user function even when one shadows the name (#459, see `W013`), and a non-ident argument (`report of (x + 0.0)`) is an ordinary call the check never sees. | +| `W019` | warning | An interrogative used as a **bare statement** — `why is "..."`, `what is x`, `prev of y` at statement level — evaluates and **discards** its result: a silent no-op (#583). Question words (`what/who/when/where/why/how`) cannot be assigned with `is` — the "assignment" is the interrogative expression form — so when a same-named binding exists in scope the statement is almost certainly a mistaken assignment (the real hit: a catch handler "reassigning" a `local why` that silently kept its stale value). An interrogative inside an expression (`print of (why is x)`, `r is prev of y`) is never flagged. Extended by #736 to the observer **query** forms over an ident — `report of x`, `report_value of x`, `observe of x`, `trajectory of x` at statement level — which are the same silent no-op through the other door: they print nothing and raise nothing, so silence is indistinguishable from "the observer had nothing to say". Zero false positives by construction: over an ident these are compiler-resolved special forms that never reach a user function (`report` and `report_value` are reserved; the other names retain their special forms even when shadowed, #459). Non-ident operands of `report` / `report_value` are `E005` errors; non-ident operands of `observe` / `trajectory` are ordinary calls this check never sees. | | `W020` | warning | An `unobserved:` block in which **every** assignment targets a dict field or list element (`d.k is ...`, `xs[i] is ...`) — a provable no-op (#655). Observer bookkeeping is gated on the named env path, so a dict field is never observed and there is nothing to skip; the in-place numeric mutation the block used to enable became unconditional with NaN-boxing B-3a (`dict_set_cached_immediate`), so it costs nothing to drop the block. This shape was true when written and expired silently, which is why it needs a lint — our own README shipped the dead form as its headline example for two months. Conservative by construction: `g_unobserved_depth` is a global, so a **call** inside the block runs the callee unobserved too and suppresses the warning; any plain-variable assignment, or a name-binding form (`for` / listcomp / `catch` / `match`), also suppresses it. Only a provably inert block fires. | | `W021` | hint | Function definition shadows a **public stdlib function** from a module the file never imported (`define 'median' shadows lib/stats.eigs 'median' (import stats to use it)`) — a discoverability nudge toward `lib/*.eigs` (#591), sibling of `W013` (which covers compiled-in builtins; a name that is both stays `W013`-only). The name table is scraped from the public top-level defines of the same `lib/` directories the import resolver searches; the hint stays silent when the module is imported, and when the linted file *is* the module that ships the name. Name-only matching has false positives (a deliberately-different local `mean`), so this is hint-severity: advisory, **never fails `--lint`** under either `--lint-level`, and suppressible like any other code. | | `W022` | warning | A bare literal argument list with **more elements than the callee's parameters** — with `define two(a, b)`, `two of [1, 2, 99]` passes 3 arguments to a 2-parameter callee (#733). Since #974 the runtime raises a catchable `value`-kind error at that call site (`call passes 3 arguments but the callee takes 2`); this warning catches the same mistake earlier, statically, without running the program. Conservative by construction, and **same-file only**: it fires only when the callee name provably has one meaning in the file — exactly one `define` of it anywhere and no other binding (assignment, param, lambda param, loop/comprehension var, catch name, list-pattern name, import, or any identifier inside a `match` pattern poisons the name), so a call into an imported module's callee is never checked here — cross-module over-arity reaches the runtime raise instead. One-parameter callees are exempt by the #405 semantics themselves: a 2+-element bare list binds WHOLE to a single parameter — nothing is dropped, and that shape is the deliberate variadic idiom (`reverse of [1, 2, 3]`). Parenthesized lists (`f of ([...])`) are a single argument and never fire. | diff --git a/docs/GRAMMAR.md b/docs/GRAMMAR.md index d415bd03..9c9604d7 100644 --- a/docs/GRAMMAR.md +++ b/docs/GRAMMAR.md @@ -78,6 +78,18 @@ ordinary identifiers everywhere else. The six question words above are soft in the same way — `what` not followed by `is` parses as an identifier. +### Reserved Observer Forms + +``` +report report_value +``` + +These words cannot be identifiers or binding names (`E005`). Their expression +form is `('report' | 'report_value') 'of' identifier_operand`, where +`identifier_operand = IDENT | '(' identifier_operand ')'`. Other operand shapes +are `E005` errors; `of` retains normal precedence. Like every word keyword, +these words are allowed as dict keys after a dot. + ### Observer Predicates ``` diff --git a/docs/OBSERVER.md b/docs/OBSERVER.md index 27c8a5f0..7b7f9bd4 100644 --- a/docs/OBSERVER.md +++ b/docs/OBSERVER.md @@ -136,6 +136,16 @@ you put the thresholds — see [Resolution](#resolution). coverage, not the value's motion — the same visible-gap rule as `moving`. +### Reserved report forms + +`report` and `report_value` are reserved words, never binding names or +first-class functions. Use `report of x` / `report_value of x` with a named +variable; parentheses around that name are allowed. Any binding of either word, +or a non-identifier operand (literal, arithmetic, call, index, field, or argument +list), is rejected at compile time with `E005`. Assign an expression to a +variable first so there is a binding history to inspect. Dict fields such as +`d.report` remain legal. See [SPEC](SPEC.md#observer-semantics-and-predicates) for the language rule. + ### Two signals: entropy vs. value (`report` vs. `report_value`) **Since #861 the predicate words and `report` ROUTE: numeric bindings diff --git a/docs/SPEC.md b/docs/SPEC.md index 6a797754..6a521924 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1292,6 +1292,29 @@ iterations pass without certification (a runaway pinned at the saturation ceiling, sub-deadband drift); `__loop_exit__` records which one happened. +`report` and `report_value` are **reserved observer forms**, like the +predicate keywords: neither may be a binding name (including function names, +parameters, `local`, loop/comprehension variables, destructuring targets, +`catch` names, or an `import` module name). They are not first-class values. +Misuse is a compile-time parse error **`E005`**, before any statement in that +source unit executes. The rule also applies to REPL input, `eval`, `load_file`, +`import`, the embedding API, and `--lint`; the CLI accepts source strings with +`eigenscript -e ''`. + +Both forms require an **identifier operand**, optionally parenthesized: +`report of x`, `report of (x)`, and `report_value of ((x))` query the same +binding history as before. Literals, arithmetic expressions, calls, indexing, +field access, and literal argument lists (`[]`, `[x]`, `[x, y]`) are rejected +with `E005` and “requires a variable name operand”. Assign an expression to a +variable first; a temporary value has no named assignment history. This also +replaces `report`'s old non-identifier fallback (`equilibrium`, or `opaque` for +a function) and `report_value`'s undefined-name error. `of` precedence is +unchanged: `report of x + "!"` appends to the report of `x`. + +As with other keywords, quoted dict keys and dot fields remain legal: +`d.report` and `d.report_value` are data fields, not bindings of reserved names. +`match` cases compare expressions; they do not introduce binders. + **`report of x`** names the most specific band true of the same trajectory the predicates read (value channel for numerics, entropy otherwise — #861), resolving `oscillating` → `diverging` → `improving` → diff --git a/docs/STDLIB.md b/docs/STDLIB.md index 3aeb3612..8a82b829 100644 --- a/docs/STDLIB.md +++ b/docs/STDLIB.md @@ -964,6 +964,12 @@ print of msg # "World is running v0.5" ### lib/eigen.eigs — Meta-Circular Interpreter +The meta-interpreter's `report` bridge classifies values without the host's +binding trajectories: it returns `equilibrium` for ordinary values and `opaque` +for functions. Since #1102 a fresh-parameter wrapper uses the reserved host +syntax to preserve that existing fallback; the meta-interpreter remains a +separate, partial implementation of the language. + | Function | Signature | Description | |----------|-----------|-------------| | `eigen_tokenize` | `eigen_tokenize of source` | Tokenize source string into token list | diff --git a/docs/llms.txt b/docs/llms.txt index cc384205..d7bd9872 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -77,7 +77,11 @@ not any outer `n`. ## Reserved and soft keywords (cannot be plain variable names) - **Reserved** (never identifiers): `in`, and the observer predicates - `converged`, `stable`, `improving`, `diverging`, `oscillating`, `equilibrium`. + `converged`, `stable`, `improving`, `diverging`, `oscillating`, `equilibrium`, + plus `report` and `report_value`. Never bind either report name, even as a + parameter or loop variable (`E005`). Use `report of x` / `report_value of x` + with an identifier (parentheses allowed); expressions/arg lists are `E005` + errors, so bind the expression first. Dict fields `d.report` remain legal. - **Soft** (`prev`, `at`, and the six question words `what who when where why how`): usable as params/loop-vars/catch-names, but you can NEVER bind one with plain `is` (`what is e` is the interrogative form, a silent no-op as an @@ -104,9 +108,9 @@ print of ("sqrt(2) = " + (str of (newton_sqrt of 2))) # sqrt(2) = 1.4142135623 - `loop while not converged` uses the **bare** predicate reading the loop's last-assigned value. Put it **inside a function** so the loop gets a fresh binding to watch (a module-scope version can loop forever). -- `report of x` returns the entropy-channel state string; `report_value of x` - the value-channel state (use the value channel for convergence vs divergence — - the entropy channel calls a monotonically exploding value `converged`). +- `report of x` returns the routed state string (value channel for numeric + bindings, entropy channel otherwise); `report_value of x` uses the value + channel explicitly. Both read the named binding's trajectory. - `converged` needs LOW absolute entropy on top of a settled window: a value pinned at `5.0` reports `stable`, never `converged`; pinned at `0.0`/`1.0` it converges. Don't guess the predicate — see docs/PREDICATES.md. diff --git a/lib/eigen.eigs b/lib/eigen.eigs index 6d1bc60b..bd4ea970 100644 --- a/lib/eigen.eigs +++ b/lib/eigen.eigs @@ -1696,9 +1696,9 @@ define _eval_block(stmts, env) as: define _make_default_env as: env is _env_new of null - # Bridge ALL C builtins into the meta-interpreter's environment. + # Bridge the C builtins into the meta-interpreter's environment. # Since we're running inside the C runtime, these names resolve to - # VAL_BUILTIN values. The evaluator's "call" handler calls them + # VAL_BUILTIN values (report uses the wrapper below). The "call" handler calls them # with `fn of arg` which the C runtime handles natively. _env_set_local of [env, "print", print] _env_set_local of [env, "write", write] @@ -1708,7 +1708,11 @@ define _make_default_env as: _env_set_local of [env, "num", num] _env_set_local of [env, "append", append] _env_set_local of [env, "type", type] - _env_set_local of [env, "report", report] + # #1102: report is reserved syntax, so it cannot be stored as a value. + # This fresh-parameter wrapper preserves this interpreter's existing + # value-only bridge: no trajectory -> equilibrium, functions -> opaque. + # It does not expose the host's named binding history to the meta env. + _env_set_local of [env, "report", (v) => report of v] _env_set_local of [env, "assert", assert] _env_set_local of [env, "throw", throw] _env_set_local of [env, "keys", keys] diff --git a/src/builtins.c b/src/builtins.c index 94d57c35..016ff18c 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -584,13 +584,11 @@ Value* builtin_append(Value *arg) { Value* builtin_report(Value *arg) { - /* #262 Step E: observer trajectories live on the Env slot, never on the - * Value. `report of ` is a slot-keyed special form (REPORT_SLOT/ - * REPORT_NAME); this builtin is reached only for a value-based operand with - * no binding (a computed expr, or an unobserved param), which has no - * trajectory → the no-observation band, "equilibrium". - * #708: a function/builtin operand answers "opaque" here too, matching - * the slot-keyed forms — a fn has no content the observer can sample. */ + /* #1102: source report forms are reserved and always slot-keyed. Retain + * this registry entry for vm_run_bytecode's GET_NAME("report") + CALL; + * tests/test_vm_run_bytecode.eigs pins that compatibility path. Values + * have no binding trajectory (#262), hence "equilibrium"; callables + * remain "opaque" (#708). The LSP excludes this entry from completions. */ if (arg && (arg->type == VAL_FN || arg->type == VAL_BUILTIN)) return make_str("opaque"); return make_str("equilibrium"); diff --git a/src/compiler.c b/src/compiler.c index 270f8462..bfbec22a 100644 --- a/src/compiler.c +++ b/src/compiler.c @@ -2715,6 +2715,9 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { emit_op_u16_u16(c, OP_PREDICATE_NAME, pkind, (uint16_t)pnidx, node->line); break; } + /* #1102: the parser reserves the report words and validates + * their identifier operand before any unit reaches compilation. + * Keep these name/slot emissions and runtime semantics unchanged. */ if (fn_node && fn_node->type == AST_IDENT && strcmp(fn_node->data.ident.name, "report") == 0 && arg_node && arg_node->type == AST_IDENT) { diff --git a/src/eigenlsp.c b/src/eigenlsp.c index 3148bc5b..266e84a9 100644 --- a/src/eigenlsp.c +++ b/src/eigenlsp.c @@ -738,8 +738,9 @@ static void send_diagnostics(Document *doc) { strbuf_append_fmt(&sb, "%d", err_col); strbuf_append(&sb, "},\"end\":{\"line\":"); strbuf_append_fmt(&sb, "%d", err_line); - strbuf_append_fmt(&sb, ",\"character\":%d}},\"severity\":1,\"code\":\"E002\"," - "\"source\":\"eigenscript\",\"message\":", err_end); + strbuf_append_fmt(&sb, ",\"character\":%d}},\"severity\":1,\"code\":\"%s\"," + "\"source\":\"eigenscript\",\"message\":", err_end, + g_first_error_code ? g_first_error_code : "E002"); json_escape_to(&sb, full); strbuf_append_char(&sb, '}'); } else if (doc->ast) { diff --git a/src/eigenscript.c b/src/eigenscript.c index 7284ac74..7171ff28 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -50,7 +50,8 @@ * four siblings (#955) — per-context, so two EigsStates on one thread * cannot cross-read known-ness. */ -void eigs_record_first_error_at(int line, int col, int len, const char *msg) { +void eigs_record_first_error_code_at(int line, int col, int len, + const char *code, const char *msg) { int candidate_col_known = col >= 0 && len > 0; if (g_first_error_line > 0) { if (line <= 0 || line > g_first_error_line) return; @@ -59,6 +60,7 @@ void eigs_record_first_error_at(int line, int col, int len, const char *msg) { col >= g_first_error_col) return; } } + g_first_error_code = code; g_first_error_line = line; g_first_error_col = candidate_col_known ? col : 0; g_first_error_len = len; @@ -66,6 +68,10 @@ void eigs_record_first_error_at(int line, int col, int len, const char *msg) { snprintf(g_first_error_msg, sizeof(g_first_error_msg), "%s", msg ? msg : "syntax error"); } +void eigs_record_first_error_at(int line, int col, int len, const char *msg) { + eigs_record_first_error_code_at(line, col, len, "E002", msg); +} + void eigs_record_first_error(int line, const char *msg) { eigs_record_first_error_at(line, -1, 0, msg); } @@ -191,6 +197,8 @@ const char* tok_type_name(TokType t) { case TOK_IN: return "'in'"; case TOK_NULL: return "'null'"; case TOK_UNOBSERVED: return "'unobserved'"; + case TOK_REPORT: return "'report'"; + case TOK_REPORT_VALUE: return "'report_value'"; case TOK_LOCAL: return "'local'"; case TOK_PLUS: return "'+'"; case TOK_MINUS: return "'-'"; diff --git a/src/eigenscript.h b/src/eigenscript.h index 6fd6b94a..f9336cb8 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -132,6 +132,7 @@ typedef enum { TOK_TRY, TOK_CATCH, TOK_BREAK, TOK_CONTINUE, TOK_IMPORT, TOK_MATCH, TOK_CASE, TOK_UNOBSERVED, + TOK_REPORT, TOK_REPORT_VALUE, TOK_LOCAL, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_PERCENT, TOK_LT, TOK_GT, TOK_LE, TOK_GE, TOK_EQ, TOK_NE, TOK_ASSIGN, @@ -727,6 +728,7 @@ struct EigsThread { * set this instead of printing when the VM is live. */ int error_print_pending; char error_msg[4096]; + const char *first_error_code; /* stable parse diagnostic, normally E002 */ char first_error_msg[256]; struct Value *error_value; /* thrown payload for structured catch */ /* #406: structured runtime errors. Kind (ErrKind), 1-based line, and @@ -947,6 +949,7 @@ extern __thread EigsThread *eigs_current; #define g_first_error_col_known (eigs_current->first_error_col_known) #define g_error_print_pending (eigs_current->error_print_pending) #define g_error_msg (eigs_current->error_msg) +#define g_first_error_code (eigs_current->first_error_code) #define g_first_error_msg (eigs_current->first_error_msg) #define g_error_value (eigs_current->error_value) #define g_error_kind (eigs_current->error_kind) @@ -1640,6 +1643,8 @@ void vm_print_stack_trace(FILE *out); /* uncaught-error call stack (vm.c); no-o int vm_current_line(void); /* live source line (vm.c); 0 without a VM */ void eigs_record_first_error(int line, const char *msg); void eigs_record_first_error_at(int line, int col, int len, const char *msg); +void eigs_record_first_error_code_at(int line, int col, int len, + const char *code, const char *msg); /* #407: one-line source excerpt + `^` caret under `col` (0-based), the * shared format for parse-time and runtime diagnostics. No-op when src is * NULL or the position is out of range. */ diff --git a/src/embed_smoke.c b/src/embed_smoke.c index c54c105f..31efa435 100644 --- a/src/embed_smoke.c +++ b/src/embed_smoke.c @@ -196,6 +196,35 @@ int main(void) { CHECK(r != NULL && eigs_value_as_num(r) == 100.0, "eval still works after error"); eigs_value_release(r); + /* #1102: source reservation is shared by the embedding parser. A + * rejected unit must not execute its earlier assignment, and another + * eval must recover with no stale diagnostic code. */ + { + const char *names[] = {"report", "report_value"}; + for (int i = 0; i < 2; i++) { + char source[192]; + snprintf(source, sizeof(source), + "embed_reserved_ran is 1\ndefine f(\n %s\n) as:\n return 0\n", + names[i]); + r = eigs_eval_string(source); + CHECK(r == NULL && g_parse_errors > 0, + "reserved observer parameter rejected by embed eval"); + CHECK(g_first_error_line == 3 && g_first_error_code && + strcmp(g_first_error_code, "E005") == 0 && + strstr(g_first_error_msg, "reserved observer form"), + "embed reserved diagnostic code and offending line"); + eigs_value_release(r); + EigsValue *ran = eigs_get_global("embed_reserved_ran"); + CHECK(!ran || eigs_value_type(ran) == EIGS_TYPE_NULL, + "embed rejected unit executes no earlier statement"); + eigs_value_release(ran); + r = eigs_eval_string("1 + 2"); + CHECK(r && eigs_value_as_num(r) == 3.0 && g_first_error_line == 0, + "embed recovers after reserved observer error"); + eigs_value_release(r); + } + } + /* --- FFI: register a C function, call from script. --------------- */ eigs_register_function("host_add", host_add); r = eigs_eval_string("host_add of [3, 4]"); diff --git a/src/lexer.c b/src/lexer.c index 17466787..6e28faab 100644 --- a/src/lexer.c +++ b/src/lexer.c @@ -84,6 +84,8 @@ static TokType keyword_type(const char *word) { if (strcmp(word, "prev") == 0) return TOK_PREV; break; case 'r': + if (strcmp(word, "report") == 0) return TOK_REPORT; + if (strcmp(word, "report_value") == 0) return TOK_REPORT_VALUE; if (strcmp(word, "return") == 0) return TOK_RETURN; break; case 's': @@ -158,6 +160,8 @@ const char* tok_base_string(TokType t) { case TOK_MATCH: return "match "; case TOK_CASE: return "case "; case TOK_UNOBSERVED: return "unobserved "; + case TOK_REPORT: return "report "; + case TOK_REPORT_VALUE:return "report_value "; case TOK_LOCAL: return "local "; case TOK_PLUS: return "+ "; case TOK_MINUS: return "- "; @@ -217,6 +221,7 @@ static TokenList tokenize_at_line(const char *source, int initial_line, int init * this document's diagnostic. Nested f-string tokenization bumps * g_tokenize_depth, so only reset at the outermost pass. */ if (g_tokenize_depth == 0) { + g_first_error_code = "E002"; g_first_error_line = 0; g_first_error_col = 0; g_first_error_len = 0; diff --git a/src/lint.c b/src/lint.c index 496b2046..502f6fac 100644 --- a/src/lint.c +++ b/src/lint.c @@ -756,9 +756,10 @@ static void check_builtin_shadow(ASTNode *node, LintContext *ctx) { * printing nothing and raising nothing. That is the worst affordance the * observer has: silence is indistinguishable from "nothing to say", and the * bare form is what issue bodies and READMEs reach for. Zero false positives by - * construction: over an ident these names never reach a user function even when - * one shadows them (#459, see W013), and a non-ident argument (`report of (x + - * 0.0)`) is an ordinary call this check never sees. */ + * construction: over an ident these names never reach a user function. The + * report words are reserved (#1102); observe/trajectory retain their special + * forms even when shadowed (#459, W013). Non-ident report operands are parse + * errors; non-ident observe/trajectory operands are calls this check never sees. */ static const char *disc_observer_query(ASTNode *node) { static const char *names[] = {"report", "report_value", "observe", "trajectory"}; diff --git a/src/lint_host.c b/src/lint_host.c index 48bacbdc..104ebf83 100644 --- a/src/lint_host.c +++ b/src/lint_host.c @@ -1262,12 +1262,14 @@ int eigenscript_lint(const char *path, int json_mode, int fail_on_warning) { lint_json_escape(g_first_error_msg[0] ? g_first_error_msg : "parse error", esc, sizeof(esc)); lint_json_escape(path, pesc, sizeof(pesc)); - printf("[{\"code\":\"E002\",\"severity\":\"error\",\"line\":%d," + printf("[{\"code\":\"%s\",\"severity\":\"error\",\"line\":%d," "\"column\":%d,\"file\":\"%s\",\"message\":\"%s\"}]\n", + g_first_error_code ? g_first_error_code : "E002", g_first_error_line, g_first_error_col + 1, pesc, esc); } else { - fprintf(stderr, "%s: %d parse error(s) [E002] — cannot lint\n", - path, g_parse_errors); + fprintf(stderr, "%s: %d parse error(s) [%s] — cannot lint\n", + path, g_parse_errors, + g_first_error_code ? g_first_error_code : "E002"); } free_ast(ast); free_tokenlist(&tl); diff --git a/src/lsp_builtin_index.h b/src/lsp_builtin_index.h new file mode 100644 index 00000000..d2ea1aa3 --- /dev/null +++ b/src/lsp_builtin_index.h @@ -0,0 +1,347 @@ +/* Generated by tools/gen_lsp_builtin_index.sh — DO NOT EDIT (#742). + * Names come from the registration seams + ext_names.h; hover text + * from the `name of ...` signature comment above each definition. + * Reserved report forms are excluded using the lexer keyword table. + * Regenerated by the Makefile lsp target and build.sh lsp. */ +static const char *builtin_docs[][2] = { + {"abs", "abs — builtin; see docs/BUILTINS.md"}, + {"acos", "acos — builtin; see docs/BUILTINS.md"}, + {"add", "add — builtin; see docs/BUILTINS.md"}, + {"append", "append — builtin; see docs/BUILTINS.md"}, + {"arena_mark", "arena_mark of null — saves current arena position. All Values allocated"}, + {"arena_reset", "arena_reset of null — reclaims all Values allocated since the last arena_mark."}, + {"arena_stats", "arena_stats of null — returns total bytes allocated through the arena."}, + {"args", "args of null → list of command-line arguments (after the script name)."}, + {"asin", "asin — builtin; see docs/BUILTINS.md"}, + {"assert", "assert — builtin; see docs/BUILTINS.md"}, + {"atan", "atan — builtin; see docs/BUILTINS.md"}, + {"atan2", "atan2 — builtin; see docs/BUILTINS.md"}, + {"audio_capture_close", "audio_capture_close of null — stop and close the recording device."}, + {"audio_capture_open", "audio_capture_open of [freq, channels] — open the recording device"}, + {"audio_capture_read", "audio_capture_read of null — drain accumulated samples since the"}, + {"audio_clear", "audio_clear of null — stop all mixer channels and free their clips"}, + {"audio_close", "audio_close of null"}, + {"audio_envelope", "audio_envelope of [samples, attack, decay, sustain_level, release] — ADSR"}, + {"audio_gain", "audio_gain of [samples, volume] — scale and clamp"}, + {"audio_mix", "audio_mix of [samples_a, samples_b] — add and clamp"}, + {"audio_music_play", "audio_music_play of [path, loops] — stream a music file (mp3/ogg/wav) via"}, + {"audio_music_stop", "audio_music_stop of null — halt and free the current track."}, + {"audio_music_volume", "audio_music_volume of v — music volume 0..128"}, + {"audio_noise", "audio_noise of [duration, amplitude] — white noise"}, + {"audio_open", "audio_open of [freq, channels] — open the mixer device (callback mode)"}, + {"audio_pause", "audio_pause of flag — 1=pause, 0=unpause"}, + {"audio_play", "audio_play of samples — convert float list [-1,1] to int16, queue"}, + {"audio_play_loop", "audio_play_loop of [samples, loops] — play `samples` `loops` times on"}, + {"audio_queue_size", "audio_queue_size of null — bytes queued"}, + {"audio_saw", "audio_saw of [freq, duration, amplitude] — sawtooth wave"}, + {"audio_sine", "audio_sine of [freq, duration, amplitude] — generate sine wave samples"}, + {"audio_square", "audio_square of [freq, duration, amplitude] — square wave"}, + {"audio_stop", "audio_stop of channel — stop one mixer channel. Returns 1, or 0 on a"}, + {"audio_stream_clear", "audio_stream_clear of null — drop any buffered audio on the live"}, + {"audio_stream_close", "audio_stream_close of null — stop and close the live stream device."}, + {"audio_stream_open", "audio_stream_open of [freq, channels] — open the live streaming"}, + {"audio_stream_push", "audio_stream_push of samples — queue a block of float samples [-1, 1]"}, + {"audio_stream_queued", "audio_stream_queued of null — samples still buffered (not yet played)"}, + {"audio_sweep", "audio_sweep of [freq_start, freq_end, duration, amplitude, waveform]"}, + {"audio_volume", "audio_volume of [channel, vol] — live per-channel volume, 0.0..4.0"}, + {"bit_and", "bit_and — builtin; see docs/BUILTINS.md"}, + {"bit_not", "bit_not — builtin; see docs/BUILTINS.md"}, + {"bit_or", "bit_or — builtin; see docs/BUILTINS.md"}, + {"bit_shl", "bit_shl — builtin; see docs/BUILTINS.md"}, + {"bit_shr", "bit_shr — builtin; see docs/BUILTINS.md"}, + {"bit_xor", "bit_xor — builtin; see docs/BUILTINS.md"}, + {"buf_copy", "buf_copy of [src, src_off, dst, dst_off, count] — bulk copy between buffers."}, + {"buf_deinterleave", "buf_deinterleave of [src, channel, nch, count?] — every nch-th sample"}, + {"buf_dot", "buf_dot of [a, b, a_off, b_off, count] — windowed dot product:"}, + {"buf_fill", "buf_fill of [b, off, count, value] — bulk store over a window:"}, + {"buf_from_list", "buf_from_list of list — convert list of numbers to buffer"}, + {"buf_from_pcm16le", "buf_from_pcm16le of [bytes, byte_off, count] — decode `count`"}, + {"buf_get", "buf_get of [buf, index] — O(1) indexed read"}, + {"buf_len", "buf_len of buf — return buffer length"}, + {"buf_mix", "buf_mix of [dst, src, dst_off, src_off, count, gain] —"}, + {"buf_peak", "buf_peak of [b, off, count] — max |x| over a window (normalize and"}, + {"buf_resample_linear", "buf_resample_linear of [src, dst_len] — endpoint-inclusive linear"}, + {"buf_scale_range", "buf_scale_range of [b, off, count, gain] — in-place multiply over a"}, + {"buf_set", "buf_set of [buf, index, value] — O(1) indexed write"}, + {"buf_to_pcm16le", "buf_to_pcm16le of [floats, off, count] — encode `count` samples from"}, + {"buffer", "buffer of count — create a zero-filled numeric buffer"}, + {"build_corpus", "build_corpus of [file_list, top_n, stream_path, vocab_path]"}, + {"ceil", "ceil — builtin; see docs/BUILTINS.md"}, + {"channel", "channel — builtin; see docs/BUILTINS.md"}, + {"channel_closed", "channel_closed — builtin; see docs/BUILTINS.md"}, + {"char_at", "char_at of [string, index] → single character as string, or \"\" if out of range."}, + {"chdir", "chdir of \"path\" → 1 on success, 0 on failure"}, + {"chr", "chr of n → single-character string from ASCII code"}, + {"classify", "classify — builtin; see docs/BUILTINS.md"}, + {"clear_math_flags", "clear_math_flags of null"}, + {"clock_unix", "clock_unix of null — seconds since the Unix epoch from CLOCK_REALTIME"}, + {"close_channel", "close_channel — builtin; see docs/BUILTINS.md"}, + {"coalesce", "coalesce of [value, default] — returns value unless empty/null"}, + {"concat", "concat of [list_a, list_b] → new 1D list with a's elements then b's"}, + {"contains", "contains — builtin; see docs/BUILTINS.md"}, + {"copy_into", "copy_into of [dest, dest_offset, src]"}, + {"cos", "cos — builtin; see docs/BUILTINS.md"}, + {"db_connect", "db_connect — db extension builtin; see docs/BUILTINS.md"}, + {"db_execute", "db_execute — db extension builtin; see docs/BUILTINS.md"}, + {"db_query_json", "db_query_json — db extension builtin; see docs/BUILTINS.md"}, + {"db_query_value", "db_query_value — db extension builtin; see docs/BUILTINS.md"}, + {"deflate", "deflate — builtin; see docs/BUILTINS.md"}, + {"dict_remove", "dict_remove — builtin; see docs/BUILTINS.md"}, + {"dict_set", "dict_set — builtin; see docs/BUILTINS.md"}, + {"dispatch", "dispatch of [table, key, arg] — O(1) function dispatch."}, + {"divide", "divide — builtin; see docs/BUILTINS.md"}, + {"dot", "dot — builtin; see docs/BUILTINS.md"}, + {"eigen_checkpoint_info", "eigen_checkpoint_info — model extension builtin; see docs/BUILTINS.md"}, + {"eigen_eval_loss", "eigen_eval_loss — model extension builtin; see docs/BUILTINS.md"}, + {"eigen_generate", "eigen_generate — model extension builtin; see docs/BUILTINS.md"}, + {"eigen_model_info", "eigen_model_info — model extension builtin; see docs/BUILTINS.md"}, + {"eigen_model_load", "eigen_model_load — model extension builtin; see docs/BUILTINS.md"}, + {"eigen_model_loaded", "eigen_model_loaded — model extension builtin; see docs/BUILTINS.md"}, + {"eigen_model_save_binary", "eigen_model_save_binary — model extension builtin; see docs/BUILTINS.md"}, + {"ends_with", "ends_with — builtin; see docs/BUILTINS.md"}, + {"env_get", "env_get — builtin; see docs/BUILTINS.md"}, + {"eval", "eval of code_string — execute EigenScript code and return result"}, + {"exe_path", "exe_path of null → absolute path of the running interpreter binary."}, + {"exec_capture", "exec_capture of [\"cmd\", \"arg1\", ...] → [exit_code, stdout_text]"}, + {"exit", "exit of N — request a clean process exit with code N (default 0). Sets the"}, + {"exp", "exp — builtin; see docs/BUILTINS.md"}, + {"f64_from_bytes", "f64_from_bytes of → the decoded double."}, + {"f64_to_bytes", "f64_to_bytes of x → list of 8 ints: the big-endian IEEE-754 double encoding"}, + {"file_exists", "file_exists — builtin; see docs/BUILTINS.md"}, + {"fill", "fill of [count, value] — create a list of `count` elements all set to `value`."}, + {"floor", "floor — builtin; see docs/BUILTINS.md"}, + {"flush", "flush of null — flush stdout"}, + {"free_val", "free_val of value → frees a heap-allocated Value tree. Returns null."}, + {"gather", "gather of [tensor, indices, dim] → select elements at indices along last dim"}, + {"get_at", "get_at of [list, index] or get_at of [list, row, col]"}, + {"get_observer_thresholds", "get_observer_thresholds — builtin; see docs/BUILTINS.md"}, + {"getcwd", "getcwd of null → current working directory as string"}, + {"gfx_circle", "gfx_circle of [cx, cy, radius, r, g, b] — filled circle via midpoint"}, + {"gfx_clear", "gfx_clear of [r, g, b]"}, + {"gfx_clip", "gfx_clip of [x, y, w, h] — set render clip rectangle."}, + {"gfx_close", "gfx_close of null"}, + {"gfx_delay", "gfx_delay of ms"}, + {"gfx_fb", "gfx_fb of [buffer, width, height, x, y, scale]"}, + {"gfx_line", "gfx_line of [x1, y1, x2, y2, r, g, b]"}, + {"gfx_open", "gfx_open of [width, height, title]"}, + {"gfx_point", "gfx_point of [x, y, r, g, b]"}, + {"gfx_poll", "gfx_poll of null — return next event as dict, or null if none."}, + {"gfx_present", "gfx_present of null — flip buffer to screen"}, + {"gfx_read", "gfx_read of [x, y] — read back one rendered pixel as [r, g, b]."}, + {"gfx_rect", "gfx_rect of [x, y, w, h, r, g, b] or [x, y, w, h, r, g, b, a]"}, + {"gfx_rrect", "gfx_rrect of [x, y, w, h, radius, r, g, b] or [..., a]"}, + {"gfx_text", "gfx_text of [x, y, text, r, g, b] or [x, y, text, r, g, b, scale]"}, + {"gfx_text_height", "gfx_text_height of scale? (number, [scale], or null → 1) — pixel line"}, + {"gfx_text_width", "gfx_text_width of [text, scale?] (or of \"text\") — pixel width of `text`"}, + {"gfx_ticks", "gfx_ticks of null — milliseconds since SDL_Init"}, + {"gfx_title", "gfx_title of \"new title\""}, + {"has_key", "has_key — builtin; see docs/BUILTINS.md"}, + {"heap_inuse", "heap_inuse of null — bytes currently in use by the C allocator"}, + {"hex", "hex of n → uppercase hex string, minimal digits (\"0\" for 0)."}, + {"hmac_sha256", "hmac_sha256 — builtin; see docs/BUILTINS.md"}, + {"http_cors", "http_cors of origin — configure CORS. Pass \"*\" for wildcard, null to disable."}, + {"http_early_bind", "http_early_bind — http extension builtin; see docs/BUILTINS.md"}, + {"http_post", "http_post of [url, headers_json, body_string] -> response body string"}, + {"http_request_body", "http_request_body — http_request extension builtin; see docs/BUILTINS.md"}, + {"http_request_headers", "http_request_headers of null → raw request headers as string."}, + {"http_route", "http_route — http extension builtin; see docs/BUILTINS.md"}, + {"http_route_authed", "http_route_authed — http extension builtin; see docs/BUILTINS.md"}, + {"http_serve", "http_serve — http extension builtin; see docs/BUILTINS.md"}, + {"http_session_id", "http_session_id — http_request extension builtin; see docs/BUILTINS.md"}, + {"http_static", "http_static — http extension builtin; see docs/BUILTINS.md"}, + {"index_of", "index_of of [haystack, needle] → first index, or -1. Non-string operands"}, + {"inflate", "inflate — builtin; see docs/BUILTINS.md"}, + {"is_dir", "is_dir of path — 1 if path names a directory, 0 for a plain file, a"}, + {"is_file", "is_file — builtin; see docs/BUILTINS.md"}, + {"join", "join of [list, separator] — concatenate list elements into a string."}, + {"json_build", "json_build of [key1, val1, key2, val2, ...] — properly escaped JSON object"}, + {"json_decode", "json_decode — builtin; see docs/BUILTINS.md"}, + {"json_encode", "json_encode — builtin; see docs/BUILTINS.md"}, + {"json_path", "json_path of [json_string, \"dot.path\"] -> value as string, or \"\""}, + {"json_raw", "json_raw — builtin; see docs/BUILTINS.md"}, + {"keys", "keys — builtin; see docs/BUILTINS.md"}, + {"leaky_relu", "leaky_relu of tensor → element-wise max(0.01*x, x). Works on 1D or 2D."}, + {"len", "len — builtin; see docs/BUILTINS.md"}, + {"list_contains", "list_contains of [list, value] — 1 if any element structurally equals"}, + {"list_index_of", "list_index_of of [list, value] — index of the first element structurally"}, + {"list_insert_at", "list_insert_at of [list, index, value] — insert value at index, shift tail"}, + {"list_remove_at", "list_remove_at of [list, index] — remove element at index, shift tail down."}, + {"list_slice", "list_slice of [list, start, end] → new list with the elements of [start, end)."}, + {"list_truncate", "list_truncate of [list, new_len] — shrink list in-place to new_len items."}, + {"load_file", "load_file — builtin; see docs/BUILTINS.md"}, + {"log", "log of 1e-15 answered ln(1e-10) with no flag, a silent plateau that a"}, + {"log_softmax", "log_softmax — builtin; see docs/BUILTINS.md"}, + {"ls", "ls of \"path\" → list of filenames in directory, or [] on failure."}, + {"math_flags", "math_flags — builtin; see docs/BUILTINS.md"}, + {"matmul", "matmul — builtin; see docs/BUILTINS.md"}, + {"max", "max — builtin; see docs/BUILTINS.md"}, + {"md5", "md5 — builtin; see docs/BUILTINS.md"}, + {"md5_file", "md5_file — builtin; see docs/BUILTINS.md"}, + {"mean", "mean — builtin; see docs/BUILTINS.md"}, + {"min", "min — builtin; see docs/BUILTINS.md"}, + {"mkdir", "mkdir of \"path\" → 1 on success, 0 on failure. Creates parents."}, + {"mktemp", "mktemp of null → path to a new temporary file"}, + {"model_load_weights", "model_load_weights — model extension builtin; see docs/BUILTINS.md"}, + {"model_save_weights", "model_save_weights — model extension builtin; see docs/BUILTINS.md"}, + {"monotonic_ms", "monotonic_ms of null — milliseconds from CLOCK_MONOTONIC"}, + {"monotonic_ns", "monotonic_ns of null — nanoseconds from CLOCK_MONOTONIC"}, + {"multiply", "multiply — builtin; see docs/BUILTINS.md"}, + {"must_not_yield", "must_not_yield of fn — run `fn of null` as an ATOMIC critical section and"}, + {"native_train_step_builtin", "native_train_step_builtin — model extension builtin; see docs/BUILTINS.md"}, + {"nearest_in_range", "nearest_in_range of [entities, x, y, range, world_w, world_h, px_key, py_key, active_key]"}, + {"nearest_in_range_all", "nearest_in_range_all of [entities, range, world_w, world_h, px_key?, py_key?, active_key?]"}, + {"negative", "negative — builtin; see docs/BUILTINS.md"}, + {"net_accept", "net_accept of listener_id"}, + {"net_close", "net_close of handle_id → null. Deterministic and untraced: closing is"}, + {"net_dial", "net_dial of [host, port]"}, + {"net_listen", "net_listen of port → listener handle id, or null when the bind/listen"}, + {"net_port", "net_port of listener_id → the locally bound port (the kernel's pick"}, + {"net_recv", "net_recv of [conn_id, max_bytes]"}, + {"net_send", "net_send of [conn_id, data] — data is a string or a buffer/list of"}, + {"norm", "norm of a → L2 (Euclidean) norm = sqrt(sum_i a[i]^2). Buffers and tensors."}, + {"num", "num — builtin; see docs/BUILTINS.md"}, + {"num_copy", "num_copy of val → fresh heap-allocated copy of a numeric Value."}, + {"numerical_grad", "numerical_grad of [loss_fn, param, eps]"}, + {"numerical_grad_cols", "numerical_grad_cols of [loss_fn, matrix, col_indices, eps]"}, + {"numerical_grad_rows", "numerical_grad_rows of [loss_fn, matrix, row_indices, eps]"}, + {"observe", "observe — builtin; see docs/BUILTINS.md"}, + {"ord", "ord of s → first byte of s as integer (0..255), or -1 on empty / non-string"}, + {"path_base", "path_base of \"a/b/c.txt\" → \"c.txt\""}, + {"path_dir", "path_dir of \"a/b/c.txt\" → \"a/b\""}, + {"path_ext", "path_ext of \"a/b/c.txt\" → \".txt\""}, + {"path_join", "path_join of [a, b] → \"a/b\""}, + {"pi", "pi — builtin; see docs/BUILTINS.md"}, + {"pow", "pow — builtin; see docs/BUILTINS.md"}, + {"ppu_render_frame", "ppu_render_frame of [mem_buf, fb_buf]"}, + {"print", "print — builtin; see docs/BUILTINS.md"}, + {"proc_close", "proc_close of fd → null (idempotent on EBADF)"}, + {"proc_read", "proc_read of [out_fd, max_bytes] → string (raw bytes; NUL-truncates) | null EOF"}, + {"proc_read_buf", "proc_read_buf of [out_fd, max_bytes] → VAL_BUFFER (binary-safe) | null EOF"}, + {"proc_read_line", "proc_read_line of out_fd → string (no trailing \\n) | null EOF"}, + {"proc_spawn", "proc_spawn of [\"cmd\", \"arg1\", ...] → [pid, in_fd, out_fd] | [-1,-1,-1]"}, + {"proc_wait", "proc_wait of pid → exit_code | -1 on error"}, + {"proc_write", "proc_write of [in_fd, \"text\"] → bytes_written | -1 on broken pipe"}, + {"random", "random of null → float in [0, 1)"}, + {"random_hex", "random_hex of n → string of n random hex characters from /dev/urandom."}, + {"random_int", "random_int of [lo, hi] → integer in [lo, hi] inclusive"}, + {"random_normal", "random_normal of [rows, cols, scale] → 2D, or random_normal of [len, scale] → 1D"}, + {"range", "range of n → [0, 1, ..., n-1]"}, + {"raw_key", "raw_key — builtin; see docs/BUILTINS.md"}, + {"read_bytes", "read_bytes of path — read binary file, return list of byte values (0-255)"}, + {"read_bytes_buf", "read_bytes_buf of path — read binary file, return VAL_BUFFER of byte values."}, + {"read_line", "read_line of null — blocking line read from stdin via getline(3):"}, + {"read_text", "read_text of \"path\" → file contents as string, or \"\" on failure."}, + {"record_history", "record_history of flag — enable (nonzero) or disable (0) per-assignment"}, + {"recv", "recv — builtin; see docs/BUILTINS.md"}, + {"recv_timeout", "recv_timeout of [channel, ms] — bounded wait. Returns the value if one"}, + {"regex_find", "regex_find — builtin; see docs/BUILTINS.md"}, + {"regex_match", "regex_match — builtin; see docs/BUILTINS.md"}, + {"regex_replace", "regex_replace of [string, pattern, replacement] -> string"}, + {"relu", "relu of tensor → element-wise max(0, x). Works on 1D or 2D."}, + {"remove_file", "remove_file of path — delete a file. Returns 1 on success, 0 on failure."}, + {"rename", "rename of [old_path, new_path] — rename/replace a file. On POSIX rename(2) is"}, + {"reshape", "reshape of [buf, rows, cols] -> a shaped copy of the flat buffer (rows*cols"}, + {"rm", "rm of \"path\" → 1 on success, 0 on failure"}, + {"round", "round — builtin; see docs/BUILTINS.md"}, + {"sandbox_run", "sandbox_run of [descriptor, max_iterations?] — run an EigenScript-assembled"}, + {"scan_int_tokens", "scan_int_tokens of text"}, + {"scan_ints", "scan_ints of text"}, + {"scan_tokens", "scan_tokens of text"}, + {"screen_clear", "screen_clear of null — clear terminal and hide cursor"}, + {"screen_end", "screen_end of null — show cursor and reset"}, + {"screen_put", "screen_put of [row, col, char, color_code] — write a character at terminal position"}, + {"screen_render", "screen_render of [entities_list, screen_w, screen_h, player_x, player_y, world_w, world_h]"}, + {"secure_equals", "secure_equals of [a, b] → 1 if the two strings are equal, else 0."}, + {"seed_random", "seed_random of n → seeds the RNG, returns 1"}, + {"send", "send — builtin; see docs/BUILTINS.md"}, + {"set_at", "set_at of [list, index, value] — sets list[index] = value, returns list"}, + {"set_observer_thresholds", "set_observer_thresholds — builtin; see docs/BUILTINS.md"}, + {"sgd_update", "sgd_update of [param, grad, lr] — in-place param = param - lr * grad"}, + {"sgd_update_cols", "sgd_update_cols of [matrix, grad, col_indices, lr]"}, + {"sgd_update_rows", "sgd_update_rows of [matrix, grad, row_indices, lr]"}, + {"sha256", "sha256 — builtin; see docs/BUILTINS.md"}, + {"sha256_file", "sha256_file — builtin; see docs/BUILTINS.md"}, + {"shape", "shape of tensor → [rows, cols] for 2D, [len] for 1D"}, + {"shared_clear", "shared_clear — http_request extension builtin; see docs/BUILTINS.md"}, + {"shared_delete", "shared_delete — http_request extension builtin; see docs/BUILTINS.md"}, + {"shared_get", "shared_get — http_request extension builtin; see docs/BUILTINS.md"}, + {"shared_has", "shared_has — http_request extension builtin; see docs/BUILTINS.md"}, + {"shared_incr", "shared_incr — http_request extension builtin; see docs/BUILTINS.md"}, + {"shared_keys", "shared_keys — http_request extension builtin; see docs/BUILTINS.md"}, + {"shared_set", "shared_set — http_request extension builtin; see docs/BUILTINS.md"}, + {"shared_size", "shared_size — http_request extension builtin; see docs/BUILTINS.md"}, + {"sign_extend", "sign_extend of [val, bits] — sign-extend val from given bit width."}, + {"sin", "sin — builtin; see docs/BUILTINS.md"}, + {"softmax", "softmax — builtin; see docs/BUILTINS.md"}, + {"sort", "sort of list — in-place qsort of an all-number or all-string list."}, + {"sort_by", "sort_by of [list, key_fn] — sort list by numeric keys from key_fn."}, + {"spawn", "spawn — builtin; see docs/BUILTINS.md"}, + {"split", "split — builtin; see docs/BUILTINS.md"}, + {"sqrt", "sqrt — builtin; see docs/BUILTINS.md"}, + {"starts_with", "starts_with — builtin; see docs/BUILTINS.md"}, + {"state_at", "state_at — builtin; see docs/BUILTINS.md"}, + {"store_close", "store_close — builtin; see docs/BUILTINS.md"}, + {"store_collections", "store_collections — builtin; see docs/BUILTINS.md"}, + {"store_count", "store_count — builtin; see docs/BUILTINS.md"}, + {"store_delete", "store_delete — builtin; see docs/BUILTINS.md"}, + {"store_drop", "store_drop — builtin; see docs/BUILTINS.md"}, + {"store_get", "store_get — builtin; see docs/BUILTINS.md"}, + {"store_open", "store_open — builtin; see docs/BUILTINS.md"}, + {"store_put", "store_put — builtin; see docs/BUILTINS.md"}, + {"store_query", "store_query — builtin; see docs/BUILTINS.md"}, + {"store_update", "store_update — builtin; see docs/BUILTINS.md"}, + {"str", "str — builtin; see docs/BUILTINS.md"}, + {"str_from_bytes", "str_from_bytes of → string of those raw bytes."}, + {"str_lower", "str_lower — builtin; see docs/BUILTINS.md"}, + {"str_replace", "str_replace — builtin; see docs/BUILTINS.md"}, + {"str_upper", "str_upper — builtin; see docs/BUILTINS.md"}, + {"stream_close", "stream_close of null → closes the stream file, returns 1"}, + {"stream_open", "stream_open of [\"path\", count] → opens file, writes header with count, returns 1"}, + {"stream_write", "stream_write of value → writes one float64, returns 1"}, + {"substr", "substr of [string, start, length] → substring"}, + {"subtract", "subtract — builtin; see docs/BUILTINS.md"}, + {"sum", "sum — builtin; see docs/BUILTINS.md"}, + {"tan", "tan — builtin; see docs/BUILTINS.md"}, + {"task_alive", "task_alive of id → 1 while the task is READY/RUNNING/SUSPENDED, else 0"}, + {"task_detach", "task_detach of id -> 1 (0 for main/unknown). Marks the task fire-and-forget"}, + {"task_join", "task_join of id — block until task `id` finishes; return its (deep-copied)"}, + {"task_kill", "task_kill of id — deterministically tear down task `id`: drop its mailbox"}, + {"task_now", "task_now of null → the current virtual-clock value (a number, 0 before any"}, + {"task_recv", "task_recv of null — return the next message from this task's mailbox, or"}, + {"task_sched_seed", "task_sched_seed of n — install a scheduling seed. By default tasks run FIFO"}, + {"task_self", "task_self of null → the running task's id (a number, in the same integer"}, + {"task_send", "task_send of [id, value] — append a deep-copied message to task `id`'s"}, + {"task_sleep", "task_sleep of ticks — suspend the current task until the virtual clock"}, + {"task_spawn", "task_spawn of fn / task_spawn of [fn, arg1, ...] → task id (a number)."}, + {"task_try_recv", "task_try_recv of null — non-blocking receive: the next message, or null if"}, + {"task_yield", "task_yield of null — cooperatively hand control to the next ready task."}, + {"tensor_load", "tensor_load of path — load 1D or 2D tensor from binary file."}, + {"tensor_save", "tensor_save of [tensor, path] — save 1D or 2D list to binary file."}, + {"text_builder_append", "text_builder_append — builtin; see docs/BUILTINS.md"}, + {"text_builder_append_line", "text_builder_append_line — builtin; see docs/BUILTINS.md"}, + {"text_builder_clear", "text_builder_clear — builtin; see docs/BUILTINS.md"}, + {"text_builder_extend", "text_builder_extend — builtin; see docs/BUILTINS.md"}, + {"text_builder_new", "text_builder_new — builtin; see docs/BUILTINS.md"}, + {"text_builder_part_count", "text_builder_part_count — builtin; see docs/BUILTINS.md"}, + {"text_builder_to_string", "text_builder_to_string — builtin; see docs/BUILTINS.md"}, + {"thread_join", "thread_join — builtin; see docs/BUILTINS.md"}, + {"throw", "throw — builtin; see docs/BUILTINS.md"}, + {"token_name", "token_name of id → string name of token type (for display)"}, + {"tokenize_ids", "tokenize_ids of string → list of token type IDs (integers)."}, + {"tokenize_with_names", "tokenize_with_names of string → list of [type_id, name_str] pairs."}, + {"trim", "trim — builtin; see docs/BUILTINS.md"}, + {"try_parse", "try_parse of string → 1 if valid EigenScript syntax, 0 if not."}, + {"try_recv", "try_recv of channel — non-blocking receive, returns null if empty"}, + {"type", "type — builtin; see docs/BUILTINS.md"}, + {"usleep", "usleep of microseconds — pause execution"}, + {"values", "values — builtin; see docs/BUILTINS.md"}, + {"vm_run_bytecode", "vm_run_bytecode of — assemble a chunk (and its nested"}, + {"write", "write of value — output without trailing newline"}, + {"write_bytes", "write_bytes of [path, data, append?] — write raw bytes to a file."}, + {"write_text", "write_text of [\"path\", text] → 1 on success, 0 on failure."}, + {"zeros", "zeros of n → 1D list of n zeros"}, + {"zeros_like", "zeros_like — builtin; see docs/BUILTINS.md"}, + {"zlib_deflate", "zlib_deflate — builtin; see docs/BUILTINS.md"}, + {"zlib_inflate", "zlib_inflate — builtin; see docs/BUILTINS.md"}, + {0, 0} +}; diff --git a/src/main.c b/src/main.c index 996e9ccc..7309837c 100644 --- a/src/main.c +++ b/src/main.c @@ -86,6 +86,7 @@ int main(int argc, char **argv) { "\n" "Usage:\n" " eigenscript [args...] run a script (args readable via `args of null`)\n" + " eigenscript -e [args...] run a source string\n" " eigenscript start the REPL\n" " eigenscript --fmt [--write] format a source file (stdout, or rewrite with --write)\n" " eigenscript --lint [--json] [--lint-level error|warning] \n" @@ -292,6 +293,17 @@ int main(int argc, char **argv) { return repl_exit_code; } + /* Source strings use the same parse/compile/execute path as files. + * Keep argv[1] as the source identity and strip only the source argument + * from the script-visible args; relative loads start at the caller cwd. */ + int source_string = strcmp(argv[1], "-e") == 0; + if (source_string && argc < 3) { + fprintf(stderr, "Usage: eigenscript -e [args...]\n"); + eigs_thread_detach(); + eigs_state_destroy(eigs_st); + return 1; + } + /* Extract script directory for load_file resolution. g_script_dir * is an EigsState bridge macro — state is already attached above. */ { @@ -301,7 +313,8 @@ int main(int argc, char **argv) { } long src_size = 0; - char *source = read_file_util(argv[1], &src_size); + char *source = source_string ? xstrdup(argv[2]) + : read_file_util(argv[1], &src_size); if (!source) { fprintf(stderr, "Error: cannot read file '%s'\n", argv[1]); eigs_thread_detach(); @@ -309,6 +322,10 @@ int main(int argc, char **argv) { return 1; } + if (source_string) { + for (int i = 2; i + 1 < argc; i++) argv[i] = argv[i + 1]; + argv[--argc] = NULL; + } srand(time(NULL)); eigenscript_set_args(argc, argv); diff --git a/src/parser.c b/src/parser.c index 307883b5..91be54df 100644 --- a/src/parser.c +++ b/src/parser.c @@ -161,8 +161,34 @@ static void p_end_statement(Parser *p) { p_match(p, TOK_NEWLINE); } +/* #1102: one reservation rule for every parser entry point. These word + * tokens are never identifiers; only the observer-call and dot-key grammar + * consume them successfully. Keep the diagnostic on the name token, including + * multiline parameter lists and patterns. */ +static int tok_is_report(TokType type) { + return type == TOK_REPORT || type == TOK_REPORT_VALUE; +} + +static int p_report_error(Token *t, int operand) { + if (!tok_is_report(t->type)) return 0; + char msg[192]; + snprintf(msg, sizeof(msg), "'%s' is a reserved observer form; %s", + t->str_val, operand ? "requires a variable name operand" + : "use it with 'of variable', never as a binding"); + fprintf(stderr, "Parse error line %d:%d: %s [E005]\n", + t->line, t->col + 1, msg); + eigs_record_first_error_code_at(t->line, t->col, t->len, "E005", msg); + p_print_caret(t->line, t->col); + g_parse_errors++; + return 1; +} + static void p_expect(Parser *p, TokType type) { if (p_cur(p)->type != type) { + if (p_report_error(p_cur(p), 0)) { + p_advance(p); + return; + } fprintf(stderr, "Parse error line %d:%d: expected %s, got %s", p_cur(p)->line, p_cur(p)->col + 1, tok_type_name(type), tok_type_name(p_cur(p)->type)); if (p_cur(p)->str_val) fprintf(stderr, " ('%s')", p_cur(p)->str_val); @@ -912,6 +938,15 @@ static ASTNode* parse_primary(Parser *p) { return make_node(AST_NULL, p_cur(p)->line); } + if (tok_is_report(t->type)) { + p_advance(p); + if (p_cur(p)->type != TOK_OF) p_report_error(t, 0); + ASTNode *n = make_node_col(AST_IDENT, t->line, t->col); + n->data.ident.name = xstrdup(t->str_val); + set_name_hash(n, n->data.ident.name); + return n; + } + if (t->type == TOK_IDENT) { p_advance(p); ASTNode *n = make_node_col(AST_IDENT, t->line, t->col); @@ -926,10 +961,10 @@ static ASTNode* parse_primary(Parser *p) { int is_lambda = 0; p_advance(p); /* skip ( */ /* Scan forward: if we see IDENT [, IDENT]* ) => then it's a lambda */ - if (tok_is_ident_like(p_cur(p)->type) || p_cur(p)->type == TOK_RPAREN) { + if (tok_is_ident_like(p_cur(p)->type) || tok_is_report(p_cur(p)->type) || p_cur(p)->type == TOK_RPAREN) { int scan = p->pos; while (scan < p->tl->count && - (tok_is_ident_like(p->tl->tokens[scan].type) || p->tl->tokens[scan].type == TOK_COMMA)) + (tok_is_ident_like(p->tl->tokens[scan].type) || tok_is_report(p->tl->tokens[scan].type) || p->tl->tokens[scan].type == TOK_COMMA)) scan++; if (scan + 1 < p->tl->count && p->tl->tokens[scan].type == TOK_RPAREN && p->tl->tokens[scan+1].type == TOK_ARROW) @@ -942,7 +977,8 @@ static ASTNode* parse_primary(Parser *p) { char **params = xmalloc_array(MAX_PARAMS, sizeof(char*)); int param_count = 0; int lambda_cap_reported = 0; - while (tok_is_ident_like(p_cur(p)->type)) { + while (tok_is_ident_like(p_cur(p)->type) || tok_is_report(p_cur(p)->type)) { + p_report_error(p_cur(p), 0); if (param_count >= MAX_PARAMS) { /* #354: one loud diagnostic, then drain (see match). */ if (!lambda_cap_reported) { @@ -1158,6 +1194,7 @@ static int chain_too_deep(Parser *p) { } static ASTNode* parse_relation(Parser *p) { + Token *callee = p_cur(p); ASTNode *left = parse_primary(p); if (p_cur(p)->type == TOK_OF) { @@ -1168,6 +1205,8 @@ static ASTNode* parse_relation(Parser *p) { * absorbing trailing infix arithmetic: `len of xs - 1` now * parses as `(len of xs) - 1`, not `len of (xs - 1)`. */ ASTNode *right = parse_unary(p); + if (tok_is_report(callee->type) && right && right->type != AST_IDENT) + p_report_error(callee, 1); ASTNode *n = make_node_col(AST_RELATION, op_tok->line, op_tok->col); n->data.relation.left = left; n->data.relation.right = right; @@ -1448,7 +1487,8 @@ static ASTNode* parse_statement_inner(Parser *p) { params = xmalloc_array(MAX_PARAMS, sizeof(char*)); defaults = xcalloc(MAX_PARAMS, sizeof(ASTNode*)); int param_cap_reported = 0; - while (tok_is_ident_like(p_cur(p)->type)) { + while (tok_is_ident_like(p_cur(p)->type) || tok_is_report(p_cur(p)->type)) { + p_report_error(p_cur(p), 0); if (param_count >= MAX_PARAMS) { /* #354: one loud diagnostic, then drain (see match). */ if (!param_cap_reported) { @@ -1790,11 +1830,13 @@ static ASTNode* parse_statement_inner(Parser *p) { char *names_tmp[64]; for (;;) { if (p_cur(p)->type != TOK_IDENT) { - fprintf(stderr, - "Parse error line %d: destructuring pattern requires " - "identifiers (index/field targets like a[0] or a.x " - "are not supported)\n", p_cur(p)->line); - g_parse_errors++; + if (!p_report_error(p_cur(p), 0)) { + fprintf(stderr, + "Parse error line %d: destructuring pattern requires " + "identifiers (index/field targets like a[0] or a.x " + "are not supported)\n", p_cur(p)->line); + g_parse_errors++; + } for (int k = 0; k < n; k++) free(names_tmp[k]); while (p_cur(p)->type != TOK_NEWLINE && p_cur(p)->type != TOK_EOF) p_advance(p); diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index b45522a5..45574c93 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -2045,6 +2045,23 @@ else fi echo "" +# #1102: reserved observer forms. Require the complete fixture population as +# well as its exit status; a partial run cannot silently reduce this section. +echo "[42a] Reserved observer forms (#1102)" +REPORT_OUT=$(bash "$TESTS_DIR/test_report_reserved.sh" 2>&1); REPORT_RC=$? +REPORT_PASS=$(echo "$REPORT_OUT" | grep -c "^PASS:" || true) +REPORT_FAIL=$(echo "$REPORT_OUT" | grep -c "^FAIL:" || true) +TOTAL=$((TOTAL + 1)) +if [ "$REPORT_RC" -eq 0 ] && [ "$REPORT_PASS" -eq 209 ] && [ "$REPORT_FAIL" -eq 0 ]; then + PASS=$((PASS + 1)) + echo " PASS: all $REPORT_PASS reserved observer checks" +else + FAIL=$((FAIL + 1)) + echo " FAIL: reserved observer forms (rc=$REPORT_RC, $REPORT_PASS/209 checks passed)" + echo "$REPORT_OUT" | tail -20 +fi +echo "" + # #971: strict math mode (EIGS_STRICT) — domain ops raise instead of clamping. echo "Strict math mode (EIGS_STRICT domain-op raises)" SM_OUTPUT=$(bash "$TESTS_DIR/test_strict_math.sh" 2>&1) @@ -3822,13 +3839,14 @@ check "gate CLOSES on a program with no observer surface" "$OBS_G1" "1" # 3. And OPEN on a direct observer surface. OBS_G2=$(EIGS_OBS_GATE_STATS=1 $EIGS_BIN "$TESTS_DIR/test_observer_level_set.eigs" 2>&1 | grep -c 'obs-gate: observed') check "gate OPENS on a direct observer surface" "$OBS_G2" "1" -# 4. And OPEN on the INDIRECT form. `local r is report` emits NO reader opcode — +# 4. And OPEN on the INDIRECT form. `local r is observe` emits NO reader opcode — # it compiles to GET_NAME + CALL — so this passes only because the scan also # matches observer-read builtin names in the constant pool. An opcode-only -# scan reports "unobserved" here and silently breaks every aliased report. -printf 'x is 1.0\nlocal r is report\nx is 2.0\nprint of (r of x)\n' > "$OBS_GATE_TMP/alias.eigs" +# scan reports "unobserved" here and silently breaks aliased observer reads. +# #1102: report is now reserved; observe still exercises the same mechanism. +printf 'x is 1.0\nlocal r is observe\nx is 2.0\nprint of (r of x)\n' > "$OBS_GATE_TMP/alias.eigs" OBS_G3=$(EIGS_OBS_GATE_STATS=1 $EIGS_BIN "$OBS_GATE_TMP/alias.eigs" 2>&1 | grep -c 'obs-gate: observed') -check "gate OPENS on an aliased report (no reader opcode emitted)" "$OBS_G3" "1" +check "gate OPENS on an aliased observe (no reader opcode emitted)" "$OBS_G3" "1" # 5. The escape hatch, which is also the baseline arm for perf work: ONE # byte-identical binary serves both arms, so a measurement cannot be # confounded by a second build. diff --git a/tests/test_cli.sh b/tests/test_cli.sh index 8f229a63..b17088e4 100755 --- a/tests/test_cli.sh +++ b/tests/test_cli.sh @@ -180,6 +180,36 @@ else fail "CLI15 div-zero raises" "rc=$RC out='$OUT'" fi +# ---- #1102: source-string execution shares the file compilation path ---- +OUT=$("$EIGS" -e 'print of (1 + 2)' 2>&1) +RC=$? +if [ "$RC" -eq 0 ] && [ "$OUT" = "3" ]; then + ok "CLI16 -e executes a source string" +else + fail "CLI16 -e executes" "rc=$RC out='$OUT'" +fi +OUT=$("$EIGS" -e 'print of (len of (args of null))' first second 2>&1) +RC=$? +if [ "$RC" -eq 0 ] && [ "$OUT" = "2" ]; then + ok "CLI17 -e passes script args without source text" +else + fail "CLI17 -e args" "rc=$RC out='$OUT'" +fi +OUT=$("$EIGS" -e 'exit of 7' 2>&1) +RC=$? +if [ "$RC" -eq 7 ] && [ -z "$OUT" ]; then + ok "CLI18 -e preserves explicit exit status" +else + fail "CLI18 -e exit" "rc=$RC out='$OUT'" +fi +OUT=$("$EIGS" -e 2>&1) +RC=$? +if [ "$RC" -eq 1 ] && [ "$OUT" = "Usage: eigenscript -e [args...]" ]; then + ok "CLI19 -e missing source is a usage error" +else + fail "CLI19 -e missing source" "rc=$RC out='$OUT'" +fi + # ---- Summary ---- echo "" echo "CLI: $PASS passed, $FAIL failed" diff --git a/tests/test_lint.sh b/tests/test_lint.sh index 0263736d..97c622e3 100644 --- a/tests/test_lint.sh +++ b/tests/test_lint.sh @@ -468,14 +468,14 @@ TMPFILE=$(mktemp /tmp/lint_test_XXXXXX.eigs) cat > "$TMPFILE" << 'EIGS' define dispatch(a, b, c) as: return 999 -define report(v) as: +define observe(v) as: return v chr is 7 print of "hi" EIGS OUTPUT=$($EIGS --lint "$TMPFILE" 2>&1 || true) check_contains "W013 fires on define dispatch (#459)" "$OUTPUT" "W013.*'dispatch'" -check_contains "W013 fires on define report (observer special form)" "$OUTPUT" "W013.*'report'" +check_contains "W013 fires on define observe (observer special form)" "$OUTPUT" "W013.*'observe'" check_contains "W012 fires on a registry-only builtin (chr)" "$OUTPUT" "W012.*'chr'" rm -f "$TMPFILE" diff --git a/tests/test_lsp.py b/tests/test_lsp.py index 601f8c79..a282d37f 100755 --- a/tests/test_lsp.py +++ b/tests/test_lsp.py @@ -174,6 +174,16 @@ def main(): check("diagnostic severity is error (1)", bool(d) and d[0]["severity"] == 1) check("diagnostic mentions expected colon", bool(d) and "expected ':'" in d[0]["message"]) + # #1102: parser code and exact offending token survive LSP transport. + for name in ("report", "report_value"): + source = "define f(\n " + name + "\n) as:\n return 0\n" + d = diagnostics(converse([INIT, did_open(source), SHUTDOWN, EXIT])) + check(name + " reserved observer error code/range (#1102)", + bool(d) and d[0].get("code") == "E005" and d[0]["severity"] == 1 + and "reserved observer form" in d[0]["message"] + and d[0]["range"]["start"] == {"line": 1, "character": 4} + and d[0]["range"]["end"] == {"line": 1, "character": 4 + len(name)}) + # --- unexpected character → diagnostic naming the char --- r = converse([INIT, did_open("x is @\n"), SHUTDOWN, EXIT]) d = diagnostics(r) @@ -237,6 +247,16 @@ def main(): # `undefined variable: input` at runtime. check("completion does not advertise a phantom 'input' builtin", isinstance(items, list) and not any(it.get("label") == "input" for it in items)) + # #1102: the runtime's bytecode compatibility registry is broader than + # the source builtin surface. Check the actual editor response, including + # a callable control so dropping all builtins cannot pass these checks. + check("completion still offers the callable print builtin", + isinstance(items, list) and any(it.get("label") == "print" and + it.get("kind") == 3 for it in items)) + for name in ("report", "report_value"): + check("completion excludes reserved observer form '" + name + "'", + isinstance(items, list) and not any(it.get("label") == name + for it in items)) # --- #590: stdlib (lib/) completion + hover from the generated index --- # Completion is import-aware: the document's own `import`s scope which diff --git a/tests/test_opaque_fn.eigs b/tests/test_opaque_fn.eigs index 4a57f9d3..6f55effc 100644 --- a/tests/test_opaque_fn.eigs +++ b/tests/test_opaque_fn.eigs @@ -5,6 +5,8 @@ # itself is unchanged: containers and numeric trajectories measure as # before; this file pins both halves. +load_file of "lib/test.eigs" + define a() as: return 1 define b(p, q, r) as: @@ -13,38 +15,39 @@ define b(p, q, r) as: # ---- report/report_value/predicates/observe on a rebound fn binding ---- f is a f is b -assert of [(report of f) == "opaque", "OP01 report of a fn binding is opaque"] -assert of [(report_value of f) == "opaque", "OP02 report_value is opaque too"] -assert of [(equilibrium of f) == 0, "OP03 equilibrium predicate is false"] -assert of [(converged of f) == 0, "OP04 converged predicate is false"] -assert of [(stable of f) == 0, "OP05 stable predicate is false"] +assert_true of [(report of f) == "opaque", "OP01 report of a fn binding is opaque"] +assert_true of [(report_value of f) == "opaque", "OP02 report_value is opaque too"] +assert_true of [(equilibrium of f) == 0, "OP03 equilibrium predicate is false"] +assert_true of [(converged of f) == 0, "OP04 converged predicate is false"] +assert_true of [(stable of f) == 0, "OP05 stable predicate is false"] obs is observe of f -assert of [obs[0] == "opaque", "OP06 observe band is opaque"] +assert_true of [obs[0] == "opaque", "OP06 observe band is opaque"] # ---- a function named directly, and a builtin binding ---- -assert of [(report of a) == "opaque", "OP07 report of a define'd name is opaque"] +assert_true of [(report of a) == "opaque", "OP07 report of a define'd name is opaque"] g is print -assert of [(report of g) == "opaque", "OP08 a builtin binding is opaque"] +assert_true of [(report of g) == "opaque", "OP08 a builtin binding is opaque"] -# ---- the value-operand fallback path (no binding) ---- +# ---- extract a value to a binding before reporting (#1102) ---- hs is [a] -assert of [(report of hs[0]) == "opaque", "OP09 fn value operand is opaque"] +held_fn is hs[0] +assert_true of [(report of held_fn) == "opaque", "OP09 extracted fn binding is opaque"] # ---- bare predicate reads the last-observed binding ---- f2 is a f2 is b -assert of [equilibrium == 0, "OP10 bare predicate on a fn binding is false"] +assert_true of [equilibrium == 0, "OP10 bare predicate on a fn binding is false"] # ---- the check is ask-time: rebinding to a number answers again ---- f is 42 -assert of [((report of f) == "opaque") == 0, "OP11 number rebind is not opaque"] +assert_true of [((report of f) == "opaque") == 0, "OP11 number rebind is not opaque"] # ---- numeric bindings and containers are untouched ---- n is 1 n is 2 n is 3 -assert of [((report of n) == "opaque") == 0, "OP12 numeric binding classifies"] +assert_true of [((report of n) == "opaque") == 0, "OP12 numeric binding classifies"] d is {"h": a} -assert of [((report of d) == "opaque") == 0, "OP13 container holding a fn is not opaque"] +assert_true of [((report of d) == "opaque") == 0, "OP13 container holding a fn is not opaque"] -print of "All tests passed" +test_summary of null diff --git a/tests/test_report_reserved.sh b/tests/test_report_reserved.sh new file mode 100644 index 00000000..3e9be9e8 --- /dev/null +++ b/tests/test_report_reserved.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# #1102: language-level reservation, through the real compile entry points. +# Every rejection checks status, diagnostic identity/location, and absence of +# executed statements. Controls ensure the source templates are valid programs. +set -eu +TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" +EIGS="${EIGS:-$TESTS_DIR/../src/eigenscript}" +python3 - "$EIGS" <<'PY' +import json +import pathlib +import re +import subprocess +import sys +import tempfile + +binary = str(pathlib.Path(sys.argv[1]).resolve()) +passed = failed = 0 + +def check(label, ok, result=None): + global passed, failed + if ok: + passed += 1 + print('PASS: ' + label) + else: + failed += 1 + print('FAIL: ' + label) + if result is not None: + print(f' rc={result.returncode} stdout={result.stdout[:180]!r} stderr={result.stderr[:300]!r}') + +def run(args, stdin=None): + return subprocess.run([binary, *args], input=stdin, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd=work, timeout=30) + +def clean(r): + return not re.search(r'AddressSanitizer|LeakSanitizer|runtime error:|UndefinedBehaviorSanitizer', r.stdout + r.stderr) + +# Tuple = construct, valid source with NAME substituted, offending source line. +# Anchored to parser binding families: FUNC, LAMBDA, ASSIGN/LOCAL, FOR, +# TRY, LISTCOMP, destructuring, IMPORT; match patterns are expression +# reads, not binders. Additional +# shapes exercise lookahead, multiline positions, and nonexecuted branches. +shapes = [ + ('define', 'define NAME(v) as:\n return "mine"\nx is 1\nx is 2\nprint of (NAME of x)\nprint of (NAME of 5)\n', 1), + ('implicit parameter define', 'define NAME as:\n return n\n', 1), + ('assignment', 'NAME is 5\n', 1), + ('local', 'local NAME is 5\n', 1), + ('compound assignment', 'NAME += 1\n', 1), + ('parameter', 'define f(NAME) as:\n return 0\n', 1), + ('second parameter', 'define f(a, NAME) as:\n return 0\n', 1), + ('default parameter', 'define f(NAME is 3) as:\n return 0\n', 1), + ('multiline parameter', 'define f(\n a,\n NAME\n) as:\n return 0\n', 3), + ('lambda parameter', 'f is (NAME) => 0\n', 1), + ('second lambda parameter', 'f is (a, NAME) => 0\n', 1), + ('multiline lambda parameter', 'f is (a,\n NAME) => 0\n', 2), + ('for binder', 'for NAME in [1, 2]:\n 0\n', 1), + ('catch binder', 'try:\n 0\ncatch NAME:\n 0\n', 3), + ('list match name', 'match [1, 2]:\n case [NAME, tail]:\n 0\n', 2), + ('second list match name', 'match [1, 2]:\n case [head, NAME]:\n 0\n', 2), + ('comprehension binder', 'xs is [0 for NAME in [1, 2]]\n', 1), + ('filtered comprehension binder', 'xs is [0 for NAME in [1, 2] if 1]\n', 1), + ('destructure first', '[NAME, tail] is [1, 2]\n', 1), + ('destructure second', '[head, NAME] is [1, 2]\n', 1), + ('import binder', 'import NAME\n', 1), + ('cold branch', 'if 0:\n NAME is 5\n', 2), + ('nested local', 'define f(x) as:\n local NAME is 5\n', 2), + ('unobserved', 'unobserved:\n NAME is 5\n', 2), +] + +with tempfile.TemporaryDirectory(prefix='eigs_report_reserved_') as tmp: + work = pathlib.Path(tmp) + (work / 'user_report.eigs').write_text('value is 1\n') + source_file = work / 'program.eigs' + for label, template, line in shapes: + control = template.replace('NAME', 'user_report') + if 'list match name' in label: + control = 'user_report is 1\nhead is 1\ntail is 2\n' + control + if label == 'compound assignment': + control = 'user_report is 0\n' + control + source_file.write_text(control) + r = run([str(source_file)]) + check('control ' + label, r.returncode == 0 and clean(r), r) + for name in ('report', 'report_value'): + src = 'print of "EXECUTED"\n' + template.replace('NAME', name) + source_file.write_text(src) + for mode, args in [('file', [str(source_file)]), ('-e', ['-e', src]), + ('lint', ['--lint', '--json', str(source_file)])]: + r = run(args) + ok = r.returncode == 1 and clean(r) + if mode == 'lint': + try: + ds = json.loads(r.stdout) + ok &= len(ds) == 1 and ds[0]['code'] == 'E005' and ds[0]['severity'] == 'error' + ok &= ds[0]['line'] == line + 1 and f"'{name}' is a reserved observer form" in ds[0]['message'] + except (ValueError, KeyError, TypeError, IndexError): + ok = False + else: + ok &= r.stdout == '' and re.search(rf'^Parse error line {line + 1}:\d+: \'{name}\' is a reserved observer form.*\[E005\]$', r.stderr, re.M) is not None + check(f'{name}: {label} / {mode}', ok, r) + + # All RHS shapes other than an optionally parenthesized identifier are + # errors, including bare call arg lists at every cardinality. + for name in ('report', 'report_value'): + for operand in ('5', '(x + 1)', 'x[0]', 'd.key', '[x]', '[]', '[x, x]', + '([x])', '(len of x)', '"literal"', 'null', '((x + 0))'): + src = f'print of "EXECUTED"\nx is [1, 2]\nd is {{"key": 1}}\nprint of ({name} of {operand})\n' + r = run(['-e', src]) + check(f'{name}: operand {operand}', r.returncode == 1 and r.stdout == '' and clean(r) + and f"'{name}' is a reserved observer form; requires a variable name operand" in r.stderr + and '[E005]' in r.stderr, r) + for expr in (name, f'({name})', f'5 |> {name}'): + r = run(['-e', f'print of "EXECUTED"\nf is {expr}\n']) + check(f'{name}: cannot take first-class form {expr}', r.returncode == 1 and r.stdout == '' and clean(r) + and 'reserved observer form' in r.stderr and '[E005]' in r.stderr, r) + + inner = f'print of "EXECUTED"\n{name} is 4\n' + (work / 'badmodule.eigs').write_text(inner) + for label, outer in [('eval', 'eval of ' + json.dumps(inner)), + ('load_file', 'load_file of "badmodule.eigs"'), + ('import', 'import badmodule')]: + source_file.write_text(outer + '\n') + r = run([str(source_file)]) + check(f'{name}: {label}', r.returncode == 1 and r.stdout == '' and clean(r) + and re.search(rf'^Parse error line 2:\d+: \'{name}\' is a reserved observer form.*\[E005\]$', r.stderr, re.M) is not None, r) + # REPL accepts another unit after a rejected unit (its exit status is + # 0 for other reserved-word syntax errors too). + r = run([], f'{name} is 4\nprint of "RECOVERED"\nexit\n') + check(f'{name}: REPL rejection and recovery', r.returncode == 0 and clean(r) + and 'reserved observer form' in r.stderr and '[E005]' in r.stderr + and 'RECOVERED' in r.stdout, r) + + # Pin the unchanged name/slot operands, precedence and opaque band. Fields + # named after keywords are still data keys, never lexical bindings. + valid = '''x is 1 +x is 2 +print of (report of x) +print of (report_value of x) +print of (report of (x)) +print of (report_value of ((x))) +print of (report of x + "!") +define f(v) as: + v is 1 + v is 2 + print of (report of v) + print of (report_value of v) +f of 0 +print of (report of f) +print of (report_value of f) +d is {"report": 7, "report_value": 8} +print of d.report +print of d.report_value +d.report is 9 +d.report_value += 2 +print of d.report +print of d.report_value +print of f"{report of x}:{report_value of x}" +load_file of "lib/eigen.eigs" +print of (eigen_run of "report of 5") +print of (eigen_run of "report of print") +''' + expected = 'moving\nmoving\nmoving\nmoving\nmoving!\nmoving\nmoving\nopaque\nopaque\n7\n8\n9\n10\nmoving:moving\nequilibrium\nopaque\n' + source_file.write_text(valid) + r = run([str(source_file)]) + check('identifier operands and field keys', r.returncode == 0 and r.stdout == expected and clean(r), r) + r = run(['--fmt', str(source_file)]) + ok = r.returncode == 0 and clean(r) + source_file.write_text(r.stdout) + rr = run([str(source_file)]) + check('formatted observer forms run unchanged', ok and rr.returncode == 0 and rr.stdout == expected and clean(rr), rr) + rr = run(['--fmt', str(source_file)]) + check('formatter is idempotent', ok and rr.returncode == 0 and rr.stdout == r.stdout and clean(rr), rr) + +print(f'RESULTS: {passed}/{passed + failed} passed, {failed} failed (reserved observer forms)') +sys.exit(bool(failed)) +PY diff --git a/tests/test_vm_run_bytecode.eigs b/tests/test_vm_run_bytecode.eigs index e9f447df..008afab2 100644 --- a/tests/test_vm_run_bytecode.eigs +++ b/tests/test_vm_run_bytecode.eigs @@ -44,6 +44,11 @@ assert_eq of [vm_run_bytecode of [ABI, [CONST,0,0, CONST,1,0, GT, RETURN], [10, # builtin call: len of "hello" -> 5 (GET_NAME resolves "len" in the global env) assert_eq of [vm_run_bytecode of [ABI, [GET_NAME,0,0, CONST,1,0, CALL,1,0, RETURN], ["len", "hello"]], 5, "builtin call via GET_NAME+CALL"] +# #1102: report is reserved in source, but existing bytecode can still call +# its value-only compatibility entry. Neither path reads a binding trajectory. +assert_eq of [vm_run_bytecode of [ABI, [GET_NAME,0,0, CONST,1,0, CALL,1,0, RETURN], ["report", 5]], "equilibrium", "report compatibility builtin remains reachable from bytecode"] +assert_eq of [vm_run_bytecode of [ABI, [GET_NAME,0,0, GET_NAME,1,0, CALL,1,0, RETURN], ["report", "print"]], "opaque", "report compatibility builtin still classifies callable values"] + # control flow: max(a, b) via JUMP_IF_FALSE (relative forward offset) # CONST a; CONST b; GT; JUMP_IF_FALSE +4; CONST a; RETURN; CONST b; RETURN maxcode is [CONST,0,0, CONST,1,0, GT, JUMP_IF_FALSE,4,0, CONST,0,0, RETURN, CONST,1,0, RETURN] diff --git a/tools/gen_lsp_builtin_index.sh b/tools/gen_lsp_builtin_index.sh index 356e5f1f..7cf44d29 100755 --- a/tools/gen_lsp_builtin_index.sh +++ b/tools/gen_lsp_builtin_index.sh @@ -23,9 +23,11 @@ # definition never wrote one fall back to a docs/BUILTINS.md pointer — # back-filling those comments is upstream work, not papered over here. # -# The generated header is a build artifact, NOT committed (the -# lsp_stdlib_index.h / amalgamation precedent, #397): the Makefile `lsp` -# target and `build.sh lsp` regenerate it before compiling eigenlsp. +# Reserved report words come from the lexer's keyword registrations (#1102). +# The runtime retains a report builtin for bytecode compatibility, but source +# programs cannot use it as a function, so it must not be a Function completion. +# The generated header is committed for review; the Makefile `lsp` target and +# `build.sh lsp` regenerate it before compiling eigenlsp. # # Usage: tools/gen_lsp_builtin_index.sh [output-header] # default output: src/lsp_builtin_index.h @@ -50,6 +52,13 @@ TMP_NAMES=$(mktemp) TMP_OUT=$(mktemp) trap 'rm -f "$TMP_NAMES" "$TMP_OUT"' EXIT +# Use the language's reservation site, not another hand-maintained word list. +RESERVED_REPORT_NAMES=$(sed -nE 's/.*strcmp\(word, "([^"]+)"\).*return TOK_REPORT(_VALUE)?;.*/\1/p' src/lexer.c | tr '\n' ' ') +if [ "$(printf '%s\n' "$RESERVED_REPORT_NAMES" | wc -w)" -ne 2 ]; then + echo "gen_lsp_builtin_index: expected two reserved observer words from lexer" >&2 + exit 1 +fi + # ---- 1. core names from the registration seams ------------------------- # Line shape: env_set_local_owned(env, "name", make_builtin(fn)); grep -h 'env_set_local_owned(env, "' $CORE_SRCS \ @@ -80,7 +89,7 @@ if [ "$total" -eq 0 ]; then fi # ---- 3. emit, resolving each name's signature comment ------------------ -sort -u -t"$(printf '\t')" -k1,1 "$TMP_NAMES" | awk -v OUT="$TMP_OUT" -v docs="$DOC_SRCS" ' +sort -u -t"$(printf '\t')" -k1,1 "$TMP_NAMES" | awk -v OUT="$TMP_OUT" -v docs="$DOC_SRCS" -v reserved="$RESERVED_REPORT_NAMES" ' function c_escape(s) { gsub(/\\/, "\\\\", s) gsub(/"/, "\\\"", s) @@ -88,6 +97,8 @@ function c_escape(s) { } BEGIN { FS = "\t" + n = split(reserved, words, /[ \t\n]+/) + for (i = 1; i <= n; i++) excluded[words[i]] = 1 # Preload every signature-comment line from the runtime TUs: # a comment line whose text starts `name of ` (after comment markers). n = split(docs, files, /[ \t]+/) @@ -106,12 +117,14 @@ BEGIN { print "/* Generated by tools/gen_lsp_builtin_index.sh — DO NOT EDIT (#742)." > OUT print " * Names come from the registration seams + ext_names.h; hover text" > OUT print " * from the `name of ...` signature comment above each definition." > OUT + print " * Reserved report forms are excluded using the lexer keyword table." > OUT print " * Regenerated by the Makefile lsp target and build.sh lsp. */" > OUT print "static const char *builtin_docs[][2] = {" > OUT core = 0; ext = 0; documented = 0 } { name = $1; group = $2 + if (name in excluded) next if (name in sig) { detail = sig[name] documented++