Skip to content

Commit 85e2888

Browse files
Real exceptions: throw + finally on top of the existing try/catch
First step of the Python-ergonomics catch-up goal. The existing Statement::Try only supported `try { ... } catch err { ... }` where the caught value was an error string produced by builtin failures. This commit adds: - `throw expr` — explicit exception raise. Expression is evaluated and its display-string becomes the error the surrounding catch receives. New Statement::Throw(Expression). - `finally { ... }` clause on try blocks. Optional, runs after both the body AND any handler — including when the handler itself raises (matches Python's try/except/finally). Statement::Try extended with `finally: Option<Vec<Statement>>`. Implementation surfaces touched: - ast.rs: Try gains `finally: Option<Vec<Statement>>`; Throw(Expression) added - parser.rs: Token::Throw, Token::Finally lexed; parse_try_stmt reads optional finally; throw is its own statement - interpreter.rs: execute_stmt handles Throw via eval+Err; Try now runs finally regardless of outcome; rewrite_module_calls + register_user_functions visit the new shape - formatter.rs: Try formatter outputs the finally clause; Throw formats as `throw expr;` - compiler.rs: Throw falls back to Op::ExecStmt like Try (the tree-walk path handles unwind; bytecode VM would need a try-stack to compile these natively) Test cases (examples/tests/test_exceptions.omc — 8 tests, all pass): 1. Basic throw + catch with string 2. Throw number; catch receives stringified form 3. Catch does NOT run when try succeeds 4. Finally runs after successful try 5. Finally runs after catch 6. Nested try — inner catch consumes, outer doesn't run 7. Nested try with rethrow — outer catch sees rethrown value 8. Throw propagates through fn call boundary What's NOT yet shipped (future work toward "feel like a real language"): - Typed-catch hierarchies. The caught value is currently always a Value::String. To support `catch e: SomeType { ... }` the Err(String) contract would need to be Err(Value), threading the thrown object through every Err propagation site in the interpreter. Single-session lift but ~40 sites to update. - Try-without-catch (`try { ... } finally { ... }`). Parser currently requires catch before finally; runtime semantics already handle the case. - Custom exception classes. Once classes/methods land, exception types will compose with them naturally. Regression: 161 prior OMC tests + 8 new = 169 OMC tests pass. 77 codegen tests, cargo unit tests all green. No prior heal-pass / substrate / harmonic_libs tests touched. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 46060d4 commit 85e2888

6 files changed

Lines changed: 222 additions & 21 deletions

File tree

examples/tests/test_exceptions.omc

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Tests for OMC's exception handling: throw, try/catch, finally.
2+
# Currently the caught value is a Value::String (the thrown expression's
3+
# display-form). Typed-catch hierarchies (Err(Value) rather than
4+
# Err(String)) is future work — this test suite documents what's
5+
# supported today.
6+
7+
fn assert_eq(actual, expected, msg) {
8+
if actual != expected {
9+
test_record_failure(msg + ": expected " + to_string(expected) + " got " + to_string(actual));
10+
}
11+
}
12+
13+
fn assert_true(cond, msg) {
14+
if !cond {
15+
test_record_failure(msg);
16+
}
17+
}
18+
19+
# ---- throw + catch basics ----
20+
21+
fn test_throw_string() {
22+
h caught = "";
23+
try {
24+
throw "boom";
25+
} catch e {
26+
caught = e;
27+
}
28+
assert_eq(caught, "boom", "string throw caught");
29+
}
30+
31+
fn test_throw_number_stringified() {
32+
h caught = "";
33+
try {
34+
throw 42;
35+
} catch e {
36+
caught = e;
37+
}
38+
assert_eq(caught, "42", "number throw stringified at catch");
39+
}
40+
41+
fn test_no_throw_no_catch_run() {
42+
h ran = 0;
43+
try {
44+
ran = 1;
45+
} catch e {
46+
ran = 99;
47+
}
48+
assert_eq(ran, 1, "catch should not run when try succeeds");
49+
}
50+
51+
# ---- finally semantics ----
52+
53+
fn test_finally_runs_on_success() {
54+
h ran = 0;
55+
try {
56+
# No throw
57+
} catch e {
58+
ran = 99;
59+
} finally {
60+
ran = 1;
61+
}
62+
assert_eq(ran, 1, "finally runs after successful try");
63+
}
64+
65+
fn test_finally_runs_on_failure() {
66+
h ran_catch = 0;
67+
h ran_finally = 0;
68+
try {
69+
throw "x";
70+
} catch e {
71+
ran_catch = 1;
72+
} finally {
73+
ran_finally = 1;
74+
}
75+
assert_eq(ran_catch, 1, "catch ran on failure");
76+
assert_eq(ran_finally, 1, "finally also ran after catch");
77+
}
78+
79+
# ---- nested try ----
80+
81+
fn test_nested_inner_catch() {
82+
h outer = 0;
83+
h inner = 0;
84+
try {
85+
try {
86+
throw "inner";
87+
} catch e {
88+
inner = 1;
89+
}
90+
} catch e {
91+
outer = 99;
92+
}
93+
assert_eq(inner, 1, "inner catch ran");
94+
assert_eq(outer, 0, "outer catch did NOT run (inner consumed)");
95+
}
96+
97+
fn test_nested_rethrow() {
98+
h outer_msg = "";
99+
try {
100+
try {
101+
throw "original";
102+
} catch e {
103+
throw "rethrown";
104+
}
105+
} catch e {
106+
outer_msg = e;
107+
}
108+
assert_eq(outer_msg, "rethrown", "outer catch sees rethrown value");
109+
}
110+
111+
# ---- throw inside a called function ----
112+
113+
fn raises() {
114+
throw "from raises";
115+
}
116+
117+
fn test_throw_through_call() {
118+
h caught = "";
119+
try {
120+
raises();
121+
} catch e {
122+
caught = e;
123+
}
124+
assert_eq(caught, "from raises", "throw propagates through call boundary");
125+
}
126+
127+
# OMC's parser currently REQUIRES `catch` before `finally`, even when
128+
# the try block is not expected to throw. Try-without-catch (Python's
129+
# `try: ... finally: ...` without an except clause) is a future
130+
# parser-side relaxation — the runtime semantics already handle it.

omnimcode-core/src/ast.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,16 +86,25 @@ pub enum Statement {
8686
/// `alias` — parser enforces this.
8787
selected: Option<Vec<String>>,
8888
},
89-
/// `try { ... } catch err { ... }`. If the try block raises an
90-
/// error (via `error("msg")` or any builtin failure), execution
91-
/// jumps to the catch block with `err_var` bound to a Value::String
92-
/// holding the error message. Without try/catch, builtin failures
93-
/// crash the program.
89+
/// `try { ... } catch err { ... }` with optional `finally { ... }`.
90+
/// If the try block raises an error (via `throw expr`, `error("msg")`,
91+
/// or any builtin failure), execution jumps to the catch block with
92+
/// `err_var` bound to a Value::String holding the error message. The
93+
/// `finally` block, if present, runs unconditionally after both the
94+
/// try body AND any handler — even when the handler itself raises.
95+
/// Matches Python's try/except/finally semantics.
9496
Try {
9597
body: Vec<Statement>,
9698
err_var: String,
9799
handler: Vec<Statement>,
100+
finally: Option<Vec<Statement>>,
98101
},
102+
/// `throw expr` — explicit exception raise. The expression is
103+
/// evaluated and its display-string becomes the error message
104+
/// that the surrounding catch (if any) receives in its err_var.
105+
/// Future work: carry the thrown Value through Err(Value) instead
106+
/// of stringifying, enabling typed-catch hierarchies.
107+
Throw(Expression),
99108
/// `match expr { pat => stmts, ... }`. First arm whose pattern
100109
/// accepts the scrutinee runs; remaining arms are skipped.
101110
/// A wildcard or bare-identifier arm at the end is the default.

omnimcode-core/src/compiler.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -990,11 +990,11 @@ impl Compiler {
990990
Statement::FunctionDef { .. } => {
991991
// Function defs hoisted by compile_program(); skip here.
992992
}
993-
Statement::Try { .. } => {
993+
Statement::Try { .. } | Statement::Throw(_) => {
994994
// Tree-walk fallback. See Op::ExecStmt comments — full
995995
// exception unwind would require a side try-stack and
996996
// a Result-aware op dispatch loop. Until that pays for
997-
// itself, fall back to the AST walker for try/catch.
997+
// itself, fall back to the AST walker for try/catch/throw.
998998
self.emit(Op::ExecStmt(Box::new(s.clone())));
999999
}
10001000
Statement::Match { .. } => {

omnimcode-core/src/formatter.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ fn format_stmt(stmt: &Statement, level: usize, out: &mut String) {
178178
out.push_str(";\n");
179179
}
180180
}
181-
Statement::Try { body, err_var, handler } => {
181+
Statement::Try { body, err_var, handler, finally } => {
182182
out.push_str("try {\n");
183183
for s in body { format_stmt(s, level + 1, out); }
184184
indent_to(level, out);
@@ -187,8 +187,18 @@ fn format_stmt(stmt: &Statement, level: usize, out: &mut String) {
187187
out.push_str(" {\n");
188188
for s in handler { format_stmt(s, level + 1, out); }
189189
indent_to(level, out);
190+
if let Some(finally_body) = finally {
191+
out.push_str("} finally {\n");
192+
for s in finally_body { format_stmt(s, level + 1, out); }
193+
indent_to(level, out);
194+
}
190195
out.push_str("}\n");
191196
}
197+
Statement::Throw(e) => {
198+
out.push_str("throw ");
199+
format_expr(e, out);
200+
out.push_str(";\n");
201+
}
192202
Statement::Match { scrutinee, arms } => {
193203
out.push_str("match ");
194204
format_expr(scrutinee, out);

omnimcode-core/src/interpreter.rs

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,7 @@ impl Interpreter {
644644
pragmas,
645645
}
646646
}
647-
Statement::Try { body, err_var, handler } => Statement::Try {
647+
Statement::Try { body, err_var, handler, finally } => Statement::Try {
648648
body: body
649649
.into_iter()
650650
.map(|s| Self::rewrite_module_calls(s, module_names, alias))
@@ -654,7 +654,13 @@ impl Interpreter {
654654
.into_iter()
655655
.map(|s| Self::rewrite_module_calls(s, module_names, alias))
656656
.collect(),
657+
finally: finally.map(|stmts| stmts.into_iter()
658+
.map(|s| Self::rewrite_module_calls(s, module_names, alias))
659+
.collect()),
657660
},
661+
Statement::Throw(e) => Statement::Throw(
662+
Self::rewrite_call_expr(e, module_names, alias),
663+
),
658664
Statement::Match { scrutinee, arms } => Statement::Match {
659665
scrutinee: Self::rewrite_call_expr(scrutinee, module_names, alias),
660666
arms: arms
@@ -1493,19 +1499,39 @@ impl Interpreter {
14931499
// No arm matched — silent no-op.
14941500
Ok(())
14951501
}
1496-
Statement::Try { body, err_var, handler } => {
1502+
Statement::Try { body, err_var, handler, finally } => {
14971503
// Run the body; if anything inside returns Err, jump to
14981504
// the handler with err_var bound to the message string.
1499-
// The body and handler share the surrounding scope —
1500-
// no extra scope is pushed (matches Python try/except).
1501-
match self.execute_block(body) {
1505+
// After the body+handler complete (success OR failure),
1506+
// run finally unconditionally — including when the
1507+
// handler itself raises. Matches Python try/except/finally.
1508+
let body_result = self.execute_block(body);
1509+
let after_handler = match body_result {
15021510
Ok(()) => Ok(()),
15031511
Err(msg) => {
1504-
// Install err_var in the current scope, run handler.
15051512
self.set_var(err_var.clone(), Value::String(msg));
15061513
self.execute_block(handler)
15071514
}
1508-
}
1515+
};
1516+
if let Some(finally_body) = finally {
1517+
// Run finally regardless of after_handler's status.
1518+
// If both finally and after_handler fail, finally's
1519+
// error wins (Python's behavior — finally is the
1520+
// "last word" that the surrounding scope sees).
1521+
let finally_result = self.execute_block(finally_body);
1522+
if finally_result.is_err() {
1523+
return finally_result;
1524+
}
1525+
}
1526+
after_handler
1527+
}
1528+
Statement::Throw(expr) => {
1529+
// Evaluate the expression, raise its display-string as
1530+
// the current frame's error. Surrounding catch (if any)
1531+
// binds the string to err_var; uncaught throws propagate
1532+
// up to the top-level error handler.
1533+
let v = self.eval_expr(expr)?;
1534+
Err(v.to_display_string())
15091535
}
15101536
_ => Ok(()),
15111537
}
@@ -6908,9 +6934,10 @@ impl Interpreter {
69086934
Statement::While { body, .. } | Statement::For { body, .. } => {
69096935
for s in body { visit(s, fns); }
69106936
}
6911-
Statement::Try { body, handler, .. } => {
6937+
Statement::Try { body, handler, finally, .. } => {
69126938
for s in body { visit(s, fns); }
69136939
for s in handler { visit(s, fns); }
6940+
if let Some(f) = finally { for s in f { visit(s, fns); } }
69146941
}
69156942
Statement::Match { arms, .. } => {
69166943
for arm in arms { for s in &arm.body { visit(s, fns); } }

omnimcode-core/src/parser.rs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ pub enum Token {
2828
Safe, // H.5 host-level support: `safe <expr>` prefix
2929
Try,
3030
Catch,
31+
Finally,
32+
Throw,
3133
Match,
3234
/// `..` for inclusive ranges in match patterns: `0..9`, `"a".."z"`.
3335
/// Lexed when not part of `..=` (which we don't use yet) or `...`.
@@ -365,6 +367,8 @@ impl Lexer {
365367
"safe" => Token::Safe,
366368
"try" => Token::Try,
367369
"catch" => Token::Catch,
370+
"finally" => Token::Finally,
371+
"throw" => Token::Throw,
368372
"match" => Token::Match,
369373
"and" => Token::And,
370374
"or" => Token::Or,
@@ -760,6 +764,16 @@ impl Parser {
760764
Token::For => self.parse_for_stmt(),
761765
Token::Fn => self.parse_function_def(),
762766
Token::Try => self.parse_try_stmt(),
767+
Token::Throw => {
768+
// `throw expr;` — evaluate expr, raise its display string
769+
// as the current frame's error. Caught by surrounding
770+
// try/catch; uncaught throws propagate to the top-level
771+
// error handler (which prints + exits the program).
772+
self.advance(); // consume `throw`
773+
let expr = self.parse_expression()?;
774+
self.expect(Token::Semicolon)?;
775+
Ok(Statement::Throw(expr))
776+
}
763777
Token::Match => self.parse_match_stmt(),
764778
// `import core;` or `import core as c;` or `load "path";`
765779
Token::Import | Token::Load => {
@@ -963,10 +977,11 @@ impl Parser {
963977
Ok(Statement::While { condition, body })
964978
}
965979

966-
/// `try { ... } catch err { ... }` — the simplest possible
967-
/// exception form. No exception classes (yet); the caught value
968-
/// is always a string holding the error message. Single catch
969-
/// arm only — we may add multi-arm matching later.
980+
/// `try { ... } catch err { ... }` with optional trailing
981+
/// `finally { ... }`. The caught value is currently a Value::String
982+
/// holding the error message; future work will carry the thrown
983+
/// Value through unchanged for typed-catch hierarchies. Single
984+
/// catch arm only — multi-arm typed matching is later work.
970985
fn parse_try_stmt(&mut self) -> Result<Statement, String> {
971986
self.expect(Token::Try)?;
972987
self.expect(Token::LBrace)?;
@@ -975,7 +990,17 @@ impl Parser {
975990
let err_var = self.parse_ident()?;
976991
self.expect(Token::LBrace)?;
977992
let handler = self.parse_block()?;
978-
Ok(Statement::Try { body, err_var, handler })
993+
// Optional `finally { ... }`. Runs unconditionally after both
994+
// the try body and any handler (including when handler itself
995+
// raises). Matches Python's try/except/finally semantics.
996+
let finally = if self.current() == Token::Finally {
997+
self.expect(Token::Finally)?;
998+
self.expect(Token::LBrace)?;
999+
Some(self.parse_block()?)
1000+
} else {
1001+
None
1002+
};
1003+
Ok(Statement::Try { body, err_var, handler, finally })
9791004
}
9801005

9811006
/// `match expr { pat => stmt, pat => { stmts }, ... }`

0 commit comments

Comments
 (0)