Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,35 @@ All notable changes to EigenScript are documented here.

### Changed

- **`null.field` and `null["k"]` raise instead of yielding `null`
(#872).** The contract makes dict-miss-returns-`null` a deliberate
decision — a missing key is a lookup miss, not a logic error — but
that rationale covers a *dict*, and `null` was the one non-dict type
on which field access silently succeeded. So a typo'd config path
propagated through arbitrary depth (`cfg.databse.host` → `null` →
`null`) and surfaced somewhere unrelated, or nowhere. Walking through
a miss now fails at the miss. A dict's own miss is unchanged and still
`null`, on purpose. Fifteen access sites in `vm.c` carried the
`!= VAL_NULL` exemption; all of them are gone, and the full suite
passes unchanged — nothing depended on the absorption.

- **A discarded interrogative is a compile error (#869).** `what is 42`
reads as an assignment, parses as a question about the literal `42`,
and had **no effect at all**: the program ran to completion at rc=0
with nothing on stderr. Only `--lint` caught it, and `what`, `when`
and `where` are plausible variable names in exactly the domains this
language targets. Every neighbouring mistake is loud — an unresolved
name is fatal, `break` outside a loop is a compile error — so this was
the odd one out.
The check keys on the **discard**, not on the syntax, which is what
makes it safe: the REPL and `eval` compile a unit whose last statement
*is* the result, so `what is x` typed at the REPL still answers `=> 5`
and interrogatives in expression position are untouched. Only a value
nobody can read is refused. (A discarded interrogative as a unit's
final statement is therefore still lint-only.) The interrogative word
table is now shared between lint and the compiler rather than
duplicated.

- **JSON is a lossless round-trip for every number (#875).** The
contract promises `num of (str of x) == x`. That held for `str of` and
for nothing else: `json_encode`, `json_build` and `json_path` each
Expand Down Expand Up @@ -270,6 +299,12 @@ All notable changes to EigenScript are documented here.
naming the file used and the file shadowed. Sweep of the repo and all
15 consumer repos found zero imports whose resolution flips.

- **`LANGUAGE_CONTRACT.md`'s recommended midpoint index raised (#867).**
The Indexing promise recommended `a[floor of (lo + hi) / 2]`, but `of`
binds tighter than `/` — per the precedence table 25 lines above in
the same file — so it parsed as `(floor of (lo + hi)) / 2` and raised
`index must be an integer, got 1.5`. Now `a[floor of ((lo + hi) / 2)]`.

- **vm_run_bytecode/sandbox_run: an assembled chunk's temporal opcodes
now turn history recording on themselves (#831).** `g_trace_hist` was
set only by the bytecode compiler's source scan, so a descriptor
Expand Down
13 changes: 10 additions & 3 deletions docs/LANGUAGE_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ unary and `of` are right-associative.
are accepted (`a[2.0]` works), since EigenScript has a single number type;
but a fractional value is never silently truncated. Because `/` always
yields a double, a division-derived index must be collapsed explicitly —
`a[floor of (lo + hi) / 2]` — which keeps the rounding decision in the
`a[floor of ((lo + hi) / 2)]` — which keeps the rounding decision in the
programmer's hands. A value that is fractional only through float drift
(`2.9999998`) also raises, surfacing the sloppy arithmetic rather than
mis-indexing.
Expand Down Expand Up @@ -482,8 +482,15 @@ list index is a logic error. Both forms of dict access (`d.k` and `d["k"]`)
agree. Use `has_key of [d, "k"]` to test membership when `null` is itself a
valid stored value.

**Status:** Enforced — `tests/test_dict.eigs`, `OP_DOT_GET` /
`OP_INDEX_GET` in `vm.c`.
That rationale covers a **dict**. It does not cover `null`, which is not a
dict — so `null.k` and `null["k"]` **raise**, like field access on any other
non-dict (#872). This is what keeps a typo'd path from propagating: `d.mising`
is `null` at the miss, and walking through it (`d.mising.deeper`) fails
*there* rather than yielding `null` through arbitrary depth and surfacing
somewhere unrelated, or nowhere.

**Status:** Enforced — `tests/test_dict.eigs` (incl. the null-receiver
cases), `OP_DOT_GET` / `OP_INDEX_GET` in `vm.c`.

## Statistics convention (library)

Expand Down
39 changes: 37 additions & 2 deletions src/compiler.c
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ static void name_set_remove(NameSet *s, const char *name) {
static void compile_node(Compiler *c, ASTNode *node);
static void compile_node_inner(Compiler *c, ASTNode *node);
static void compile_block(Compiler *c, ASTNode **stmts, int count);
static void check_discarded_interrogative(ASTNode *stmt); /* #869 */

/* ---- Loop-context stack (#335/#336) ----
* Push/pop are strictly balanced within each AST_LOOP/AST_FOR case, so the
Expand Down Expand Up @@ -2007,8 +2008,10 @@ static void compile_node_inner(Compiler *c, ASTNode *node) {
case AST_PROGRAM: {
for (int i = 0; i < node->data.program.count; i++) {
compile_node(c, node->data.program.stmts[i]);
if (i + 1 < node->data.program.count)
if (i + 1 < node->data.program.count) {
check_discarded_interrogative(node->data.program.stmts[i]); /* #869 */
emit(c, OP_POP, node->line);
}
}
break;
}
Expand Down Expand Up @@ -3018,6 +3021,36 @@ static void compile_node_inner(Compiler *c, ASTNode *node) {
}
}

/* #869: a statement whose value is about to be thrown away. For an
* interrogative that is always a mistake — `what is 42` reads as an
* assignment, parses as a question about the literal 42, and had NO effect at
* all: the program ran to completion, rc=0, with no diagnostic on stderr.
* Only lint caught it. Every neighbouring mistake in the language is loud (an
* unresolved name is fatal, `break` outside a loop is a compile error), so
* this was the odd one out.
*
* The DISCARD is the signal, which is why the check lives here and not in the
* parser: the REPL and `eval` compile a unit whose LAST statement is the
* result, so `what is x` typed at the REPL still answers, and only a value
* nobody can read is refused. (A discarded interrogative as a unit's final
* statement is therefore not caught here — lint's W019 still flags it.)
*
* Called from both statement loops: AST_PROGRAM (a script's or REPL line's top
* level) and compile_block (every nested body). */
static void check_discarded_interrogative(ASTNode *stmt) {
if (!stmt || stmt->type != AST_INTERROGATE) return;
int k = stmt->data.interrogate.kind;
if (k < 0 || k > 5) return; /* `prev of x` — lint W019 covers it */
char msg[256];
snprintf(msg, sizeof(msg),
"'%s is ...' is an interrogative, not an assignment — question words "
"cannot be assigned with 'is', and this statement's result is discarded",
eigs_interrogative_word(k));
fprintf(stderr, "Compile error line %d: %s\n", stmt->line, msg);
eigs_record_first_error(stmt->line, msg);
g_parse_errors++;
}

static void compile_block(Compiler *c, ASTNode **stmts, int count) {
if (count == 0) {
emit(c, OP_NULL, 0);
Expand All @@ -3032,8 +3065,10 @@ static void compile_block(Compiler *c, ASTNode **stmts, int count) {
i + 1, count, stmts[i]->line,
depth_after - depth_before, depth_before, depth_after);
}
if (i + 1 < count)
if (i + 1 < count) {
check_discarded_interrogative(stmts[i]);
emit(c, OP_POP, stmts[i]->line);
}
}
}

Expand Down
8 changes: 8 additions & 0 deletions src/eigenscript.c
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,14 @@ const char* tok_type_name(TokType t) {
}
}

