Skip to content

Commit 9637c8b

Browse files
Phase 1 ergonomics: string +, dicts, try/catch
Three Python-grade ergonomic gaps closed in one push. * String + concat. `"count: " + 42` now produces "count: 42" instead of returning 0 via int coercion. Added Value::to_display_string() for bare-number stringification (concat_many's existing logic centralized into one helper); both tree-walk Expression::Add and VM arith_add use it when either operand is a string. * Dictionaries / hash maps. New Value::Dict variant backed by BTreeMap<String, Value> (sorted-key iteration; deterministic for tests). Literal syntax `{"k": v, ...}`. Indexing via `d["key"]` routes through the same Op::ArrayIndex now made polymorphic (dispatches on container type). Nine builtins: dict_new / dict_get / dict_set / dict_has / dict_del / dict_keys / dict_values / dict_len / dict_merge. dict_get takes an optional default arg. VM gets dedicated Op::DictSetNamed / Op::DictDelNamed for mutating operations — same name-on-opcode pattern as Op::ArrPushNamed solves the synthetic-arg-shim mutation loss. * try/catch error handling. `try { ... } catch err { ... }`. The caught value is a Value::String holding the error message; builtin failures and the new `error("msg")` builtin both raise. VM compiles Statement::Try to Op::ExecStmt — a tree-walk fallback that runs the AST node via the embedded Interpreter. Pragmatic choice: full exception unwind would require either a side try-stack or a Result-aware op dispatch loop refactor; this costs only a tree-walk per try block, which is rare in hot paths. Critical detail: after Op::ExecStmt the VM drains interp.return_value via a new vm_take_return helper, so a `return` inside a try body correctly bubbles out of the surrounding VM-compiled function. 40/40 functional examples produce byte-identical output under tree-walk and VM. 92/92 unit tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8d7ec74 commit 9637c8b

9 files changed

Lines changed: 511 additions & 20 deletions

File tree

omnimcode-core/src/ast.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ pub enum Statement {
5252
module: String,
5353
alias: Option<String>,
5454
},
55+
/// `try { ... } catch err { ... }`. If the try block raises an
56+
/// error (via `error("msg")` or any builtin failure), execution
57+
/// jumps to the catch block with `err_var` bound to a Value::String
58+
/// holding the error message. Without try/catch, builtin failures
59+
/// crash the program.
60+
Try {
61+
body: Vec<Statement>,
62+
err_var: String,
63+
handler: Vec<Statement>,
64+
},
5565
}
5666

