Skip to content

Commit 159dca9

Browse files
Generators: yield + eager-list-building MVP
Seventh piece of the Python-ergonomics catch-up. Any fn whose body contains `yield expr;` is a generator. Calling it runs the body to completion, collecting yielded values into an Array, and returns the Array. Composes naturally with `for x in gen` — the for loop just iterates the result array. fn fibs(n) { h a = 0; h b = 1; h k = 0; while k < n { yield a; h t = a + b; a = b; b = t; k = k + 1; } } for v in fibs(10) { ... } # 0, 1, 1, 2, 3, 5, 8, 13, 21, 34 Implementation strategy — EAGER list-building: AST: Statement::Yield(Expression) for `yield expr;` Detection: stmts_contain_yield() — recursive walker matching the existing stmts_contain_return() pattern. Generators are statically identifiable from the AST. Runtime: Interpreter gains `yield_stacks: Vec<Vec<Value>>` — a stack of yield-collectors for the current call chain. invoke_user_function detects a generator body, pushes a fresh collector, runs the body, pops the collector, returns Value::Array. The Yield statement's executor appends to the top of the stack. This is NOT a real lazy generator. `yield` in an infinite loop hangs (the body runs to completion before returning). Honest trade-off for shipping the syntax now: + Composes cleanly with the existing for-loop iteration over Value::Array (no for-loop changes needed) + No coroutine state machine, no CPS transform, no separate iterator object — single Value type covers everything + Generator detection is a static AST walk; zero runtime cost for non-generator fns - Memory grows with yield count (no streaming) - Infinite generators don't terminate - Side effects between yields can't be observed lazily (caller sees the whole sequence at once) Lazy generators via continuation-passing or stackful coroutines are future work — the deferred-implementation comment in the relevant code explains the path. For most uses today (comprehension-style generation, finite-sequence factories, helper generators that feed for loops), the eager approach matches Python's surface behavior exactly. Tests (examples/tests/test_generators.omc — 8 tests, all pass): - yield in a while loop produces an array - empty body / zero iterations yields [] - yield-computed values (squares) - yield in if-conditional branch (only-evens) - multiple yields per iteration - generator composes with for-loop iteration - nested generator call (outer generator that uses naturals() inside) - yield + substrate primitives (yield nth_fibonacci(i)) Regression: 225 OMC tests pass (was 217 + 8 new generator tests). All previous suites green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 237ccc6 commit 159dca9

6 files changed

Lines changed: 257 additions & 5 deletions

File tree

