Skip to content

Commit a5ed502

Browse files
Phase 3: stack traces + REPL upgrades
* Stack traces. Errors raised inside a user function now carry the call chain, innermost frame first: Error: <msg> at inner at middle at outer Both engines produce identical traces. Tree-walk: each invoke_user_function appends its own frame on the error path. VM: run_function gained a wrapper that pushes a call frame on entry, pops on exit, and appends to the message on Err. Source line numbers are TODO (would require AST span threading). Bonus: VM run_function previously leaked a scope frame on `?`-style errors (vm_pop_scope was only reached on the success path). The new wrapper rescues the scope on the error path too. * REPL upgrades. The interactive shell now: - Evaluates and prints bare expressions: `1 + 2` shows `3`. Parser error "Expected Semicolon" + balanced braces + no trailing `;` triggers re-parse with `;` appended. - Continues multi-line input until braces/parens are balanced. The is_balanced helper ignores chars inside string literals. - REPL meta-commands: :help, :quit, :reset. - Prompt switches to "...> " during continuation. * Parser: `arr[idx]` / `dict[key]` work in expression position. The Statement::Assignment dispatch previously committed to Statement:: IndexAssignment as soon as it saw `Ident[`, then unconditionally expected `=`. Now it lookaheads after `]` and falls back to expression-statement parsing if the `=` isn't there. Without this, bare `d["key"]` at the REPL hit "Expected Eq, got Eof". 41/41 functional examples produce identical output under tree-walk and VM. 92/92 unit tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 007bc4e commit a5ed502

4 files changed

Lines changed: 274 additions & 39 deletions

File tree

omnimcode-core/src/interpreter.rs

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ pub struct Interpreter {
3535
/// reason as test_failures: a plain OMC global wouldn't propagate
3636
/// to nested assertion calls.
3737
test_current_name: std::cell::RefCell<String>,
38+
/// Names of currently-executing user functions, innermost-last.
39+
/// Pushed at the entry of invoke_user_function, popped on exit
40+
/// (success OR failure). Used to render call traces on errors —
41+
/// future work will also attach source line numbers via per-op
42+
/// metadata in CompiledFunction.
43+
call_stack: Vec<String>,
3844
}
3945

4046
impl Interpreter {
@@ -57,6 +63,7 @@ impl Interpreter {
5763
lambda_counter: 0,
5864
test_failures: std::cell::RefCell::new(Vec::new()),
5965
test_current_name: std::cell::RefCell::new(String::new()),
66+
call_stack: Vec::new(),
6067
}
6168
}
6269

@@ -3337,15 +3344,34 @@ impl Interpreter {
33373344
self.set_var(param.clone(), arg);
33383345
}
33393346

3347+
// Push a call-stack frame so error messages can show
3348+
// who-called-whom. The frame is popped in BOTH the success
3349+
// and error paths so the trace doesn't leak across calls.
3350+
self.call_stack.push(name.to_string());
3351+
3352+
let mut exec_err: Option<String> = None;
33403353
for stmt in body {
3341-
self.execute_stmt(stmt)?;
3354+
if let Err(e) = self.execute_stmt(stmt) {
3355+
exec_err = Some(e);
3356+
break;
3357+
}
33423358
if self.return_value.is_some() {
33433359
break;
33443360
}
33453361
}
33463362

3347-
let result = self.return_value.take().unwrap_or(Value::Null);
3363+
self.call_stack.pop();
33483364
self.locals.pop();
3365+
3366+
if let Some(e) = exec_err {
3367+
// Append our own frame and rethrow. Each invoke_user_function
3368+
// up the stack does the same, so the final message lists
3369+
// every frame innermost-first. Mirrors VM run_function's
3370+
// error-path formatting.
3371+
return Err(format!("{}\n at {}", e, name));
3372+
}
3373+
3374+
let result = self.return_value.take().unwrap_or(Value::Null);
33493375
Ok(result)
33503376
}
33513377

@@ -3457,6 +3483,41 @@ impl Interpreter {
34573483
self.return_value.take()
34583484
}
34593485