5767
#[derive(Clone, Debug, PartialEq)]
@@ -68,6 +78,11 @@ pub enum Expression {
6878
String(String),
6979
Boolean(bool),
7080
Array(Vec<Expression>),
81+
/// Dict literal: `{"k1": v1, "k2": v2}`. Keys are string-typed
82+
/// expressions (must evaluate to strings); values are arbitrary.
83+
/// Stored as a Vec<(key_expr, value_expr)> so the compiler can
84+
/// emit them in source order.
85+
Dict(Vec<(Expression, Expression)>),
7186

7287
// Variables and access
7388
Variable(String),

omnimcode-core/src/bytecode.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,29 @@ pub enum Op {
9898

9999
// Arrays
100100
NewArray(usize), // pop N items into a new array, push
101-
ArrayIndex, // pop index, pop array, push array[index]
101+
/// Pop 2N values (alternating key, value) off the stack and
102+
/// build a Dict. Keys are stringified via to_display_string at
103+
/// build time. Order matches source-order pairs in the literal.
104+
NewDict(usize),
105+
/// Mutating dict insert: pop value, pop key, store at named dict
106+
/// variable in the current scope. Same name-on-opcode trick as
107+
/// ArrSetNamed — required so the mutation propagates back through
108+
/// the VM scope chain instead of getting lost in vm_call_builtin's
109+
/// synthetic-arg shim. Emitted by the compiler when it sees
110+
/// `dict_set(VAR, k, v)` with a literal Variable as the first arg.
111+
DictSetNamed(String),
112+
/// Mutating dict delete: pop key, remove from named dict variable.
113+
/// Same rationale as DictSetNamed.
114+
DictDelNamed(String),
115+
/// Tree-walk fallback: execute an AST statement via the embedded
116+
/// Interpreter rather than as bytecode. Used for forms whose
117+
/// control flow doesn't map cleanly onto the stack VM (currently
118+
/// just Statement::Try — exception unwind would require either
119+
/// a side try-stack or a full Result-aware op dispatch refactor;
120+
/// the fallback keeps the VM dispatch loop simple and pays a
121+
/// per-try-block tree-walk cost only).
122+
ExecStmt(Box<crate::ast::Statement>),
123+
ArrayIndex, // pop index, pop container; container dispatch (Array → idx int, Dict → idx str)
102124
ArrayIndexAssign(String), // pop value, pop index, assign array_var[idx] = value
103125
/// Mutating array push: pop one value off the stack and append it
104126
/// to the named array variable in the current scope. Emitted by the

omnimcode-core/src/compiler.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ impl Compiler {
8787
Expression::String(_) => Some("string"),
8888
Expression::Boolean(_) => Some("bool"),
8989
Expression::Array(_) => Some("array"),
90+
Expression::Dict(_) => Some("dict"),
9091
Expression::Variable(name) => self.var_types.get(name.as_str()).copied(),
9192
Expression::Add(l, r)
9293
| Expression::Sub(l, r)
@@ -255,6 +256,13 @@ impl Compiler {
255256
}
256257
self.emit(Op::NewArray(items.len()));
257258
}
259+
Expression::Dict(pairs) => {
260+
for (k, v) in pairs {
261+
self.compile_expr(k)?;
262+
self.compile_expr(v)?;
263+
}
264+
self.emit(Op::NewDict(pairs.len()));
265+
}
258266
Expression::Add(l, r) => {
259267
let lt = self.infer_type(l);
260268
let rt = self.infer_type(r);
@@ -412,6 +420,28 @@ impl Compiler {
412420
return Ok(());
413421
}
414422
}
423+
if name == "dict_set" && args.len() == 3 {
424+
if let Expression::Variable(d_name) = &args[0] {
425+
// key then value → stack top is value, beneath it is key
426+
self.compile_expr(&args[1])?; // key
427+
self.compile_expr(&args[2])?; // value
428+
self.emit(Op::DictSetNamed(d_name.clone()));
429+
// dict_set returns Null in tree-walk; mirror that
430+
// so the stack stays balanced for the caller.
431+
let null_idx = self.add_const(Const::Null);
432+
self.emit(Op::LoadConst(null_idx));
433+
return Ok(());
434+
}
435+
}
436+
if name == "dict_del" && args.len() == 2 {
437+
if let Expression::Variable(d_name) = &args[0] {
438+
self.compile_expr(&args[1])?; // key
439+
self.emit(Op::DictDelNamed(d_name.clone()));
440+
let null_idx = self.add_const(Const::Null);
441+
self.emit(Op::LoadConst(null_idx));
442+
return Ok(());
443+
}
444+
}
415445
}
416446
// Fast-path inline for hot harmonic ops — avoids the Call -> bridge
417447
// -> stdlib lookup overhead. Only inline when the user HASN'T
@@ -829,6 +859,13 @@ impl Compiler {
829859
Statement::FunctionDef { .. } => {
830860
// Function defs hoisted by compile_program(); skip here.
831861
}
862+
Statement::Try { .. } => {
863+
// Tree-walk fallback. See Op::ExecStmt comments — full
864+
// exception unwind would require a side try-stack and
865+
// a Result-aware op dispatch loop. Until that pays for
866+
// itself, fall back to the AST walker for try/catch.
867+
self.emit(Op::ExecStmt(Box::new(s.clone())));
868+
}
832869
}
833870
Ok(())
834871
}

omnimcode-core/src/disasm.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ fn op_mnemonic(op: &Op, ip: usize, constants: &[Const]) -> String {
7171
Op::ReturnNull => "RETURN_NULL".to_string(),
7272

7373
Op::NewArray(n) => format!("NEW_ARRAY {}", n),
74+
Op::NewDict(n) => format!("NEW_DICT {}", n),
75+
Op::DictSetNamed(name) => format!("DICT_SET_NAMED {}", name),
76+
Op::DictDelNamed(name) => format!("DICT_DEL_NAMED {}", name),
77+
Op::ExecStmt(_) => "EXEC_STMT <ast>".to_string(),
7478
Op::ArrayIndex => "ARRAY_INDEX".to_string(),
7579
Op::ArrayIndexAssign(name) => format!("ARRAY_INDEX_ASSIGN {}", name),
7680
Op::ArrPushNamed(name) => format!("ARR_PUSH_NAMED {}", name),

omnimcode-core/src/formatter.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,17 @@ fn format_stmt(stmt: &Statement, level: usize, out: &mut String) {
170170
}
171171
out.push_str(";\n");
172172
}
173+
Statement::Try { body, err_var, handler } => {
174+
out.push_str("try {\n");
175+
for s in body { format_stmt(s, level + 1, out); }
176+
indent_to(level, out);
177+
out.push_str("} catch ");
178+
out.push_str(err_var);
179+
out.push_str(" {\n");
180+
for s in handler { format_stmt(s, level + 1, out); }
181+
indent_to(level, out);
182+
out.push_str("}\n");
183+
}
173184
}
174185
}
175186

@@ -216,6 +227,16 @@ fn format_expr(expr: &Expression, out: &mut String) {
216227
}
217228
out.push(']');
218229
}
230+
Expression::Dict(pairs) => {
231+
out.push('{');
232+
for (i, (k, v)) in pairs.iter().enumerate() {
233+
if i > 0 { out.push_str(", "); }
234+
format_expr(k, out);
235+
out.push_str(": ");
236+
format_expr(v, out);
237+
}
238+
out.push('}');
239+
}
219240
Expression::Add(l, r) => format_binop(l, "+", r, out),
220241
Expression::Sub(l, r) => format_binop(l, "-", r, out),
221242
Expression::Mul(l, r) => format_binop(l, "*", r, out),

0 commit comments

Comments
 (0)