Skip to content

Commit cb769e9

Browse files
Reverse FFI foundation: register_builtin core API + tests
Adds Interpreter::register_builtin(name, Fn(&[Value]) -> Result<Value, String>) — the foundation for OMC reaching out to host capabilities. Used as the dispatch substrate for every "OMC calls X" feature: pyo3-embedded Python (next), Godot signals, file pickers, sandboxed I/O, etc. Dispatch precedence in call_function (tree-walk): 1. user-defined fn (self.functions) 2. host-registered builtin ← NEW 3. module-qualified call ("phi.fold") 4. built-in stdlib Same precedence in vm_call_builtin (VM path), so host builtins shadow even hot stdlib names like str_len / now_ms — useful for sandboxing and substitution. The HashMap lookup is one branch when not present. Public API: fn register_builtin<F>(&mut self, name: &str, handler: F) where F: Fn(&[Value]) -> Result<Value, String> + 'static fn unregister_builtin(&mut self, name: &str) -> bool fn has_host_builtin(&self, name: &str) -> bool Handlers: - Receive evaluated Values (not Expressions — they live outside OMC's eval context). - Return Value on success, String on error. Errors propagate through normal stack-trace machinery and are catchable via try/catch in OMC code. - Single-threaded (Rc<dyn Fn>) — matches OMC's runtime. - Stored as Rc<dyn Fn> so handlers can be cheaply cloned when the Interpreter itself is. Test suite (omnimcode-core/tests/host_builtins.rs, 9 tests): - simple int double, both engines - side-effect captures (Python-callback pattern), both engines - return Vec<Value> as Array (NumPy → OMC array path) - error propagation via try/catch - shadowing stdlib names, both engines - register/unregister lifecycle VM tests use a `__capture(value)` host fn pattern to retrieve the final value, since the VM pops its top-level scope on exit and doesn't preserve `h __result__` like tree-walk does. This pattern will be reused by future PyO3 demos. 43/43 functional examples produce identical output under tree-walk and VM. 9/9 new tests + 92 existing unit tests + ffi tests all pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d3c29b6 commit cb769e9

2 files changed

Lines changed: 327 additions & 0 deletions

File tree

omnimcode-core/src/interpreter.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@ pub struct Interpreter {
4141
/// sees in stack traces. The fn's own internal line numbers don't
4242
/// belong here; they'd need per-statement position tracking.
4343
call_stack: Vec<(String, crate::ast::Pos)>,
44+
/// Reverse-FFI: builtins registered by the embedder (Python /
45+
/// Godot / a Rust host). When OMC code calls a name not found
46+
/// in user fns, modules, or the built-in stdlib, dispatch
47+
/// falls through to this map. Lets an embedder expose host-side
48+
/// capabilities (numpy, godot signals, file pickers, etc.) to
49+
/// OMC programs without baking them into the interpreter.
50+
///
51+
/// Stored as `Rc<dyn Fn>` so handlers can be cheaply cloned
52+
/// when the Interpreter itself is cloned (rare, but FFI wrappers
53+
/// occasionally do it). Single-threaded — handlers don't need
54+
/// to be Send/Sync, matching the rest of OMC's runtime.
55+
host_builtins: HashMap<
56+
String,
57+
std::rc::Rc<dyn Fn(&[Value]) -> Result<Value, String>>,
58+
>,
4459
}
4560

4661
impl Interpreter {
@@ -64,9 +79,56 @@ impl Interpreter {
6479
test_failures: std::cell::RefCell::new(Vec::new()),
6580
test_current_name: std::cell::RefCell::new(String::new()),
6681
call_stack: Vec::new(),
82+
host_builtins: HashMap::new(),
6783
}
6884
}
6985