3486+
/// Push a call-stack frame (function name only for now). The VM
3487+
/// calls this at the entry of run_function so error traces work
3488+
/// for VM-dispatched calls too, not just tree-walk.
3489+
pub fn push_call_frame(&mut self, name: &str) {
3490+
self.call_stack.push(name.to_string());
3491+
}
3492+
3493+
/// REPL-facing: evaluate a single expression in the current
3494+
/// interpreter state. Used to implement Python-style
3495+
/// "type-an-expression-and-see-the-value" at the prompt.
3496+
pub fn eval_for_repl(&mut self, expr: &Expression) -> Result<Value, String> {
3497+
self.eval_expr(expr)
3498+
}
3499+
3500+
/// Pop a call-stack frame. Counterpart to push_call_frame; called
3501+
/// in BOTH the success and error paths so the trace can't leak
3502+
/// across calls.
3503+
pub fn pop_call_frame(&mut self) {
3504+
self.call_stack.pop();
3505+
}
3506+
3507+
/// Format an error message with the current call stack appended.
3508+
/// Used by VM run_function on its error-return path to give the
3509+
/// same kind of trace tree-walk produces. Innermost frame first.
3510+
pub fn format_error_with_trace(&self, msg: &str) -> String {
3511+
if msg.contains("\n at ") {
3512+
return msg.to_string();
3513+
}
3514+
let mut out = msg.to_string();
3515+
for fname in self.call_stack.iter().rev() {
3516+
out.push_str(&format!("\n at {}", fname));
3517+
}
3518+
out
3519+
}
3520+
34603521
/// VM-facing: same idea for break/continue flags. Returns and
34613522
/// clears the flag.
34623523
pub fn vm_take_break(&mut self) -> bool {

omnimcode-core/src/main.rs

Lines changed: 149 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -254,56 +254,176 @@ fn execute_program(source: &str) -> Result<(), String> {
254254
}
255255

256256
fn repl() {
257-
println!("═══════════════════════════════════════════════════════════════");
258-
println!(" OMNIcode Interactive Shell (v1.0.0-standalone) ");
259-
println!("═══════════════════════════════════════════════════════════════");
260-
println!();
261-
println!("Type OMNIcode statements. Press Ctrl+C to exit.");
257+
println!("OMNIcode interactive shell");
258+
println!("Type :help for commands, :quit to exit. Statements end with ;");
262259
println!();
263260

264261
let stdin = io::stdin();
265262
let mut interpreter = Interpreter::new();
266-
let mut input_buffer = String::new();
263+
let mut buffer = String::new();
264+
let mut continuing = false;
267265

268266
loop {
269-
print!("omc> ");
267+
print!("{}", if continuing { "...> " } else { "omc> " });
270268
io::stdout().flush().unwrap();
271269

272-
input_buffer.clear();
273-
match stdin.read_line(&mut input_buffer) {
274-
Ok(0) => break, // EOF
275-
Ok(_) => {
276-
if input_buffer.trim().is_empty() {
270+
let mut line = String::new();
271+
match stdin.read_line(&mut line) {
272+
Ok(0) => { println!(); break; }
273+
Err(e) => { eprintln!("Error reading input: {}", e); break; }
274+
Ok(_) => {}
275+
}
276+
277+
let trimmed = line.trim();
278+
279+
// REPL meta-commands (only at the start of a fresh input).
280+
if !continuing {
281+
match trimmed {
282+
"" => continue,
283+
":quit" | ":q" | ":exit" => break,
284+
":help" | ":h" | ":?" => {
285+
repl_print_help();
277286
continue;
278287
}
288+
":reset" => {
289+
interpreter = Interpreter::new();
290+
println!("interpreter state reset");
291+
continue;
292+
}
293+
_ => {}
294+
}
295+
}
296+
297+
buffer.push_str(&line);
298+
299+
// Heuristic for multi-line input: count unmatched braces/parens/brackets.
300+
// If they're unbalanced (more openers than closers), keep reading.
301+
// Skips characters inside string literals so `"{"` doesn't confuse us.
302+
if !is_balanced(&buffer) {
303+
continuing = true;
304+
continue;
305+
}
279306

280-
// Try to parse and execute
281-
let trimmed = input_buffer.trim();
282-
let mut parser = Parser::new(trimmed);
283-
284-
match parser.parse() {
307+
// First attempt: parse as-typed.
308+
let trimmed_buffer = buffer.trim().to_string();
309+
let mut parser = Parser::new(&trimmed_buffer);
310+
match parser.parse() {
311+
Ok(statements) => {
312+
continuing = false;
313+
let to_run = buffer.clone();
314+
buffer.clear();
315+
repl_execute(&mut interpreter, &to_run, statements);
316+
}
317+
Err(msg) if msg.contains("Semicolon") && !trimmed_buffer.ends_with(';') => {
318+
// Bare-expression mode: parser wanted a `;` but the
319+
// user hit enter without one. Try parsing with `;`
320+
// appended; if that yields a single Expression
321+
// statement, evaluate it and print the result. This
322+
// is what makes `1 + 2` (no semicolon) print 3.
323+
let with_semi = format!("{};", trimmed_buffer);
324+
let mut p2 = Parser::new(&with_semi);
325+
match p2.parse() {
285326
Ok(statements) => {
286-
match interpreter.execute(statements) {
287-
Ok(()) => {},
288-
Err(e) => eprintln!("Error: {}", e),
289-
}
327+
continuing = false;
328+
let to_run = buffer.clone();
329+
buffer.clear();
330+
repl_execute(&mut interpreter, &to_run, statements);
290331
}
291-
Err(e) => {
292-
eprintln!("Parse error: {}", e);
332+
Err(msg2) => {
333+
eprintln!("Parse error: {}", msg2);
334+
continuing = false;
335+
buffer.clear();
293336
}
294337
}
295338
}
296-
Err(e) => {
297-
eprintln!("Error reading input: {}", e);
298-
break;
339+
Err(msg) => {
340+
// Other parse errors that look like "needs more input"
341+
// (unterminated string, missing closing brace not caught
342+
// by is_balanced) → ask for another line. Otherwise
343+
// show the error and reset.
344+
if msg.contains("Eof") || msg.contains("end of") {
345+
continuing = true;
346+
} else {
347+
eprintln!("Parse error: {}", msg);
348+
continuing = false;
349+
buffer.clear();
350+
}
299351
}
300352
}
301353
}
302354

355+
println!("bye");
356+
}
357+
358+
fn repl_print_help() {
359+
println!("REPL commands:");
360+
println!(" :help, :h, :? show this message");
361+
println!(" :quit, :q exit the REPL");
362+
println!(" :reset discard all defined variables and functions");
303363
println!();
304-
println!("═══════════════════════════════════════════════════════════════");
305-
println!("Thank you for using OMNIcode!");
306-
println!("═══════════════════════════════════════════════════════════════");
364+
println!("Tips:");
365+
println!(" Statements need a trailing `;`. Multi-line input continues");
366+
println!(" while braces/parens are unbalanced (use a closing `}}` or");
367+
println!(" `)` to finish). Type a bare expression with `;` to see its");
368+
println!(" result via println().");
369+
}
370+
371+
/// Run a parsed REPL input. If the input is a single expression
372+
/// statement (with no trailing semicolon in the source), evaluate it
373+
/// and print the result — Python REPL style. Otherwise execute as
374+
/// normal statements.
375+
fn repl_execute(
376+
interp: &mut Interpreter,
377+
raw_source: &str,
378+
statements: Vec<omnimcode_core::ast::Statement>,
379+
) {
380+
use omnimcode_core::ast::Statement;
381+
// Detect implicit-print case: exactly one Expression statement
382+
// and the source has no trailing `;`. This makes `1 + 2` (no
383+
// semicolon) print `3`, while `1 + 2;` runs silently.
384+
let trimmed = raw_source.trim();
385+
let is_bare_expr = !trimmed.ends_with(';')
386+
&& statements.len() == 1
387+
&& matches!(&statements[0], Statement::Expression(_));
388+
389+
if is_bare_expr {
390+
if let Statement::Expression(e) = &statements[0] {
391+
match interp.eval_for_repl(e) {
392+
Ok(v) => println!("{}", v.to_display_string()),
393+
Err(msg) => eprintln!("Error: {}", msg),
394+
}
395+
return;
396+
}
397+
}
398+
399+
if let Err(e) = interp.execute(statements) {
400+
eprintln!("Error: {}", e);
401+
}
402+
}
403+
404+
/// Counts unmatched openers in `s`, ignoring contents of string
405+
/// literals. Returns true when all brackets/parens/braces are balanced
406+
/// — i.e. when the REPL can attempt to parse the input.
407+
fn is_balanced(s: &str) -> bool {
408+
let mut depth: i32 = 0;
409+
let mut in_str = false;
410+
let mut prev = '\0';
411+
for c in s.chars() {
412+
if in_str {
413+
// Honor backslash escapes so `"\""` doesn't end the string early.
414+
if c == '"' && prev != '\\' { in_str = false; }
415+
prev = c;
416+
continue;
417+
}
418+
match c {
419+
'"' => in_str = true,
420+
'(' | '[' | '{' => depth += 1,
421+
')' | ']' | '}' => depth -= 1,
422+
_ => {}
423+
}
424+
prev = c;
425+
}
426+
depth <= 0 && !in_str
307427
}
308428

309429
#[cfg(test)]

omnimcode-core/src/parser.rs

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -779,17 +779,33 @@ impl Parser {
779779
})
780780
}
781781
Token::LBracket => {
782+
// Could be `arr[idx] = value;` (IndexAssignment) or
783+
// `arr[idx];` / `arr[idx] + 1;` (expression statement).
784+
// Distinguish by what follows the `]`. If `=`, it's
785+
// an assignment; otherwise rewind and re-parse as
786+
// an expression statement so dict / array indexing
787+
// works in expression position too.
788+
let pre_lbracket = checkpoint.clone();
782789
self.advance();
783790
let index = self.parse_expression()?;
784791
self.expect(Token::RBracket)?;
785-
self.expect(Token::Eq)?;
786-
let value = self.parse_expression()?;
787-
self.expect(Token::Semicolon)?;
788-
Ok(Statement::IndexAssignment {
789-
name: ident,
790-
index,
791-
value,
792-
})
792+
if self.current() == Token::Eq {
793+
self.advance();
794+
let value = self.parse_expression()?;
795+
self.expect(Token::Semicolon)?;
796+
Ok(Statement::IndexAssignment {
797+
name: ident,
798+
index,
799+
value,
800+
})
801+
} else {
802+
// Rewind and treat the whole thing as an
803+
// expression statement.
804+
self.tokens = pre_lbracket;
805+
let expr = self.parse_expression()?;
806+
self.expect(Token::Semicolon)?;
807+
Ok(Statement::Expression(expr))
808+
}
793809
}
794810
_ => {
795811
// Parse as expression statement

omnimcode-core/src/vm.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,49 @@ impl Vm {
3434
self.run_function(&module.main, &[], module)
3535
}
3636

37+
/// Public wrapper around the bytecode interpreter loop. Adds
38+
/// call-stack tracking and error-trace formatting on top of the
39+
/// raw `run_function_inner` so VM-thrown errors get a "call at X"
40+
/// trace just like tree-walk's invoke_user_function does.
41+
/// Also rescues the operand-stack scope on error (the inner loop
42+
/// uses `?` extensively, which would otherwise leak a scope frame).
3743
fn run_function(
3844
&mut self,
3945
func: &CompiledFunction,
4046
args: &[Value],
4147
module: &Module,
48+
) -> Result<Value, String> {
49+
// Skip stack-frame tracking for __main__ — "in __main__" is
50+
// noise; the top-level module isn't a user-issued call.
51+
let track_frame = func.name != "__main__";
52+
if track_frame {
53+
self.interp.push_call_frame(&func.name);
54+
}
55+
let result = self.run_function_inner(func, args, module);
56+
if track_frame {
57+
self.interp.pop_call_frame();
58+
}
59+
match result {
60+
Ok(v) => Ok(v),
61+
Err(e) => {
62+
// The inner loop's `?` skipped vm_pop_scope on error;
63+
// restore balance here so subsequent calls don't see
64+
// a leaked scope frame.
65+
self.interp.vm_pop_scope();
66+
if track_frame {
67+
Err(format!("{}\n at {}", e, func.name))
68+
} else {
69+
Err(e)
70+
}
71+
}
72+
}
73+
}
74+
75+
fn run_function_inner(
76+
&mut self,
77+
func: &CompiledFunction,
78+
args: &[Value],
79+
module: &Module,
4280
) -> Result<Value, String> {
4381
let mut stack: Vec<Value> = Vec::with_capacity(32);
4482
let mut ip: usize = 0;

0 commit comments

Comments
 (0)