examples/tests/test_generators.omc

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Tests for generator functions (eager-list MVP).
2+
#
3+
# Any fn whose body contains `yield expr;` is a generator. Calling
4+
# it runs the body to completion, collecting each yielded value into
5+
# an array, and returns the array. This is NOT lazy — `yield` in an
6+
# infinite loop would hang. Lazy coroutine-based generators are
7+
# future work; eager list-building lets us ship the syntax and
8+
# compose with `for x in gen` today.
9+
10+
fn assert_eq(actual, expected, msg) {
11+
if actual != expected {
12+
test_record_failure(msg + ": expected " + to_string(expected) + " got " + to_string(actual));
13+
}
14+
}
15+
16+
fn assert_true(cond, msg) {
17+
if !cond { test_record_failure(msg); }
18+
}
19+
20+
# ---- basics ----
21+
22+
fn naturals(n) {
23+
h i = 0;
24+
while i < n {
25+
yield i;
26+
i = i + 1;
27+
}
28+
}
29+
30+
fn test_yield_loop() {
31+
h r = naturals(5);
32+
assert_eq(arr_len(r), 5, "5 values yielded");
33+
assert_eq(arr_get(r, 0), 0, "first is 0");
34+
assert_eq(arr_get(r, 4), 4, "last is 4");
35+
}
36+
37+
fn test_yield_empty() {
38+
h r = naturals(0);
39+
assert_eq(arr_len(r), 0, "zero yields → empty array");
40+
}
41+
42+
# ---- yield with computed values ----
43+
44+
fn squares(n) {
45+
h i = 1;
46+
while i <= n {
47+
yield i * i;
48+
i = i + 1;
49+
}
50+
}
51+
52+
fn test_yield_computed() {
53+
h r = squares(4);
54+
assert_eq(arr_get(r, 0), 1, "1^2");
55+
assert_eq(arr_get(r, 3), 16, "4^2");
56+
}
57+
58+
# ---- yield in conditional branch ----
59+
60+
fn even_only(n) {
61+
h i = 0;
62+
while i < n {
63+
if i % 2 == 0 {
64+
yield i;
65+
}
66+
i = i + 1;
67+
}
68+
}
69+
70+
fn test_yield_in_if() {
71+
h r = even_only(10);
72+
assert_eq(arr_len(r), 5, "5 evens in 0..10");
73+
assert_eq(arr_get(r, 0), 0, "first even");
74+
assert_eq(arr_get(r, 4), 8, "last even");
75+
}
76+
77+
# ---- multiple yields per loop iteration ----
78+
79+
fn pairs(n) {
80+
h i = 0;
81+
while i < n {
82+
yield i;
83+
yield i * 10;
84+
i = i + 1;
85+
}
86+
}
87+
88+
fn test_multiple_yields() {
89+
h r = pairs(3);
90+
assert_eq(arr_len(r), 6, "2 yields × 3 iters");
91+
assert_eq(arr_get(r, 0), 0, "first yield, iter 0");
92+
assert_eq(arr_get(r, 1), 0, "second yield, iter 0");
93+
assert_eq(arr_get(r, 2), 1, "first yield, iter 1");
94+
assert_eq(arr_get(r, 3), 10, "second yield, iter 1");
95+
}
96+
97+
# ---- generator composes with for loop ----
98+
99+
fn fibs(n) {
100+
h a = 0;
101+
h b = 1;
102+
h k = 0;
103+
while k < n {
104+
yield a;
105+
h t = a + b;
106+
a = b;
107+
b = t;
108+
k = k + 1;
109+
}
110+
}
111+
112+
fn test_generator_with_for() {
113+
h sum = 0;
114+
for v in fibs(10) {
115+
sum = sum + v;
116+
}
117+
# fib(0..9) = 0,1,1,2,3,5,8,13,21,34 — sum = 88
118+
assert_eq(sum, 88, "sum of first 10 Fibonaccis");
119+
}
120+
121+
# ---- nested generators ----
122+
123+
fn outer(n) {
124+
h r = naturals(n);
125+
h k = 0;
126+
while k < arr_len(r) {
127+
yield arr_get(r, k) * 2;
128+
k = k + 1;
129+
}
130+
}
131+
132+
fn test_nested_generator_call() {
133+
h r = outer(4);
134+
# naturals(4) = [0,1,2,3], doubled = [0,2,4,6]
135+
assert_eq(arr_len(r), 4, "4 values");
136+
assert_eq(arr_get(r, 3), 6, "last is 6");
137+
}
138+
139+
# ---- yield + substrate primitives ----
140+
141+
fn substrate_aligned_fibs(n) {
142+
h i = 0;
143+
while i < n {
144+
yield nth_fibonacci(i);
145+
i = i + 1;
146+
}
147+
}
148+
149+
fn test_yield_substrate_primitives() {
150+
h r = substrate_aligned_fibs(6);
151+
# nth_fibonacci(0..5) = 0, 1, 1, 2, 3, 5
152+
assert_eq(arr_get(r, 0), 0, "fib(0)");
153+
assert_eq(arr_get(r, 5), 5, "fib(5)");
154+
}