/* #869: the interrogative words, in AST_INTERROGATE kind order. Shared by
* lint's W019 and the compiler's discarded-statement check so the two cannot
* name different words for the same kind. */
const char* eigs_interrogative_word(int kind) {
static const char *words[] = {"what", "who", "when", "where", "why", "how"};
return (kind >= 0 && kind <= 5) ? words[kind] : "prev";
}

const char* val_type_name(ValType t) {
switch (t) {
case VAL_NUM: return "num";
Expand Down
2 changes: 2 additions & 0 deletions src/eigenscript.h
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,8 @@ void eigenscript_set_args(int argc, char **argv);
* when uncaught. Declared here so extension TUs (ext_store, etc.) can route
* argument/operation failures through the same strict channel as the VM. */
const char* val_type_name(ValType t);
/* #869: interrogative word for an AST_INTERROGATE kind (lint + compiler). */
const char* eigs_interrogative_word(int kind);
/* #406: the closed error-kind vocabulary. Every built-in runtime error
* carries exactly one of these; `catch` binds it as the dict's "kind"
* string (err_kind_name). The set is CLOSED by design — the same
Expand Down
5 changes: 1 addition & 4 deletions src/lint.c
Original file line number Diff line number Diff line change
Expand Up @@ -1451,10 +1451,7 @@ static void check_stdlib_shadow(ASTNode *ast, const char *path,
* nodes sitting directly in a statement list, so an interrogative used
* inside an expression (`print of (why is x)`) is never reached. */

static const char *interrog_word(int kind) {
static const char *words[] = {"what", "who", "when", "where", "why", "how"};
return (kind >= 0 && kind <= 5) ? words[kind] : "prev";
}
#define interrog_word(kind) eigs_interrogative_word(kind) /* #869: one table */

/* #736: the same silent no-op through the other door. `report of x` /
* `observe of x` / `report_value of x` / `trajectory of x` over an IDENT are
Expand Down
32 changes: 15 additions & 17 deletions src/vm.c
Original file line number Diff line number Diff line change
Expand Up @@ -1138,7 +1138,7 @@ void jit_helper_local_idx_get(int slot, int idx) {
}
return;
}
if (target->type != VAL_NULL) {
{
rt_error(EK_TYPE, g_vm.current_line,
"cannot index %s", val_type_name(target->type));
}
Expand Down Expand Up @@ -1175,7 +1175,7 @@ void jit_helper_local_dot_get(EigsChunk *chunk, int slot, int name_idx) {
}
return;
}
if (target && target->type != VAL_NULL) {
if (target) {
const char *key = chunk->const_interns[name_idx];
rt_error(EK_TYPE, g_vm.current_line,
"cannot access field '%s' on %s",
Expand Down Expand Up @@ -1221,7 +1221,7 @@ void jit_helper_local_idx_dot_get(EigsChunk *chunk, int slot,
}
return;
}
} else if (dict && dict->type != VAL_NULL) {
} else if (dict) {
const char *key = chunk->const_interns[name_idx];
rt_error(EK_TYPE, g_vm.current_line,
"cannot access field '%s' on %s",
Expand All @@ -1232,7 +1232,7 @@ void jit_helper_local_idx_dot_get(EigsChunk *chunk, int slot,
"index %d out of range (list length %d)",
i, target->data.list.count);
}
} else if (target && target->type != VAL_NULL) {
} else if (target) {
rt_error(EK_TYPE, g_vm.current_line,
"cannot index %s", val_type_name(target->type));
}
Expand Down Expand Up @@ -1312,7 +1312,7 @@ void jit_helper_dot_get(EigsChunk *chunk, int name_idx) {
vm_push(v);
return;
}
} else if (target->type != VAL_NULL) {
} else {
rt_error(EK_TYPE, g_vm.current_line,
"cannot access field '%s' on %s",
key, val_type_name(target->type));
Expand Down Expand Up @@ -1918,7 +1918,7 @@ void jit_helper_index_set(void) {
}
} else if (target->type == VAL_DICT && idx->type == VAL_STR) {
dict_set(target, idx->data.str, val);
} else if (target->type != VAL_NULL) {
} else {
rt_error(EK_TYPE, g_vm.current_line, "cannot index %s for assignment", val_type_name(target->type));
}
val_decref(target); val_decref(idx);
Expand Down Expand Up @@ -2016,7 +2016,7 @@ void jit_helper_index_get(void) {
rt_error(EK_INDEX, g_vm.current_line,
"buffer index %d out of range (length %d)",
i, target->data.buffer.count);
} else if (target->type != VAL_NULL) {
} else {
rt_error(EK_TYPE, g_vm.current_line,
"cannot index %s", val_type_name(target->type));
}
Expand Down Expand Up @@ -4080,7 +4080,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) {
result = make_num(target->data.buffer.data[i]);
else
rt_error(EK_INDEX, current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count);
} else if (target->type != VAL_NULL) {
} else {
rt_error(EK_TYPE, current_line, "cannot index %s", val_type_name(target->type));
}
val_decref(target); val_decref(idx);
Expand Down Expand Up @@ -4175,7 +4175,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) {
}
} else if (target->type == VAL_DICT && idx->type == VAL_STR) {
dict_set(target, idx->data.str, val);
} else if (target->type != VAL_NULL) {
} else {
rt_error(EK_TYPE, current_line, "cannot index %s for assignment", val_type_name(target->type));
}
Comment on lines 4176 to 4180
val_decref(target); val_decref(idx);
Expand Down Expand Up @@ -4205,7 +4205,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) {
vm_push(v);
DISPATCH();
}
} else if (target->type != VAL_NULL) {
} else {
rt_error(EK_TYPE, current_line, "cannot access field '%s' on %s",
key, val_type_name(target->type));
}
Expand Down Expand Up @@ -4274,7 +4274,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) {
} else {
vm_push_slot(slot_null());
}
} else if (target && target->type != VAL_NULL) {
} else if (target) {
const char *key = chunk->const_interns[name_idx];
rt_error(EK_TYPE, current_line, "cannot access field '%s' on %s",
key, val_type_name(target->type));
Expand Down Expand Up @@ -4360,9 +4360,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) {
}
DISPATCH();
}
if (target->type != VAL_NULL) {
rt_error(EK_TYPE, current_line, "cannot index %s", val_type_name(target->type));
}
rt_error(EK_TYPE, current_line, "cannot index %s", val_type_name(target->type));
}
vm_push_slot(slot_null());
DISPATCH();
Expand Down Expand Up @@ -4396,7 +4394,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) {
}
DISPATCH();
}
} else if (dict && dict->type != VAL_NULL) {
} else if (dict) {
const char *key = chunk->const_interns[name_idx];
rt_error(EK_TYPE, current_line, "cannot access field '%s' on %s",
key, val_type_name(dict->type));
Expand All @@ -4405,7 +4403,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) {
rt_error(EK_INDEX, current_line, "index %d out of range (list length %d)",
i, target->data.list.count);
}
} else if (target && target->type != VAL_NULL) {
} else if (target) {
rt_error(EK_TYPE, current_line, "cannot index %s", val_type_name(target->type));
}
vm_push_slot(slot_null());
Expand Down Expand Up @@ -4447,7 +4445,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) {
rt_error(EK_INDEX, current_line, "index %d out of range (list length %d)",
i, target->data.list.count);
}
} else if (target && target->type != VAL_NULL) {
} else if (target) {
rt_error(EK_TYPE, current_line, "cannot index %s for assignment",
val_type_name(target->type));
}
Expand Down
48 changes: 48 additions & 0 deletions tests/run_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4279,6 +4279,54 @@ fi
rm -rf "$CONT_DIR"
echo ""

# [99j] Discarded interrogative is a compile error (#869). `what is 42` reads
# as an assignment, parses as a question about the literal 42, and used to run
# to completion at rc=0 with no diagnostic — the statement's entire effect was
# discarded. Only lint caught it. The check keys on the DISCARD, so the REPL
# and `eval` (whose last statement IS the result) must keep answering.
echo "[99j] Discarded interrogative (#869)"
SK_DIR=$(mktemp -d /tmp/eigs_sk869_XXXX)
printf 'what is 42\nprint of "still alive"\ncount is 1\nwhere is count\nprint of (str of count)\n' > "$SK_DIR/discard.eigs"
SK_OUT=$(./eigenscript "$SK_DIR/discard.eigs" 2>&1); SK_RC=$?
TOTAL=$((TOTAL + 1))
if [ "$SK_RC" -ne 0 ] && echo "$SK_OUT" | grep -q "'what is ...' is an interrogative" \
&& echo "$SK_OUT" | grep -q "'where is ...' is an interrogative" \
&& ! echo "$SK_OUT" | grep -q "still alive"; then
PASS=$((PASS + 1))
echo " PASS: a discarded interrogative aborts before the program runs (was: silent, rc=0)"
else
FAIL=$((FAIL + 1))
echo " FAIL: discarded interrogative should be a compile error (rc=$SK_RC)"
echo "$SK_OUT" | head -5
fi

# The REPL's last statement IS the result, so an interrogative there answers.
SK_REPL=$(printf 'x is 5\nwhat is x\n' | ./eigenscript 2>&1)
TOTAL=$((TOTAL + 1))
if echo "$SK_REPL" | grep -q "=> 5" && ! echo "$SK_REPL" | grep -q "Compile error"; then
PASS=$((PASS + 1))
echo " PASS: the REPL still answers 'what is x'"
else
FAIL=$((FAIL + 1))
echo " FAIL: the REPL must still answer an interrogative"
echo "$SK_REPL" | head -5
fi

# Same for `eval`, and for an interrogative used inside an expression.
printf 'z is 3\nprint of (str of (eval of "what is z"))\nprint of (str of (what is z))\n' > "$SK_DIR/live.eigs"
SK_LIVE=$(./eigenscript "$SK_DIR/live.eigs" 2>&1); SK_LIVE_RC=$?
TOTAL=$((TOTAL + 1))
if [ "$SK_LIVE_RC" -eq 0 ] && [ "$(echo "$SK_LIVE" | head -1)" = "3" ]; then
PASS=$((PASS + 1))
echo " PASS: eval and expression-position interrogatives are untouched"
else
FAIL=$((FAIL + 1))
echo " FAIL: eval / expression interrogatives must keep working (rc=$SK_LIVE_RC)"
echo "$SK_LIVE" | head -5
fi
rm -rf "$SK_DIR"
echo ""

# [99i] Uniform -Werror=switch gate (#817 follow-up; #835 extended it to
# compile-bearing shell scripts). Dry-runs every compiling Makefile target
# plus the audited scripts (tools/freestanding_check.sh) and asserts every
Expand Down
Loading
Loading