Skip to main content

candela/
engine.rs

1//! Embedding / library API for candela.
2//!
3//! This is the persistent, in-process entry point a Rust host (such as Lumen)
4//! uses to embed candela the way it embeds `rhai`/`mlua`: register typed host
5//! functions, compile a script to a reusable [`Program`], and invoke
6//! script-defined functions by name with marshalled arguments, all while
7//! keeping interpreter state (registers + heap pools) alive between calls.
8//!
9//! ```no_run
10//! let mut engine = candela::Engine::new();
11//! engine.register_host_fn("app", "rows", |id: &str| id.len() as i64);
12//! let mut program = engine.compile("host \"app\" { int rows(string); }\nfn count(id) { return app.rows(id); }\nfn main() {}", "main.cdl")?;
13//! let rows = program.call("count", &["board".into()])?;
14//! assert_eq!(rows, candela::Value::Int(5));
15//! # Ok::<(), candela::Diagnostic>(())
16//! ```
17//!
18//! Unlike the one-shot [`crate::candela_run`] C-ABI entry point (which compiles a
19//! script, runs `main`, and returns captured stdout), the [`Engine`]/[`Program`]
20//! pair keeps the compiler and VM state resident so the host can drive the
21//! script incrementally. Errors are returned as structured [`Diagnostic`]
22//! values (reusing the `structured-errors` funnel) instead of printing and
23//! aborting the process. The value-marshalling types it uses ([`Value`],
24//! [`HostType`], ...) live in the VM-only `candela-vm` crate.
25
26use crate::compiler::CompileOutput;
27use crate::compiler::Namespace;
28use crate::compiler::compile;
29use crate::compiler::compiler_data::Ctx;
30use crate::compiler::compiler_data::Dynamiclib;
31use crate::compiler::compiler_data::Function;
32use crate::compiler::compiler_data::State;
33use crate::compiler::compiler_data::Variable;
34use crate::compiler::expr::Expr;
35use candela_vm::data::Data;
36use candela_vm::data::NULL;
37use candela_vm::embed::HostDispatch;
38use candela_vm::embed::HostType;
39use candela_vm::embed::IntoHostFn;
40use candela_vm::embed::Value;
41use candela_vm::embed::marshal_value;
42use candela_vm::embed::unmarshal_value;
43use candela_vm::errors::Diagnostic;
44use candela_vm::errors::ErrorCtx;
45use candela_vm::errors::collect_diagnostic;
46use candela_vm::instr::Instr;
47use candela_vm::rt::DataType;
48use candela_vm::rt::DynamicLibFn;
49use candela_vm::rt::EnumType;
50use candela_vm::rt::HostFnSig;
51use candela_vm::rt::InstrSrc;
52use candela_vm::rt::Pools;
53use candela_vm::rt::Source;
54use candela_vm::rt::Span;
55use candela_vm::rt::Struct;
56use candela_vm::vm;
57use candela_vm::vm::RegisterFile;
58use rustc_hash::FxHashMap;
59use smol_strc::SmolStr;
60use std::collections::HashMap;
61use std::rc::Rc;
62
63/// A registered host function: its erased dispatcher plus the argument/return
64/// type signature derived from the closure, used to validate it against the
65/// script's `host` block at compile time.
66struct RegisteredFn {
67    func: HostDispatch,
68    arg_types: Vec<HostType>,
69    ret_type: HostType,
70    /// Registered via [`Engine::register_host_fn_variadic`]: the closure takes
71    /// a `&[Value]` slice of any length, so `arg_types`/`ret_type` are unused
72    /// and signature validation against the `host` block is skipped (the block
73    /// must declare the fn with `...`).
74    variadic: bool,
75}
76
77/// The persistent embedding entry point. Holds the table of registered host
78/// functions and compiles scripts into reusable [`Program`]s.
79///
80/// This is the library analogue of the one-shot [`crate::candela_run`]: it does
81/// not run a script and hand back stdout, it keeps compiler + VM state resident
82/// so the host can call into the script repeatedly.
83#[derive(Default)]
84pub struct Engine {
85    registry: HashMap<(String, String), RegisteredFn>,
86}
87
88impl Engine {
89    #[must_use]
90    pub fn new() -> Self {
91        Self {
92            registry: HashMap::new(),
93        }
94    }
95
96    /// Registers a typed host function under `namespace.name`.
97    ///
98    /// The closure may take any combination of `i64`/`i32`, `f64`, `bool`,
99    /// `String` (or a single `&str`) arguments and return one of those or `()`.
100    /// The declared types are checked against the script's `host` block when
101    /// [`Engine::compile`] runs; a mismatch is a clean [`Diagnostic`], never a
102    /// panic.
103    pub fn register_host_fn<Marker, F>(&mut self, namespace: &str, name: &str, f: F)
104    where
105        F: IntoHostFn<Marker>,
106    {
107        let (func, arg_types, ret_type) = f.into_host_fn_parts();
108        self.registry.insert(
109            (namespace.to_owned(), name.to_owned()),
110            RegisteredFn {
111                func,
112                arg_types,
113                ret_type,
114                variadic: false,
115            },
116        );
117    }
118
119    /// Registers a variadic host function under `namespace.name`.
120    ///
121    /// Unlike [`Engine::register_host_fn`], the closure receives every argument
122    /// as a `&[Value]` slice of any length and returns a single [`Value`], so
123    /// arguments of mixed / dynamically-typed shape can cross the boundary
124    /// without a fixed Rust signature. The `host` block must declare the
125    /// function with a `...` argument list:
126    ///
127    /// ```candela
128    /// host "app" {
129    ///     log(...);
130    /// }
131    /// ```
132    ///
133    /// No arity or per-argument type checking is performed at the call site;
134    /// the closure interprets the slice it is handed. A non-variadic
135    /// declaration bound to a variadic closure (or vice versa) is a clean
136    /// [`Diagnostic`] at [`Engine::compile`] time.
137    pub fn register_host_fn_variadic<F>(&mut self, namespace: &str, name: &str, f: F)
138    where
139        F: Fn(&[Value]) -> Value + 'static,
140    {
141        self.registry.insert(
142            (namespace.to_owned(), name.to_owned()),
143            RegisteredFn {
144                func: Rc::new(f),
145                arg_types: Vec::new(),
146                ret_type: HostType::Unit,
147                variadic: true,
148            },
149        );
150    }
151
152    /// Compiles `src` into a reusable [`Program`], binding every `host` function
153    /// it declares to the matching registered closure.
154    ///
155    /// `main` is executed once here (module instantiation), so any top-level
156    /// setup runs before the host makes its first [`Program::call`].
157    ///
158    /// # Errors
159    ///
160    /// Returns a [`Diagnostic`] if the script fails to parse/type-check, if a
161    /// declared `host` function has no registered closure, if a registered
162    /// closure's arity/types disagree with the `host` block, or if running
163    /// `main` raises a runtime error.
164    pub fn compile(&self, src: &str, filename: &str) -> Result<Program, Diagnostic> {
165        let filename_owned = filename.to_owned();
166        let out: CompileOutput =
167            collect_diagnostic(|| compile(src.to_owned(), &filename_owned, false))?;
168
169        // Bind each declared host function to a registered closure, validating
170        // arity + types against the closure's derived signature.
171        let mut host_dispatch: Vec<HostDispatch> = Vec::with_capacity(out.host_fns.len());
172        for sig in &out.host_fns {
173            let key = (sig.namespace.to_string(), sig.name.to_string());
174            let registered = self.registry.get(&key).ok_or_else(|| Diagnostic {
175                filename: filename.to_owned(),
176                span: 0..0,
177                message: format!(
178                    "no host function registered for `{}.{}` (declared in a `host` block)",
179                    sig.namespace, sig.name
180                ),
181                code: String::from("unregistered_host_fn"),
182            })?;
183            validate_host_fn(sig, registered, filename)?;
184            host_dispatch.push(Rc::clone(&registered.func));
185        }
186
187        // Register 0 is candela's void-return / null sink: a call whose result is
188        // discarded writes `null` there. A normal program always has register 0
189        // occupied by a constant, but an empty `main` can leave it free, which
190        // would let a `Program::call` trampoline allocate a function parameter to
191        // it and then have a void host call clobber that parameter. Reserve it.
192        let mut registers = out.registers;
193        let mut const_registers = out.const_registers;
194        if registers.is_empty() {
195            registers.push(NULL);
196            const_registers.entry(NULL).or_insert(0);
197        }
198
199        let mut program = Program {
200            instructions: out.instructions,
201            registers,
202            pools: out.pools,
203            instr_src: out.instr_src,
204            fn_registers: out.fn_registers,
205            dyn_lib_fns: out.dyn_lib_fns,
206            host_sigs: out.host_fns,
207            host_dispatch,
208            allocated_arg_count: out.allocated_arg_count,
209            allocated_call_depth: out.allocated_call_depth,
210            sources: out.sources,
211            structs: out.structs,
212            enums: out.enums,
213            functions: out.functions,
214            dyn_libs: out.dyn_libs,
215            namespace: out.namespace,
216            const_registers,
217            free_registers: out.free_registers,
218        };
219
220        // Instantiate: run `main` once so top-level state is established before
221        // the first host-driven call.
222        program.execute_from(0)?;
223        Ok(program)
224    }
225}
226
227/// Checks that a registered closure's derived signature matches the `host`
228/// block declaration it is bound to.
229fn validate_host_fn(
230    sig: &HostFnSig,
231    registered: &RegisteredFn,
232    filename: &str,
233) -> Result<(), Diagnostic> {
234    let err = |message: String| Diagnostic {
235        filename: filename.to_owned(),
236        span: 0..0,
237        message,
238        code: String::from("host_fn_signature_mismatch"),
239    };
240
241    // A variadic declaration must be bound to a variadic closure and vice
242    // versa; when both agree there is nothing to check, the closure accepts
243    // any argument slice.
244    if sig.variadic || registered.variadic {
245        if sig.variadic != registered.variadic {
246            let (decl, reg) = if sig.variadic {
247                ("variadic (`...`)", "a fixed signature")
248            } else {
249                ("a fixed signature", "variadic")
250            };
251            return Err(err(format!(
252                "host function `{}.{}` is declared with {decl} but the registered closure has {reg}",
253                sig.namespace, sig.name,
254            )));
255        }
256        return Ok(());
257    }
258
259    if sig.arg_count() != registered.arg_types.len() {
260        return Err(err(format!(
261            "host function `{}.{}` is declared with {} argument(s) but the registered closure takes {}",
262            sig.namespace,
263            sig.name,
264            sig.arg_count(),
265            registered.arg_types.len(),
266        )));
267    }
268
269    for (idx, want) in registered.arg_types.iter().enumerate() {
270        let declared = HostType::from_datatype(sig.get_arg(idx)).ok_or_else(|| {
271            err(format!(
272                "host function `{}.{}` argument {} has a type that cannot cross the host boundary",
273                sig.namespace,
274                sig.name,
275                idx + 1,
276            ))
277        })?;
278        if declared != *want {
279            return Err(err(format!(
280                "host function `{}.{}` argument {} is declared `{}` but the registered closure expects `{}`",
281                sig.namespace,
282                sig.name,
283                idx + 1,
284                declared.describe(),
285                want.describe(),
286            )));
287        }
288    }
289
290    let declared_ret = HostType::from_datatype(sig.get_return_type()).ok_or_else(|| {
291        err(format!(
292            "host function `{}.{}` has a return type that cannot cross the host boundary",
293            sig.namespace, sig.name,
294        ))
295    })?;
296    if declared_ret != registered.ret_type {
297        return Err(err(format!(
298            "host function `{}.{}` is declared to return `{}` but the registered closure returns `{}`",
299            sig.namespace,
300            sig.name,
301            declared_ret.describe(),
302            registered.ret_type.describe(),
303        )));
304    }
305
306    Ok(())
307}
308
309/// A compiled candela program with resident interpreter state.
310///
311/// Registers and heap pools persist between [`Program::call`] invocations, so
312/// state established by one call (including anything a host function mutates on
313/// the Rust side) is visible to the next.
314///
315/// `Program` is single-threaded (`!Send`/`!Sync`): it holds `Rc` dispatchers
316/// and reflects candela's single-threaded VM.
317pub struct Program {
318    // ---- VM state (persists across calls) ----
319    instructions: Vec<Instr>,
320    registers: Vec<Data>,
321    pools: Pools,
322    instr_src: Vec<InstrSrc>,
323    fn_registers: Vec<Vec<u16>>,
324    dyn_lib_fns: Vec<DynamicLibFn>,
325    host_sigs: Vec<HostFnSig>,
326    host_dispatch: Vec<HostDispatch>,
327    allocated_arg_count: usize,
328    allocated_call_depth: usize,
329    sources: Vec<Source>,
330    structs: Vec<Struct>,
331    enums: Vec<EnumType>,
332    // ---- compiler state (drives on-demand call trampolines) ----
333    functions: Vec<Function>,
334    dyn_libs: Vec<Dynamiclib>,
335    namespace: Namespace,
336    const_registers: FxHashMap<Data, u16>,
337    free_registers: Vec<u16>,
338}
339
340impl Program {
341    /// Invokes the script-defined function `fn_name` with `args`, returning its
342    /// value (or [`Value::Null`] for a void function).
343    ///
344    /// Each call compiles a small trampoline (which specializes `fn_name` for
345    /// the argument types if it hasn't been already) onto the resident
346    /// instruction stream and runs it against the persistent register/heap
347    /// state, so globals mutated by a previous call remain visible.
348    ///
349    /// # Errors
350    ///
351    /// Returns a [`Diagnostic`] if `fn_name` is unknown, if the arguments don't
352    /// type-check against its signature, or if the call raises a runtime error.
353    pub fn call(&mut self, fn_name: &str, args: &[Value]) -> Result<Value, Diagnostic> {
354        // Scalars compile as literal exprs; arrays/maps can't, so they are
355        // allocated into the heap pools now and passed as a pre-seeded variable
356        // that holds the handle in a register the trampoline moves into place.
357        let dummy = Span { start: 0, end: 0 };
358        let mut arg_exprs: Vec<Expr> = Vec::with_capacity(args.len());
359        let mut seed_vars: Vec<Variable> = Vec::new();
360        for (i, v) in args.iter().enumerate() {
361            if let Some(expr) = value_to_expr(v) {
362                arg_exprs.push(expr);
363            } else {
364                let handle = marshal_value(
365                    v,
366                    &mut self.pools.objs,
367                    &mut self.pools.maps,
368                    &mut self.pools.strings,
369                );
370                let register_id = self.registers.len() as u16;
371                self.registers.push(handle);
372                let name = SmolStr::from(format!("__host_arg{i}"));
373                seed_vars.push(Variable {
374                    name: name.clone(),
375                    register_id,
376                    var_type: value_datatype(v),
377                });
378                arg_exprs.push(Expr::Var(name, dummy));
379            }
380        }
381
382        let arg_spans: Box<[Span]> = args.iter().map(|_| dummy).collect();
383        let call_expr = Expr::FunctionCall(
384            arg_exprs.into_boxed_slice(),
385            Box::from([SmolStr::from(fn_name)]),
386            dummy,
387            arg_spans,
388        );
389
390        // Compile the trampoline (type-checks the call) under a diagnostic sink.
391        let (mut output, ret_id) =
392            collect_diagnostic(|| self.build_trampoline(&call_expr, seed_vars))?;
393
394        let ret_id = ret_id.unwrap_or(0);
395        output.push(Instr::Halt(0));
396        let start = self.instructions.len();
397        self.instructions.extend(output);
398
399        self.execute_from(start)?;
400
401        Ok(unmarshal_value(
402            self.registers[ret_id as usize],
403            &self.pools.objs,
404            &self.pools.maps,
405            &self.pools.strings,
406            &self.structs,
407        ))
408    }
409
410    /// Compiles a call trampoline for `call_expr`, appending any freshly
411    /// specialized function bodies to a local buffer whose instructions are
412    /// absolute (offset by the current instruction count). Returns the buffer
413    /// and the register holding the call's result.
414    fn build_trampoline(
415        &mut self,
416        call_expr: &Expr,
417        seed_vars: Vec<Variable>,
418    ) -> (Vec<Instr>, Option<u16>) {
419        // A prior call whose trampoline aborted mid-inference (error unwind) may
420        // have left stale entries in the return-type inference thread-local;
421        // clear it so this compile starts clean, exactly as `compile()` does.
422        crate::compiler::type_system::reset_inference_state();
423
424        let offset = self.instructions.len() as u16;
425        let ctx = Ctx {
426            block_id: 0,
427            is_compiling_recursive: false,
428            single_run: false,
429            file_idx: 0,
430            offset,
431        };
432        // Pre-seeded variables hold heap handles for non-scalar arguments.
433        let mut variables = seed_vars;
434        let mut output = Vec::new();
435        let mut state = State {
436            registers: &mut self.registers,
437            fns: &mut self.functions,
438            structs: &mut self.structs,
439            enums: &mut self.enums,
440            pools: &mut self.pools,
441            instr_src: &mut self.instr_src,
442            fn_registers: &mut self.fn_registers,
443            dyn_libs: &mut self.dyn_libs,
444            allocated_arg_count: &mut self.allocated_arg_count,
445            allocated_call_depth: &mut self.allocated_call_depth,
446            const_registers: &mut self.const_registers,
447            free_registers: &mut self.free_registers,
448            sources: &mut self.sources,
449            reserved_registers: rustc_hash::FxHashSet::default(),
450            namespace: &mut self.namespace,
451        };
452        let ret = call_expr.compile(
453            &mut variables,
454            ctx,
455            &mut state,
456            &mut output,
457            None,
458            false,
459            true,
460        );
461        (output, ret)
462    }
463
464    /// Runs the VM against the resident state starting at instruction `start`,
465    /// capturing any error as a [`Diagnostic`].
466    fn execute_from(&mut self, start: usize) -> Result<(), Diagnostic> {
467        let err_ctx = ErrorCtx {
468            instr_src: self.instr_src.clone(),
469            sources: self
470                .sources
471                .iter()
472                .map(|s| Source {
473                    filename: s.filename.clone(),
474                    contents: s.contents.clone(),
475                })
476                .collect(),
477        };
478
479        // Move the register file out so the VM can borrow it mutably, then
480        // reclaim it (register state must persist across calls).
481        let mut register_file = RegisterFile(std::mem::take(&mut self.registers));
482
483        let instructions = &self.instructions;
484        let pools = &mut self.pools;
485        let fn_registers = &self.fn_registers;
486        let dyn_lib_fns = &self.dyn_lib_fns;
487        let structs = &self.structs;
488        let enums = &self.enums;
489        let host_sigs = &self.host_sigs;
490        let host_dispatch = &self.host_dispatch;
491        let allocated_arg_count = self.allocated_arg_count;
492        let allocated_call_depth = self.allocated_call_depth;
493
494        let result = collect_diagnostic(|| {
495            vm::execute(
496                instructions,
497                &mut register_file,
498                pools,
499                &err_ctx,
500                fn_registers,
501                dyn_lib_fns,
502                structs,
503                enums,
504                allocated_arg_count,
505                allocated_call_depth,
506                host_sigs,
507                host_dispatch,
508                start,
509            );
510        });
511
512        self.registers = std::mem::take(&mut register_file.0);
513        result
514    }
515}
516
517/// Synthesizes a literal [`Expr`] carrying a scalar [`Value`] so a host argument
518/// can be compiled through the ordinary call path. candela integers are 32-bit, so
519/// [`Value::Int`] is narrowed here. Returns `None` for non-scalars (arrays/maps),
520/// which cannot be expressed as literal exprs and are instead allocated into the
521/// heap pools and passed as a register handle (see [`Program::call`]).
522fn value_to_expr(v: &Value) -> Option<Expr> {
523    Some(match v {
524        Value::Null => Expr::Null,
525        Value::Int(i) => Expr::Int(*i as i32),
526        Value::Float(f) => Expr::Float(*f),
527        Value::Bool(b) => Expr::Bool(*b),
528        Value::String(s) => Expr::String(SmolStr::from(s.as_str())),
529        Value::Array(_) | Value::Map(_) => return None,
530    })
531}
532
533/// Infers the candela [`DataType`] of a [`Value`] so a host-provided array/map
534/// argument can be given a type the call site type-checks against. Homogeneous
535/// element/value types are assumed (matching candela's static collection typing);
536/// the first element is sampled, empty collections yield an unknown element type.
537fn value_datatype(v: &Value) -> DataType {
538    match v {
539        Value::Null => DataType::Null,
540        Value::Int(_) => DataType::Int,
541        Value::Float(_) => DataType::Float,
542        Value::Bool(_) => DataType::Bool,
543        Value::String(_) => DataType::String,
544        Value::Array(items) => DataType::Array(items.first().map(|e| Box::new(value_datatype(e)))),
545        Value::Map(entries) => {
546            let value = entries.values().next().map(value_datatype);
547            DataType::Map(Box::new((Some(DataType::String), value)))
548        }
549    }
550}