omnimcode-core/src/ast.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ pub enum Statement {
105105
/// Future work: carry the thrown Value through Err(Value) instead
106106
/// of stringifying, enabling typed-catch hierarchies.
107107
Throw(Expression),
108+
/// `yield expr` — emit one value from a generator function.
109+
/// MVP semantics (eager list-building): a fn containing any Yield
110+
/// statement is a generator. Calling it runs the body to completion,
111+
/// collecting yielded values into an array, and returns that array.
112+
/// This is NOT lazy — infinite generators would hang. Real
113+
/// coroutine-based lazy generators are future work; the eager
114+
/// list-building approach unlocks the syntactic shape today.
115+
Yield(Expression),
108116
/// `class Name { field1; field2; fn method1(self, ...) { ... } }`
109117
/// (optional `extends Parent` clause for inheritance).
110118
///

omnimcode-core/src/compiler.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -992,11 +992,12 @@ impl Compiler {
992992
Statement::FunctionDef { .. } => {
993993
// Function defs hoisted by compile_program(); skip here.
994994
}
995-
Statement::Try { .. } | Statement::Throw(_) => {
995+
Statement::Try { .. } | Statement::Throw(_) | Statement::Yield(_) => {
996996
// Tree-walk fallback. See Op::ExecStmt comments — full
997997
// exception unwind would require a side try-stack and
998998
// a Result-aware op dispatch loop. Until that pays for
999-
// itself, fall back to the AST walker for try/catch/throw.
999+
// itself, fall back to the AST walker for try/catch/
1000+
// throw/yield.
10001001
self.emit(Op::ExecStmt(Box::new(s.clone())));
10011002
}
10021003
Statement::ClassDef { .. } => {

omnimcode-core/src/formatter.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,11 @@ fn format_stmt(stmt: &Statement, level: usize, out: &mut String) {
199199
format_expr(e, out);
200200
out.push_str(";\n");
201201
}
202+
Statement::Yield(e) => {
203+
out.push_str("yield ");
204+
format_expr(e, out);
205+
out.push_str(";\n");
206+
}
202207
Statement::Match { scrutinee, arms } => {
203208
out.push_str("match ");
204209
format_expr(scrutinee, out);

omnimcode-core/src/interpreter.rs

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ pub struct Interpreter {
2020
/// dispatch path walks this chain when `<Child>__<method>` isn't
2121
/// found, trying `<Parent>__<method>` and so on.
2222
class_parents: HashMap<String, String>,
23+
/// Active yield collector for the current generator frame. Set
24+
/// by invoke_user_function when entering a generator fn (one
25+
/// whose body contains Yield); each Yield statement appends to
26+
/// the top of this stack. On exit, the collector is popped and
27+
/// returned as a Value::Array. Stack-of-vecs supports nested
28+
/// generator invocations.
29+
yield_stacks: Vec<Vec<Value>>,
2330
/// Optional JIT dispatch hook. When set, `invoke_user_function_at`
2431
/// consults this BEFORE running the tree-walk body. If the hook
2532
/// returns `Some(result)`, that result wins; otherwise tree-walk
@@ -105,6 +112,7 @@ impl Interpreter {
105112
call_stack: Vec::new(),
106113
host_builtins: HashMap::new(),
107114
class_parents: HashMap::new(),
115+
yield_stacks: Vec::new(),
108116
}
109117
}
110118

@@ -667,6 +675,9 @@ impl Interpreter {
667675
Statement::Throw(e) => Statement::Throw(
668676
Self::rewrite_call_expr(e, module_names, alias),
669677
),
678+
Statement::Yield(e) => Statement::Yield(
679+
Self::rewrite_call_expr(e, module_names, alias),
680+
),
670681
Statement::Match { scrutinee, arms } => Statement::Match {
671682
scrutinee: Self::rewrite_call_expr(scrutinee, module_names, alias),
672683
arms: arms
@@ -1589,6 +1600,18 @@ impl Interpreter {
15891600
let v = self.eval_expr(expr)?;
15901601
Err(v.to_display_string())
15911602
}
1603+
Statement::Yield(expr) => {
1604+
// Append the yielded value to the current generator
1605+
// frame's collector (top of yield_stacks). If we're
1606+
// not inside a generator (no collector pushed), it's
1607+
// a programming error — but rather than panic, we
1608+
// accept silently for ergonomics during dev.
1609+
let v = self.eval_expr(expr)?;
1610+
if let Some(top) = self.yield_stacks.last_mut() {
1611+
top.push(v);
1612+
}
1613+
Ok(())
1614+
}
15921615
_ => Ok(()),
15931616
}
15941617
}
@@ -6895,6 +6918,20 @@ impl Interpreter {
68956918
// and error paths so the trace doesn't leak across calls.
68966919
self.call_stack.push((name.to_string(), call_site));
68976920

6921+
// Generator detection: a fn body that contains any Yield
6922+
// statement is a generator. We push a fresh yield-collector
6923+
// onto yield_stacks; every Yield in the body appends to it.
6924+
// On exit, the collector is popped and returned as a
6925+
// Value::Array. Any explicit `return` inside a generator is
6926+
// silently ignored (Python's behavior: `return` in a
6927+
// generator without an expression ends iteration; with an
6928+
// expression, it becomes the StopIteration value, which OMC
6929+
// doesn't represent in the eager-list model).
6930+
let is_generator = stmts_contain_yield(body);
6931+
if is_generator {
6932+
self.yield_stacks.push(Vec::new());
6933+
}
6934+
68986935
let mut exec_err: Option<String> = None;
68996936
for stmt in body {
69006937
if let Err(e) = self.execute_stmt(stmt) {
@@ -6910,9 +6947,8 @@ impl Interpreter {
69106947
self.locals.pop();
69116948

69126949
if let Some(e) = exec_err {
6913-
// Append our own frame + the call site and rethrow.
6914-
// Each invoke_user_function up the stack does the same,
6915-
// so the final message lists every frame innermost-first.
6950+
// Drop the generator's collector on error.
6951+
if is_generator { self.yield_stacks.pop(); }
69166952
return Err(format!(
69176953
"{}\n at {}{}",
69186954
e,
@@ -6921,6 +6957,14 @@ impl Interpreter {
69216957
));
69226958
}
69236959

6960+
if is_generator {
6961+
// Return the collected yields as an array. Ignore the
6962+
// fn's return slot — generators communicate via yield.
6963+
self.return_value.take();
6964+
let yields = self.yield_stacks.pop().unwrap_or_default();
6965+
return Ok(Value::Array(crate::value::HArray::from_vec(yields)));
6966+
}
6967+
69246968
let result = self.return_value.take().unwrap_or(Value::Null);
69256969
Ok(result)
69266970
}
@@ -8060,6 +8104,35 @@ fn stmt_contains_return(s: &Statement) -> bool {
80608104
}
80618105
}
80628106

8107+
/// Does a statement list contain any `yield` statement? Used by the
8108+
/// generator-fn detector — a fn body with at least one Yield is
8109+
/// dispatched through the yield-collector path at call time.
8110+
pub(crate) fn stmts_contain_yield(stmts: &[Statement]) -> bool {
8111+
for s in stmts {
8112+
if stmt_contains_yield(s) { return true; }
8113+
}
8114+
false
8115+
}
8116+
8117+
fn stmt_contains_yield(s: &Statement) -> bool {
8118+
match s {
8119+
Statement::Yield(_) => true,
8120+
Statement::If { then_body, elif_parts, else_body, .. } => {
8121+
stmts_contain_yield(then_body)
8122+
|| elif_parts.iter().any(|(_, b)| stmts_contain_yield(b))
8123+
|| else_body.as_ref().is_some_and(|b| stmts_contain_yield(b))
8124+
}
8125+
Statement::While { body, .. } => stmts_contain_yield(body),
8126+
Statement::For { body, .. } => stmts_contain_yield(body),
8127+
Statement::Try { body, handler, finally, .. } => {
8128+
stmts_contain_yield(body)
8129+
|| stmts_contain_yield(handler)
8130+
|| finally.as_ref().is_some_and(|b| stmts_contain_yield(b))
8131+
}
8132+
_ => false,
8133+
}
8134+
}
8135+
80638136
/// Missing-return heal: for every user fn lacking ANY return statement,
80648137
/// append `return null;` at the tail. Keeps callers from seeing the
80658138
/// confusing "fn ended without return" runtime error — most users mean

omnimcode-core/src/parser.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ pub enum Token {
4343
Match,
4444
Class,
4545
Extends,
46+
Yield,
4647
/// f-string template — alternating literal and expression segments.
4748
/// Parser turns this into `concat_many(parts...)` at expression
4849
/// position.
@@ -477,6 +478,7 @@ impl Lexer {
477478
"throw" => Token::Throw,
478479
"class" => Token::Class,
479480
"extends" => Token::Extends,
481+
"yield" => Token::Yield,
480482
"match" => Token::Match,
481483
"and" => Token::And,
482484
"or" => Token::Or,
@@ -883,6 +885,15 @@ impl Parser {
883885
self.expect(Token::Semicolon)?;
884886
Ok(Statement::Throw(expr))
885887
}
888+
Token::Yield => {
889+
// `yield expr;` — emit one value from a generator fn.
890+
// Eager-list MVP: each yield appends to a collector
891+
// that the call boundary turns into a Value::Array.
892+
self.advance();
893+
let expr = self.parse_expression()?;
894+
self.expect(Token::Semicolon)?;
895+
Ok(Statement::Yield(expr))
896+
}
886897
Token::Match => self.parse_match_stmt(),
887898
// `import core;` or `import core as c;` or `load "path";`
888899
Token::Import | Token::Load => {

0 commit comments

Comments
 (0)