Skip to content
Closed
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,11 @@ scope are safe: anything stored into a binding or a container that
outlives the window is promoted to the heap at the store (#873) — the
arena reclaims only the unstored intermediates.

Runtime errors from multiline binary operations report the operator's physical
line. Successful binary operations restore the previous line attribution;
compound field/index assignments retain their prior behavior. See
[the error-line rule](docs/SPEC.md#error-handling) for the binary-only scope.

## Standard Library

Pure EigenScript libraries under `lib/`:
Expand Down
8 changes: 8 additions & 0 deletions changes/changed/1425-operator-line.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
- A runtime error raised by a written binary operator whose operands span lines reports the operator's
physical line in `e.line`, the error header, excerpt/caret and traceback (#1425). This includes plain-name
compound assignment (`x += rhs`) and written operators inside f-string interpolations, in the interpreter,
JIT and OSR. After success the compiler's temporary operator stamp restores the previous runtime line,
preserving attribution for an enclosing unary operation, index or call, including conditional operands.
Single-line code compiles to identical bytecode. Interpreted execution adds a tape line record before
the operation and a restoring line record on success. Temporal filing remains the statement's first line. Dedicated compound
field/index assignments retain their prior attribution pending a separate owner decision.
6 changes: 6 additions & 0 deletions docs/COMPARISON.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,12 @@ Not Found

## Error handling

A runtime error raised by a multiline binary operation reports the operator's
physical line. Plain-name compound assignments follow their desugared binary
operation; dedicated compound field/index assignments keep their prior line
attribution. A successful binary operation restores the previous runtime line,
so an enclosing unary, index or call error keeps its prior attribution.

Python:

```python
Expand Down
29 changes: 29 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1064,6 +1064,35 @@ catch e:
5
```

When the faulting sub-expression is a binary operator (`+`, `-`, `*`, `/`,
`%`, a comparison, a bitwise operator, or a plain-name compound
assignment such as `x += rhs`) whose operands span lines, the reported line is the
**operator's** line, not the line its right operand ends on (#1425). The `/`
below is on line 3; its operand ends on line 4. The excerpt and caret point at
the operator too, and so does the traceback:

```eigenscript
x is 0
try:
z is 1 / (
x)
catch e:
print of e.line
```
```output
3
```

A successful binary operation restores the line attribution that preceded its
operator stamp, so an enclosing unary operation, index or call keeps its prior
attribution. Compound field/index assignments (`obj.key += rhs`, `xs[i] += rhs`)
keep their prior attribution; their location policy awaits a separate owner
decision. This rule covers binary operators only. Temporal filing is unchanged (the
statement's first line). Other constructs that span lines (call arguments,
indexing, and the concatenation an f-string is lowered to) are not decided by
it; an operator written inside an f-string interpolation is a binary operator
and follows it.

```eigenscript
try:
throw of "custom failure"
Expand Down
11 changes: 11 additions & 0 deletions docs/TRACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ EIGS_REPLAY=u.tape <old>/eigenscript prog.eigs # prints new.out
The only output that follows the replaying binary is output that reads line
identity: `what is x at N`, `e.line` and error headers.

A written binary operator whose right operand stamped a different line temporarily
stamps the operator's own line just before the operation (#1425), so a runtime
error there reports the operator's line. Under interpreted execution, the tape
gains an `L <operator line>` record before the operation and, on success, an
`L <previous line>` record that
restores the line that was active after the operand. Assignment filing then
stamps the statement's first line as usual. Single-line expressions emit
nothing extra. Synthesized f-string concatenation and dedicated compound
field/index assignments keep their previous stamping; a written binary
operator inside their operands still follows the binary rule.

### Value serialization

`N` records are written with full fidelity so they can be parsed back
Expand Down
9 changes: 7 additions & 2 deletions docs/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,13 @@ sqrt(2) = 1.414213562373095
- Temporal reads: `prev of x` (value before the last assignment),
`what is x at 12` / `state_at of 12` (past-line state). Bare `x at 12` is a
parse error. A statement spanning several lines is filed under its FIRST
line, while a runtime error inside it (`e.line`, the error header) reports
the physical line of the faulting sub-expression. Wrap hot loops in `unobserved:` to skip the observer's entropy
line. A runtime error raised by a binary operator whose operands span lines
reports the operator's physical line in `e.line` and the error header,
including plain `x += rhs`.
A successful binary operation restores the previous line; enclosing unary,
index and call errors keep their prior attribution. Compound field/index
assignments keep their prior attribution pending a separate owner decision.
Wrap hot loops in `unobserved:` to skip the observer's entropy
walk (a scalar's value-window sample still lands, so numeric verdicts do not
change; `why`/`how` and non-numeric bindings' verdicts can).

Expand Down
40 changes: 27 additions & 13 deletions src/chunk.c
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ const char *op_name(uint8_t op) {
N(OP_LOOP_STALL_CHECK) N(OP_LOOP_CAP_CHECK)
N(OP_IMPORT) N(OP_MATCH)
N(OP_LISTCOMP_BEGIN) N(OP_LISTCOMP_APPEND)
N(OP_LINE) N(OP_WIDE) N(OP_DISPATCH)
N(OP_LINE) N(OP_BINARY_LINE) N(OP_BINARY_LINE_END) N(OP_WIDE) N(OP_DISPATCH)
N(OP_LOCAL_DOT_GET) N(OP_LOCAL_DOT_SET) N(OP_LOCAL_IDX_GET)
N(OP_LOCAL_IDX_DOT_GET) N(OP_LOCAL_IDX_DOT_SET)
N(OP_INTERROGATE_NAMED) N(OP_INTERROGATE_NAMED_AT)
Expand Down Expand Up @@ -401,7 +401,7 @@ void chunk_disassemble(EigsChunk *chunk, const char *label) {
uint8_t op = chunk->code[i];
fprintf(stderr, "%04d [L%d] %-20s", i, line, op_name(op));
i++;
if (op == OP_LINE && i + 3 < chunk->code_len) {
if ((op == OP_LINE || op == OP_BINARY_LINE) && i + 3 < chunk->code_len) {
/* #630: 32-bit operand. */
uint32_t arg = (uint32_t)chunk->code[i] |
((uint32_t)chunk->code[i + 1] << 8) |
Expand Down Expand Up @@ -528,7 +528,7 @@ static int op_verify_operands(uint8_t op8, VerifyRole roles[3]) {
* disassembler both special-case it BEFORE consulting this u16-strided
* table, so this arm is unreachable — listed for the exhaustiveness
* gate, not for behavior. */
case OP_LINE:
case OP_LINE: case OP_BINARY_LINE: case OP_BINARY_LINE_END:
return 0;
case OP_COUNT: /* sentinel — callers bounds-check first */
return 0;
Expand Down Expand Up @@ -715,7 +715,7 @@ static StackEffect op_verify_stack_effect(uint8_t op8, int operand0) {

/* Stack-neutral. BREAK/CONTINUE set a flag nothing reads — the compiler
* lowers both to jumps — so they are pure no-ops here too. */
case OP_LINE: case OP_WIDE:
case OP_LINE: case OP_BINARY_LINE: case OP_BINARY_LINE_END: case OP_WIDE:
case OP_TRY_END: case OP_BREAK: case OP_CONTINUE:
case OP_LOOP_ENV_FRESH: case OP_LOOP_ENV_END: case OP_LOOP_ENV_CLEAR:
case OP_UNOBSERVED_BEGIN: case OP_UNOBSERVED_END:
Expand Down Expand Up @@ -749,11 +749,23 @@ static int chunk_verify_impl(EigsChunk *chunk, char *why, size_t whyn) {
while (i < n) {
uint8_t op = code[i];
if (op >= OP_COUNT) { ok = 0; break; }
is_start[i] = 1;
if (!is_start[i]) is_start[i] = 1;
last_op = op;
/* #1425: one scalar save slot is sufficient only for an adjacent,
* non-calling binary scope. Its interior is a valid fall-through
* boundary (2), but not a legal jump/handler entry (1). */
if (op == OP_BINARY_LINE) {
if (i + 7 > n || code[i + 6] != OP_BINARY_LINE_END) { ok = 0; break; }
uint8_t bin = code[i + 5];
if (!((bin >= OP_ADD && bin <= OP_SHR) ||
(bin >= OP_EQ && bin <= OP_GE))) { ok = 0; break; }
is_start[i + 5] = is_start[i + 6] = 2;
} else if (op == OP_BINARY_LINE_END && is_start[i] != 2) {
ok = 0; break;
}
/* #630: OP_LINE has a single 32-bit operand — outside the u16-strided
* role machinery below. No index to validate; just skip 4 bytes. */
if (op == OP_LINE) {
if (op == OP_LINE || op == OP_BINARY_LINE) {
int end = i + 1 + 4;
if (end > n) { ok = 0; break; }
i = end;
Expand Down Expand Up @@ -792,7 +804,7 @@ static int chunk_verify_impl(EigsChunk *chunk, char *why, size_t whyn) {
/* Pass 2: every jump must land on an in-range instruction boundary. */
for (int t = 0; ok && t < ntargets; t++) {
int tgt = targets[t];
if (tgt < 0 || tgt >= n || !is_start[tgt]) ok = 0;
if (tgt < 0 || tgt >= n || is_start[tgt] != 1) ok = 0;
}

/* Pass 3: execution must not be able to run off the end. Pass 2 pins every
Expand Down Expand Up @@ -862,7 +874,7 @@ static int chunk_verify_impl(EigsChunk *chunk, char *why, size_t whyn) {
int h = height[off];
uint8_t op = code[off];
int end, operand0 = 0, target = -1;
if (op == OP_LINE) {
if (op == OP_LINE || op == OP_BINARY_LINE) {
end = off + 1 + 4;
} else {
VerifyRole roles[3];
Expand Down Expand Up @@ -956,7 +968,7 @@ void chunk_arm_temporal(const EigsChunk *chunk) {
int n = chunk->code_len, i = 0;
while (i < n) {
uint8_t op = code[i];
if (op == OP_LINE) { i += 1 + 4; continue; }
if (op == OP_LINE || op == OP_BINARY_LINE) { i += 1 + 4; continue; }
VerifyRole roles[3];
int nops = op_verify_operands(op, roles);
if (op == OP_INTERROGATE_NAMED || op == OP_INTERROGATE_NAMED_AT ||
Expand Down Expand Up @@ -1009,7 +1021,9 @@ void chunk_scan_leaf_accessor(EigsChunk *c) {
while (ip < end) {
uint8_t op = *ip++;
switch (op) {
case OP_LINE:
case OP_BINARY_LINE_END:
break;
case OP_LINE: case OP_BINARY_LINE:
if (ip + 4 > end) return; /* #630: 32-bit operand */
ip += 4;
break;
Expand Down Expand Up @@ -1358,7 +1372,7 @@ int chunk_has_reader_opcode(const EigsChunk *chunk) {
static int chunk_step_ip(const EigsChunk *chunk, int i) {
uint8_t op = chunk->code[i];
i++;
if (op == OP_LINE) {
if (op == OP_LINE || op == OP_BINARY_LINE) {
i += 4; /* #630: 32-bit operand */
} else if (op < OP_COUNT) {
VerifyRole roles[3];
Expand All @@ -1369,7 +1383,7 @@ static int chunk_step_ip(const EigsChunk *chunk, int i) {

/* Next instruction offset at or after `i` that is not OP_LINE. */
static int chunk_skip_lines(const EigsChunk *chunk, int i) {
while (i < chunk->code_len && chunk->code[i] == OP_LINE)
while (i < chunk->code_len && (chunk->code[i] == OP_LINE || chunk->code[i] == OP_BINARY_LINE || chunk->code[i] == OP_BINARY_LINE_END))
i = chunk_step_ip(chunk, i);
return i;
}
Expand Down Expand Up @@ -1418,7 +1432,7 @@ int chunk_scan_static_loads(const EigsChunk *chunk,
uint8_t op = chunk->code[i];
int nops = 0;
VerifyRole roles[3];
if (op != OP_LINE && op < OP_COUNT) nops = op_verify_operands(op, roles);
if (op != OP_LINE && op != OP_BINARY_LINE && op < OP_COUNT) nops = op_verify_operands(op, roles);

if (op == OP_IMPORT) {
if (nops != 1 || i + 3 > chunk->code_len) return 1;
Expand Down
40 changes: 36 additions & 4 deletions src/compiler.c
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
#if !EIGENSCRIPT_FREESTANDING
/* NB: EIGENSCRIPT_FREESTANDING is always DEFINED (eigenscript.h defaults it to
* 0), so this must test its VALUE. `#ifndef` here silently excluded both
Expand Down Expand Up @@ -115,6 +116,9 @@ typedef struct Compiler {
* stamp the line". Reset at every basic-block boundary
* (patch_jump targets, emit_loop, loop_start capture,
* after CALL/DISPATCH/RETURN, fn entry). */
int req_lo, req_hi; /* #1425: min/max line passed to emit_line (requested,
* deduped or not) since compile_operand_then_op opened
* its window; meaningful only inside that window. */
int dispatch_rebound; /* #459, root-computed and copied to fn
* compilers: the unit binds `dispatch`
* somewhere (any scope) or references
Expand Down Expand Up @@ -391,7 +395,7 @@ static int op_stack_effect(uint8_t op8) {
case OP_UNOBSERVED_BEGIN: case OP_UNOBSERVED_END:
return 0;
/* Line: no stack change */
case OP_LINE:
case OP_LINE: case OP_BINARY_LINE: case OP_BINARY_LINE_END:
return 0;
/* Break/continue: no stack change (compiler emits as jumps) */
case OP_BREAK: case OP_CONTINUE:
Expand Down Expand Up @@ -530,6 +534,8 @@ static int capture_loop_start(Compiler *c) {
* parent's line). Emit only when the line changes; reset at every
* basic-block boundary so we never *skip* a stamp the runtime needs. */
static void emit_line(Compiler *c, int line) {
if (line < c->req_lo) c->req_lo = line; /* #1425: see compile_operand_then_op */
if (line > c->req_hi) c->req_hi = line;
if (c->last_line == line) return;
emit_op_u32(c, OP_LINE, (uint32_t)line, line); /* #630: 32-bit — was (uint16_t), wrapped past line 65535 */
c->last_line = line;
Expand All @@ -545,6 +551,27 @@ static void restamp_line(Compiler *c, int line) {
emit_line(c, line); /* emit_line's own dedup is exactly the test */
}

/* #1425: scope the operator line to the binary instruction. Saving the
* runtime line, rather than guessing from last_line, preserves whichever
* short-circuit/conditional operand path executed. Calls also invalidate
* last_line. Only a differing stamp needs the scope; one-line bytecode stays
* identical. The markers surround one non-calling instruction, so scopes
* cannot nest or suspend. An error unwinds before BINARY_LINE_END and keeps
* the operator line for its diagnostic; success restores both line caches. */
static void compile_operand_then_op(Compiler *c, ASTNode *rhs, uint8_t opc, int op_line) {
int lo = c->req_lo, hi = c->req_hi;
c->req_lo = INT_MAX; c->req_hi = INT_MIN;
compile_node(c, rhs);
int spans = c->req_lo != op_line || c->req_hi != op_line;
if (c->req_lo > lo) c->req_lo = lo;
if (c->req_hi < hi) c->req_hi = hi;
if (!opc) return;
int scope = spans && c->last_line != op_line;
if (scope) emit_op_u32(c, OP_BINARY_LINE, (uint32_t)op_line, op_line);
emit(c, opc, op_line);
Comment on lines +570 to +571

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the compound operator column for diagnostics

When this path handles a multiline plain-name compound assignment such as x += (\n "s"), the desugared AST_BINOP still carries the variable's column (parser.c:1841) rather than the += token's column. Consequently the new operator-line scope reports the correct line but places the caret under x, contradicting the documented rule that the caret identifies the faulting operator. Preserve the compound token's column when constructing the binary node so the opcode emitted here is attributed to +=.

Useful? React with 👍 / 👎.

if (scope) emit(c, OP_BINARY_LINE_END, op_line);
}

/* ---- Constant helpers ---- */

/* u16 operand ceiling: constant indices are encoded as 16-bit operands
Expand Down Expand Up @@ -2160,11 +2187,16 @@ static void compile_node_inner(Compiler *c, ASTNode *node) {
patch_jump(c, jump);
break;
}
/* Normal binary op */
/* Normal binary op. An f-string's `+` (lexer-synthesized) keeps
* the old stamping: #1425 decided written operators only. */
compile_node(c, node->data.binop.left);
compile_node(c, node->data.binop.right);
uint8_t opc = binop_to_opcode(op);
if (opc) emit(c, opc, node->line);
if (node->data.binop.synth) {
compile_node(c, node->data.binop.right);
if (opc) emit(c, opc, node->line);
} else {
compile_operand_then_op(c, node->data.binop.right, opc, node->line);
}
break;
}

Expand Down
2 changes: 1 addition & 1 deletion src/eigenscript.h
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ struct ASTNode {
double num;
char *str;
struct { char *name; } ident;
struct { char op[4]; ASTNode *left; ASTNode *right; } binop;
struct { char op[4]; ASTNode *left; ASTNode *right; uint8_t synth; } binop; /* synth: f-string `+` (#1425) */
struct { char op[4]; ASTNode *operand; } unary;
struct { char *name; ASTNode *expr; int local_only; } assign;
struct { ASTNode *left; ASTNode *right; } relation;
Expand Down
33 changes: 30 additions & 3 deletions src/jit.c
Original file line number Diff line number Diff line change
Expand Up @@ -926,7 +926,9 @@ static int jit_supported_prefix(const struct EigsChunk *chunk,
* inline as 9-byte FS-prefixed inc/dec dword [disp32] — no
* call, no bail, no sp/env interaction. */
i += 1; ops++; non_line_ops++;
} else if (op == OP_LINE) {
} else if (op == OP_BINARY_LINE_END) {
i += 1; ops++;
} else if (op == OP_LINE || op == OP_BINARY_LINE) {
/* #630: [op][line:32] = 5 bytes. */
if (i + 5 > chunk->code_len) { *stop_op = op; *stop_offset = i; break; }
i += 5; ops++;
Expand Down Expand Up @@ -1109,6 +1111,17 @@ static uint8_t *emit_mov_disp32_rbx_to_eax(uint8_t *w, int32_t disp) {
return emit_u32(w, (uint32_t)disp);
}

/* #1425: 32-bit stores for the scoped line caches. %eax/%rdx are scratch. */
static uint8_t *emit_mov_eax_to_disp32_rbx(uint8_t *w, int32_t disp) {
*w++ = 0x89; *w++ = 0x83;
return emit_u32(w, (uint32_t)disp);
}

static uint8_t *emit_movabs_rdx_line(uint8_t *w) {
*w++ = 0x48; *w++ = 0xBA;
return emit_u64(w, (uint64_t)(uintptr_t)&g_trace_current_line);
}

/* dec %eax (2 bytes) */
static uint8_t *emit_dec_eax(uint8_t *w) { *w++ = 0xFF; *w++ = 0xC8; return w; }

Expand Down Expand Up @@ -4082,7 +4095,21 @@ static void jit_compile_to_thunk(struct EigsChunk *chunk,
}
}
i += 1;
} else { /* OP_LINE */
} else if (op == OP_BINARY_LINE_END) {
w = emit_mov_disp32_rbx_to_eax(w, g_layout.off_binary_prev_line);
w = emit_mov_eax_to_disp32_rbx(w, g_layout.off_current_line);
w = emit_mov_disp32_rbx_to_eax(w, g_layout.off_binary_prev_trace_line);
w = emit_movabs_rdx_line(w);
*w++ = 0x89; *w++ = 0x02; /* mov %eax,(%rdx) */
i += 1;
} else { /* OP_LINE / OP_BINARY_LINE */
if (op == OP_BINARY_LINE) {
w = emit_mov_disp32_rbx_to_eax(w, g_layout.off_current_line);
w = emit_mov_eax_to_disp32_rbx(w, g_layout.off_binary_prev_line);
w = emit_movabs_rdx_line(w);
*w++ = 0x8B; *w++ = 0x02; /* mov (%rdx),%eax */
w = emit_mov_eax_to_disp32_rbx(w, g_layout.off_binary_prev_trace_line);
}
/* #630: 32-bit operand. */
uint32_t line = (uint32_t)chunk->code[i + 1] |
((uint32_t)chunk->code[i + 2] << 8) |
Expand Down Expand Up @@ -4117,7 +4144,7 @@ static void jit_compile_to_thunk(struct EigsChunk *chunk,
last_imm = (v && v->type == VAL_NUM);
break;
}
case OP_LINE:
case OP_LINE: case OP_BINARY_LINE: case OP_BINARY_LINE_END:
case OP_JUMP_IF_FALSE_PEEK: case OP_JUMP_IF_TRUE_PEEK:
/* Pass-through. LINE touches no stack; the PEEK ops read the
* condition but leave the operand on the stack (they don't push),
Expand Down
2 changes: 2 additions & 0 deletions src/jit.h
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ typedef struct {
int off_frame_count;
int off_frames;
int off_current_line;
int off_binary_prev_line;
int off_binary_prev_trace_line;
int off_callframe_ip;
int off_callframe_fn_env;
int sizeof_callframe;
Expand Down
Loading
Loading