86+
/// Register a host-side builtin that OMC code can call by name.
87+
/// The closure receives the evaluated argument values and returns
88+
/// either a Value (success) or an error message that propagates
89+
/// through OMC's normal Result chain (catchable via try/catch).
90+
///
91+
/// Names registered here SHADOW user-defined functions of the
92+
/// same name (so an embedder can hand OMC a custom `fetch_url`
93+
/// that overrides any user `fn fetch_url(...)`). They're checked
94+
/// AFTER user fns, BEFORE the built-in stdlib — same precedence
95+
/// position the test runner's `test_*` overrides use.
96+
///
97+
/// Type signatures are dynamic: the closure is responsible for
98+
/// validating arg count and types. Use `args.len()` and
99+
/// `matches!(args[0], Value::HInt(_))` etc. Errors are strings;
100+
/// they appear in stack traces with the call site prefixed.
101+
///
102+
/// Example:
103+
/// ```ignore
104+
/// let mut interp = Interpreter::new();
105+
/// interp.register_builtin("double", |args| {
106+
/// if args.len() != 1 { return Err("double requires 1 arg".into()); }
107+
/// Ok(Value::HInt(HInt::new(args[0].to_int() * 2)))
108+
/// });
109+
/// // OMC code can now do `println(double(21));` and see "42".
110+
/// ```
111+
pub fn register_builtin<F>(&mut self, name: &str, handler: F)
112+
where
113+
F: Fn(&[Value]) -> Result<Value, String> + 'static,
114+
{
115+
self.host_builtins.insert(name.to_string(), std::rc::Rc::new(handler));
116+
}
117+
118+
/// Remove a previously-registered host builtin. Returns true if
119+
/// a handler was removed. Used by embedders that want to hand
120+
/// OMC a temporary capability for a single call sequence.
121+
pub fn unregister_builtin(&mut self, name: &str) -> bool {
122+
self.host_builtins.remove(name).is_some()
123+
}
124+
125+
/// True if a host builtin with this name is registered. Used by
126+
/// the dispatch path; exposed publicly so embedders can check
127+
/// before re-registering.
128+
pub fn has_host_builtin(&self, name: &str) -> bool {
129+
self.host_builtins.contains_key(name)
130+
}
131+
70132
/// xorshift64* — fast and tiny, sufficient for OMC scripting needs.
71133
/// Not cryptographic. Returns a non-zero u64.
72134
fn rng_next(&self) -> u64 {
@@ -1204,6 +1266,18 @@ impl Interpreter {
12041266
if let Some((params, body)) = self.functions.get(name).cloned() {
12051267
return self.invoke_user_function(name, &params, &body, args);
12061268
}
1269+
// Reverse-FFI: host-registered builtins. Checked BEFORE module
1270+
// dispatch and the built-in stdlib so an embedder can shadow
1271+
// anything (including `read_file`, `now_ms`, etc.). Eval args
1272+
// here — the host fn receives Values, not Expressions, since
1273+
// it lives outside OMC's eval context.
1274+
if let Some(handler) = self.host_builtins.get(name).cloned() {
1275+
let mut argvals = Vec::with_capacity(args.len());
1276+
for a in args {
1277+
argvals.push(self.eval_expr(a)?);
1278+
}
1279+
return handler(&argvals);
1280+
}
12071281
// Module-qualified calls (e.g., "phi.fold", "phi.res", "core.fib")
12081282
if let Some((module, func)) = name.split_once('.') {
12091283
return self.call_module_function(module, func, args);
@@ -3750,6 +3824,15 @@ impl Interpreter {
37503824
name: &str,
37513825
args: &[Value],
37523826
) -> Result<Value, String> {
3827+
// Reverse-FFI host builtins fire FIRST so they can shadow
3828+
// anything (including stdlib names like `read_file`). Lets an
3829+
// embedder hand OMC code a sandboxed `read_file` that only
3830+
// sees /tmp, etc. Skipped if the host hasn't registered the
3831+
// name — the no-op cost is one HashMap lookup.
3832+
if let Some(handler) = self.host_builtins.get(name).cloned() {
3833+
return handler(args);
3834+
}
3835+
37533836
// Phase 4 fast-path: hot builtins handled directly on values,
37543837
// bypassing the synthetic-arg shim. Each one shaved ~50% off
37553838
// its per-call time on the benchmark suite (str_concat went
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
//! Reverse-FFI host-builtin tests.
2+
//!
3+
//! Verifies that an embedder can register Rust closures as OMC-callable
4+
//! builtins, and that the dispatch works for both the tree-walk
5+
//! interpreter and the bytecode VM. Uses a shared cell to capture
6+
//! side effects so we can assert the host code actually ran.
7+
8+
use omnimcode_core::bytecode_opt::optimize_module;
9+
use omnimcode_core::compiler::compile_program;
10+
use omnimcode_core::interpreter::Interpreter;
11+
use omnimcode_core::parser::Parser;
12+
use omnimcode_core::value::{HArray, HInt, Value};
13+
use omnimcode_core::vm::Vm;
14+
use std::cell::Cell;
15+
use std::rc::Rc;
16+
17+
/// Run `source` through the tree-walk interpreter with `setup_host`
18+
/// called on the interpreter before execution. Returns the final
19+
/// value of `__result__`.
20+
fn run_treewalk(
21+
source: &str,
22+
setup_host: impl FnOnce(&mut Interpreter),
23+
) -> Result<Value, String> {
24+
let mut parser = Parser::new(source);
25+
let stmts = parser.parse()?;
26+
let mut interp = Interpreter::new();
27+
setup_host(&mut interp);
28+
interp.execute(stmts)?;
29+
interp
30+
.get_var_for_testing("__result__")
31+
.ok_or_else(|| "no __result__ variable".to_string())
32+
}
33+
34+
/// Run `source` through the bytecode VM with `setup_host` called
35+
/// before execution. The Vm's internal Interpreter is what the host
36+
/// fns end up registered on.
37+
///
38+
/// The VM pops its top-level scope on exit so we can't read
39+
/// `__result__` from it the way tree-walk can. Instead, the source
40+
/// must end with `__capture(<expr>);` where `__capture` is a host
41+
/// fn we register to stash the result in a returned Cell.
42+
fn run_vm_with_capture(
43+
source: &str,
44+
setup_host: impl FnOnce(&mut Interpreter),
45+
) -> Result<Value, String> {
46+
let mut parser = Parser::new(source);
47+
let stmts = parser.parse()?;
48+
let module = compile_program(&stmts)?;
49+
let mut module = module;
50+
optimize_module(&mut module);
51+
let mut vm = Vm::new();
52+
let captured: Rc<std::cell::RefCell<Option<Value>>> =
53+
Rc::new(std::cell::RefCell::new(None));
54+
let captured_clone = Rc::clone(&captured);
55+
{
56+
let interp = vm.interp_mut();
57+
setup_host(interp);
58+
interp.register_builtin("__capture", move |args| {
59+
*captured_clone.borrow_mut() = args.first().cloned();
60+
Ok(Value::Null)
61+
});
62+
interp.process_imports(&stmts)?;
63+
interp.register_user_functions(&stmts);
64+
for (lname, lparams, lbody) in &module.lambda_asts {
65+
interp.register_lambda(lname, lparams.clone(), lbody.clone());
66+
}
67+
}
68+
vm.run_module(&module)?;
69+
let result = captured.borrow().clone();
70+
result.ok_or_else(|| "no __capture(...) call in source".to_string())
71+
}
72+
73+
#[test]
74+
fn host_builtin_simple_int_double_treewalk() {
75+
let v = run_treewalk(
76+
r#"
77+
h __result__ = double(21);
78+
"#,
79+
|interp| {
80+
interp.register_builtin("double", |args| {
81+
Ok(Value::HInt(HInt::new(args[0].to_int() * 2)))
82+
});
83+
},
84+
)
85+
.unwrap();
86+
assert_eq!(v.to_int(), 42);
87+
}
88+
89+
#[test]
90+
fn host_builtin_simple_int_double_vm() {
91+
let v = run_vm_with_capture(
92+
r#"
93+
__capture(double(21));
94+
"#,
95+
|interp| {
96+
interp.register_builtin("double", |args| {
97+
Ok(Value::HInt(HInt::new(args[0].to_int() * 2)))
98+
});
99+
},
100+
)
101+
.unwrap();
102+
assert_eq!(v.to_int(), 42);
103+
}
104+
105+
/// Confirm side effects propagate Rust-side. The host fn writes to a
106+
/// shared Cell; we read it after OMC execution. This is the pattern
107+
/// PyO3 will use for round-tripping data.
108+
#[test]
109+
fn host_builtin_side_effect_treewalk() {
110+
let captured: Rc<Cell<i64>> = Rc::new(Cell::new(0));
111+
let captured_clone = Rc::clone(&captured);
112+
let _ = run_treewalk(
113+
r#"
114+
capture(89);
115+
h __result__ = 1;
116+
"#,
117+
move |interp| {
118+
interp.register_builtin("capture", move |args| {
119+
captured_clone.set(args[0].to_int());
120+
Ok(Value::Null)
121+
});
122+
},
123+
)
124+
.unwrap();
125+
assert_eq!(captured.get(), 89);
126+
}
127+
128+
#[test]
129+
fn host_builtin_side_effect_vm() {
130+
let captured: Rc<Cell<i64>> = Rc::new(Cell::new(0));
131+
let captured_clone = Rc::clone(&captured);
132+
let _ = run_vm_with_capture(
133+
r#"
134+
capture(89);
135+
__capture(1);
136+
"#,
137+
move |interp| {
138+
interp.register_builtin("capture", move |args| {
139+
captured_clone.set(args[0].to_int());
140+
Ok(Value::Null)
141+
});
142+
},
143+
)
144+
.unwrap();
145+
assert_eq!(captured.get(), 89);
146+
}
147+
148+
/// Host fn returns an array — the value flows back into OMC normally.
149+
/// Tests the "I want my Python list to look like an OMC array" path.
150+
#[test]
151+
fn host_builtin_returns_array() {
152+
let v = run_treewalk(
153+
r#"
154+
h xs = numpy_arange(5);
155+
h __result__ = arr_len(xs);
156+
"#,
157+
|interp| {
158+
interp.register_builtin("numpy_arange", |args| {
159+
let n = args[0].to_int().max(0) as usize;
160+
let items: Vec<Value> = (0..n)
161+
.map(|i| Value::HInt(HInt::new(i as i64)))
162+
.collect();
163+
Ok(Value::Array(HArray::from_vec(items)))
164+
});
165+
},
166+
)
167+
.unwrap();
168+
assert_eq!(v.to_int(), 5);
169+
}
170+
171+
/// Host fn errors propagate as OMC errors — catchable via try/catch.
172+
#[test]
173+
fn host_builtin_error_is_catchable() {
174+
let v = run_treewalk(
175+
r#"
176+
try {
177+
broken();
178+
h __result__ = 0;
179+
} catch e {
180+
h __result__ = e;
181+
}
182+
"#,
183+
|interp| {
184+
interp.register_builtin("broken", |_args| {
185+
Err("intentional host failure".to_string())
186+
});
187+
},
188+
)
189+
.unwrap();
190+
match v {
191+
Value::String(s) => assert!(s.contains("intentional host failure")),
192+
other => panic!("expected error string, got {:?}", other),
193+
}
194+
}
195+
196+
/// Host fn shadows a stdlib name. Used for sandboxing — embedder hands
197+
/// OMC a custom `read_file` that only sees a whitelisted directory.
198+
#[test]
199+
fn host_builtin_shadows_stdlib() {
200+
let v = run_treewalk(
201+
r#"
202+
h __result__ = now_ms();
203+
"#,
204+
|interp| {
205+
interp.register_builtin("now_ms", |_args| {
206+
Ok(Value::HInt(HInt::new(12345)))
207+
});
208+
},
209+
)
210+
.unwrap();
211+
assert_eq!(v.to_int(), 12345);
212+
}
213+
214+
/// Same shadowing test under the VM — verifies vm_call_builtin checks
215+
/// host_builtins BEFORE vm_fast_dispatch, which would otherwise win
216+
/// for hot stdlib names.
217+
#[test]
218+
fn host_builtin_shadows_stdlib_under_vm() {
219+
let v = run_vm_with_capture(
220+
r#"
221+
__capture(str_len("ignored"));
222+
"#,
223+
|interp| {
224+
interp.register_builtin("str_len", |_args| {
225+
Ok(Value::HInt(HInt::new(999)))
226+
});
227+
},
228+
)
229+
.unwrap();
230+
assert_eq!(v.to_int(), 999);
231+
}
232+
233+
/// unregister_builtin removes a previously-registered handler. The
234+
/// next call resolves to the underlying stdlib (or fails if no
235+
/// stdlib match).
236+
#[test]
237+
fn host_builtin_unregister() {
238+
let mut interp = Interpreter::new();
239+
interp.register_builtin("custom", |_args| Ok(Value::HInt(HInt::new(7))));
240+
assert!(interp.has_host_builtin("custom"));
241+
assert!(interp.unregister_builtin("custom"));
242+
assert!(!interp.has_host_builtin("custom"));
243+
assert!(!interp.unregister_builtin("custom"));
244+
}

0 commit comments

Comments
 (0)