Skip to content

Commit 6f65613

Browse files
Fix #337: break/continue outside a loop are compile errors
They compiled to silent no-ops (an explicit OP_NULL), so a mis-indented break left its loop spinning with no diagnostic — including inside a function body with no loop of its own. Both now report a compile error via the existing post-compile g_parse_errors gate. The gate is also enforced at every dynamic entry point: eval, load_file, and import checked g_parse_errors only after PARSE, so compile-stage diagnostics executed a placeholder chunk anyway. All three now fail with a catchable error (the REPL and main already gated). Module-level return (ends the program, value discarded, exit 0) is now documented in SPEC.md's Program model; the Loops section documents the new compile error. test_break_scope.eigs and test_control_flow_interactions.eigs pinned the old tolerated-no-op shape; both rewritten to pin the actual property (a callee's break never escapes to the caller's loop) using functions that break their OWN loops. Regression: suite [112] test_stray_break.eigs (eval'd stray break/continue raise catchably; in-loop eval still works) + two examples/errors/ demos gated by [90]. Release + ASan(detect_leaks=1) 2353/2353, leak tally 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2485c51 commit 6f65613

11 files changed

Lines changed: 171 additions & 20 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to EigenScript are documented here.
55
## [Unreleased]
66

77
### Fixed
8+
- **`break`/`continue` outside a loop are now compile errors (#337).** They
9+
compiled to silent no-ops (an explicit `OP_NULL`), so a mis-indented `break`
10+
left its loop spinning with no diagnostic — including inside a function body
11+
with no loop of its own (a break there never crosses frames to the caller's
12+
loop). Both now report `Compile error line N: 'break' outside a loop` and
13+
abort via the existing post-compile gate. The same gate is now enforced at
14+
every dynamic entry point: `eval`, `load_file`, and `import` previously
15+
checked `g_parse_errors` only after *parse*, so compile-stage diagnostics
16+
executed a placeholder chunk anyway — all three now fail with a catchable
17+
error. Module-level `return` (ends the program, value discarded, exit 0) is
18+
now documented in SPEC.md's Program model. Regression: section [112]
19+
(`test_stray_break.eigs`) + two new `examples/errors/` demos gated by [90].
820
- **f-string interpolation: whitespace and nested-string braces (#334).** Two
921
lexer defects. (1) `f"{ x }"` (leading space) failed with "unexpected indent
1022
in expression": the interpolation body is re-lexed as a fresh source string

docs/SPEC.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ An EigenScript program is a sequence of statements executed top to
4747
bottom. There is no required entry point — the file *is* the program.
4848
Statements are expressions, assignments, definitions, or control
4949
structures. Blocks are delimited by indentation (like Python), and a
50-
statement ends at the end of its line.
50+
statement ends at the end of its line. A `return` at module level ends
51+
the program immediately (exit status 0); the returned value is
52+
discarded.
5153

5254
Function application uses the keyword `of`: `f of x` calls `f` with the
5355
argument `x`. `print` is an ordinary builtin function.
@@ -339,7 +341,9 @@ medium
339341
`loop while cond:` repeats while the condition is truthy. `for v in
340342
seq:` iterates a list, buffer, or `range of n` (0 to n-1). `break` and
341343
`continue` behave conventionally and do not escape function-call
342-
boundaries.
344+
boundaries. A `break` or `continue` with no enclosing loop — including
345+
inside a function body that has no loop of its own — is a **compile
346+
error** (`'break' outside a loop`), not a silent no-op.
343347

344348
```eigenscript
345349
i is 0
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# ERROR DEMO — break/continue need an enclosing loop.
2+
# A break (or continue) with no loop around it — including inside a
3+
# function body that has no loop of its own — is a compile error, not
4+
# a silent no-op: a mis-indented break would otherwise leave its loop
5+
# spinning with no diagnostic (#337).
6+
# expect-error: Compile error line 8: 'break' outside a loop
7+
x is 1
8+
break
9+
print of "never runs"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# ERROR DEMO — continue outside a loop, in a function body. Function
2+
# bodies compile with their own loop context: a continue in a loop-less
3+
# function does NOT belong to a loop the caller might be running (#337).
4+
# expect-error: Compile error line 7: 'continue' outside a loop
5+
define helper as:
6+
if n > 0:
7+
continue
8+
return 1
9+
print of (str of (helper of 5))

src/builtins.c

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2446,10 +2446,20 @@ Value* builtin_load_file(Value *arg) {
24462446
runtime_error(0, "load_file: parse error in '%s'", arg->data.str);
24472447
return make_null();
24482448
}
2449-
g_parse_errors = saved_errors;
2450-
2449+
/* Compile-stage diagnostics must fail the load too (#337) — same
2450+
* rationale as eval above. */
24512451
Env *target = g_load_env ? g_load_env : g_global_env;
24522452
EigsChunk *lf_chunk = compile_ast(ast, target);
2453+
if (g_parse_errors > 0) {
2454+
g_parse_errors = saved_errors;
2455+
chunk_free(lf_chunk);
2456+
free_ast(ast);
2457+
free_tokenlist(&tl);
2458+
free(source);
2459+
runtime_error(0, "load_file: compile error in '%s'", arg->data.str);
2460+
return make_null();
2461+
}
2462+
g_parse_errors = saved_errors;
24532463
Value *result = vm_execute(lf_chunk, target);
24542464
chunk_free(lf_chunk); /* creator ref; loaded fns hold their own */
24552465
free_ast(ast);
@@ -3297,10 +3307,20 @@ Value* builtin_eval(Value *arg) {
32973307
runtime_error(0, "eval: parse error in code string");
32983308
return make_null();
32993309
}
3300-
g_parse_errors = saved_errors;
3301-
3310+
/* Keep the zeroed counter through COMPILE too — compile-stage
3311+
* diagnostics ('break' outside a loop #337, un-encodable jumps) must
3312+
* fail the eval instead of executing a placeholder chunk. */
33023313
Env *target = g_builtin_call_env ? g_builtin_call_env : g_global_env;
33033314
EigsChunk *ev_chunk = compile_ast(ast, target);
3315+
if (g_parse_errors > 0) {
3316+
g_parse_errors = saved_errors;
3317+
chunk_free(ev_chunk);
3318+
free_tokenlist(&tl);
3319+
free_ast(ast);
3320+
runtime_error(0, "eval: compile error in code string");
3321+
return make_null();
3322+
}
3323+
g_parse_errors = saved_errors;
33043324
Value *result = vm_execute(ev_chunk, target);
33053325
/* Chunks are refcounted: drop the creator ref; fn values keep their
33063326
* nested chunks alive. */

src/compiler.c

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1747,7 +1747,15 @@ static void compile_node_inner(Compiler *c, ASTNode *node) {
17471747
/* Phantom +1 for stack accounting (dead code follows jump) */
17481748
adjust_stack(c, 1);
17491749
} else {
1750-
/* Break outside loop: emit null (no-op, maintains stack balance) */
1750+
/* Break outside any loop: a compile error (#337). This used to
1751+
* be a silent no-op, turning a mis-indented break into a loop
1752+
* that never exits. Emit OP_NULL so compilation continues with
1753+
* balanced stack accounting; the post-compile g_parse_errors
1754+
* gate aborts before execution. */
1755+
fprintf(stderr, "Compile error line %d: 'break' outside a loop\n",
1756+
node->line);
1757+
eigs_record_first_error(node->line, "'break' outside a loop");
1758+
g_parse_errors++;
17511759
emit(c, OP_NULL, node->line);
17521760
}
17531761
break;
@@ -1760,7 +1768,12 @@ static void compile_node_inner(Compiler *c, ASTNode *node) {
17601768
/* Phantom +1 for stack accounting (dead code follows jump) */
17611769
adjust_stack(c, 1);
17621770
} else {
1763-
/* Continue outside loop: emit null */
1771+
/* Continue outside any loop: a compile error (#337), same
1772+
* rationale as break above. */
1773+
fprintf(stderr, "Compile error line %d: 'continue' outside a loop\n",
1774+
node->line);
1775+
eigs_record_first_error(node->line, "'continue' outside a loop");
1776+
g_parse_errors++;
17641777
emit(c, OP_NULL, node->line);
17651778
}
17661779
break;

src/vm.c

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4418,7 +4418,8 @@ static Value *vm_run(EigsChunk *chunk, Env *env) {
44184418
vm_push(make_null());
44194419
DISPATCH();
44204420
}
4421-
g_parse_errors = saved_errors;
4421+
/* Compile-stage diagnostics gate below (#337) — the counter stays
4422+
* zeroed through compile_ast, mirroring eval/load_file. */
44224423

44234424
Env *saved_load = g_load_env;
44244425
g_load_env = mod_env;
@@ -4443,6 +4444,20 @@ static Value *vm_run(EigsChunk *chunk, Env *env) {
44434444
}
44444445

44454446
EigsChunk *mod_chunk = compile_ast(ast, mod_env);
4447+
if (g_parse_errors > 0) {
4448+
g_parse_errors = saved_errors;
4449+
chunk_free(mod_chunk);
4450+
g_load_env = saved_load;
4451+
memcpy(g_import_resolve_dir, saved_resolve_dir, sizeof(saved_resolve_dir));
4452+
free_ast(ast);
4453+
free_tokenlist(&tl);
4454+
free(source);
4455+
env_decref(mod_env);
4456+
runtime_error(current_line, "import: compile errors in '%s'", name);
4457+
vm_push(make_null());
4458+
DISPATCH();
4459+
}
4460+
g_parse_errors = saved_errors;
44464461
Value *mod_result = vm_execute(mod_chunk, mod_env);
44474462
if (mod_result) val_decref(mod_result);
44484463
chunk_free(mod_chunk); /* creator ref; module fns hold their own */

tests/run_all_tests.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,13 @@ check_eigs_suite "65th break in for/while; break in 33rd nested loop" test_loop_
933933
echo "[111] Statement Cap: 4200-stmt program + block (#327)"
934934
check_eigs_suite "no silent truncation past 4096 statements" test_stmt_cap.eigs "All tests passed" 1
935935

936+
# [112] stray break/continue (#337). Outside any loop they are compile
937+
# errors (were silent no-ops); compile-stage diagnostics fail eval /
938+
# load_file / import with a catchable error instead of running a
939+
# placeholder chunk. Direct-source rc=1 covered by examples/errors/ [90].
940+
echo "[112] Stray break/continue Are Compile Errors (#337)"
941+
check_eigs_suite "eval'd stray break/continue raise; in-loop still works" test_stray_break.eigs "All tests passed" 1
942+
936943
# [23] Named parameters
937944
echo "[23/27] Named Parameters (9 checks)"
938945
NP_OUTPUT=$(./eigenscript ../tests/test_named_params.eigs 2>&1); NP_OUTPUT_RC=$?

tests/test_break_scope.eigs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1-
# break/continue must not escape function call boundaries
1+
# break/continue must not escape function call boundaries.
2+
# (#337 update: a break/continue in a LOOP-LESS function is now a compile
3+
# error — pinned by examples/errors/break_outside_loop.eigs and
4+
# continue_outside_loop.eigs. The no-escape property is pinned here with
5+
# functions that break/continue their OWN loops while the CALLER is
6+
# looping: the callee's break must never touch the caller's loop.)
27

38
define breaker as:
4-
break
9+
for j in [1, 2]:
10+
break
11+
return 0
512

613
define continuer as:
7-
continue
14+
for j in [1, 2]:
15+
continue
16+
return 0
817

918
# break inside called function must not break caller's loop
1019
count is 0
@@ -20,13 +29,15 @@ for i in [1, 2, 3]:
2029
total is total + i
2130
assert of [total == 6, "continue inside function must not escape to caller loop"]
2231

23-
# break inside nested call must not escape
32+
# break inside nested call must not escape through two frames
2433
define inner_break as:
25-
break
34+
loop while 1:
35+
break
36+
return 41
2637

2738
define outer_fn as:
28-
inner_break of null
29-
return 42
39+
r is inner_break of null
40+
return r + 1
3041

3142
result is 0
3243
for i in [1, 2, 3]:

tests/test_control_flow_interactions.eigs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
# === BREAK/CONTINUE ACROSS FUNCTION BOUNDARIES ===
55

66
# --- break in function must not break caller's for loop ---
7+
# (#337: a loop-less break is now a compile error, so the callee breaks
8+
# its OWN loop; the property under test — never the CALLER's — is unchanged.)
79
define do_break as:
8-
break
10+
for jb in [1, 2]:
11+
break
912

1013
count is 0
1114
for i in [1, 2, 3, 4, 5]:
@@ -16,7 +19,8 @@ assert of [count == 5, "CF1 break in function does not escape for"]
1619
# --- continue in function must not skip caller's loop body ---
1720
total is 0
1821
define do_continue as:
19-
continue
22+
for jc in [1, 2]:
23+
continue
2024

2125
for i in [1, 2, 3]:
2226
do_continue of null
@@ -34,7 +38,8 @@ assert of [wcount == 5, "CF3 break in function does not escape while"]
3438

3539
# --- nested: break in inner function, called from outer function, in loop ---
3640
define inner_break as:
37-
break
41+
loop while 1:
42+
break
3843

3944
define outer_call as:
4045
inner_break of null
@@ -138,7 +143,8 @@ assert of [classify of 99 == "other", "CF19 return from wildcard"]
138143
fns is []
139144
for i in range of 3:
140145
define f(x) as:
141-
break
146+
for kf in [1, 2]:
147+
break
142148
return x
143149
append of [fns, f]
144150

0 commit comments

Comments
 (0)