Skip to main content

oxdock_core/exec/
engine.rs

1//! Fluent host-extension facade: register functions and types, run scripts.
2//!
3//! Raw pipeline setup (filesystem resolver, process manager, IO, registry
4//! assembly) lives here, so downstream integrations never touch it:
5//!
6//! ```rust
7//! use oxdock_core::{Engine, OxDockFn, OxDockType, Value};
8//! use oxdock_func_macro::{oxdock_func, oxdock_type};
9//! use std::fmt;
10//!
11//! /// Opaque label type.
12//! #[oxdock_type(name = "ENGINE_DOCTEST_TAG")]
13//! #[derive(Debug, Clone, PartialEq)]
14//! struct EngineTag(String);
15//!
16//! impl fmt::Display for EngineTag {
17//!     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
18//!         write!(f, "tag:{}", self.0)
19//!     }
20//! }
21//!
22//! /// Mint one opaque label.
23//! #[oxdock_func(pure)]
24//! fn engine_make_tag() -> anyhow::Result<Value> {
25//!     Ok(Value::mint_heap(EngineTag::descriptor(), EngineTag("demo".into())))
26//! }
27//!
28//! fn main() {
29//!     let temp = oxdock_fs::GuardedPath::tempdir().unwrap();
30//!     let root = temp.as_guarded_path().clone();
31//!     let mut engine = Engine::new();
32//!     engine.register_type::<EngineTag>();
33//!     engine.register_module(oxdock_core::HostModule {
34//!         name: "DEMO".to_string(),
35//!         funcs: vec![EngineMakeTag::registration()],
36//!         types: vec![],
37//!     });
38//!     let run = engine
39//!         .run_script(
40//!             &root,
41//!             "IMPORT [DEMO]\nLET $t: ENGINE_DOCTEST_TAG = ENGINE_MAKE_TAG()\n",
42//!         )
43//!         .expect("script runs");
44//!     assert!(run.bindings.contains_key("t"));
45//! }
46//! ```
47//!
48//! Hosts that need more than the defaults (a sandboxed process manager, a
49//! caller-built filesystem, custom IO) stay on the same facade: pick the
50//! manager as the type parameter, stage IO with [`Engine::with_io`], and run
51//! with [`Engine::run_steps_on`] or [`Engine::run_script_on`]. The free
52//! [`run_steps_with_manager_with_modules`](super::run_steps_with_manager_with_modules)
53//! function remains for callers that never stage host surface at all.
54//!
55//! Registration shapes behind the macros (pure vs stateful functions,
56//! heap vs inline types):
57//!
58//! ```rust
59//! use oxdock_func_macro::{oxdock_func, oxdock_type};
60//! use ::oxdock_core::{OxDockFn, OxDockType};
61//! use ::oxdock_process::DefaultProcessManager;
62//!
63//! /// Echo one value back.
64//! #[oxdock_func(pure, name = "ECHO_VAL")]
65//! fn echo_val(val: ::oxdock_core::Value) -> ::anyhow::Result<::oxdock_core::Value> {
66//!     Ok(val)
67//! }
68//!
69//! /// Read an environment variable, defaulting to empty.
70//! #[oxdock_func(name = "ENV_OR", returns = "STRING")]
71//! fn env_or<P: ::oxdock_core::ProcessManager>(
72//!     cx: &mut ::oxdock_core::StepCtx<P>,
73//!     key: String,
74//! ) -> ::anyhow::Result<::oxdock_core::Value> {
75//!     Ok(::oxdock_core::Value::string(cx.get_env(&key).unwrap_or_default()))
76//! }
77//!
78//! /// Dense vector embedding.
79//! ///
80//! /// Heap type: one box allocation per word.
81//! #[oxdock_type(name = "EMBEDDING")]
82//! #[derive(Debug, Clone, PartialEq)]
83//! struct Embedding(Vec<f32>);
84//!
85//! impl ::std::fmt::Display for Embedding {
86//!     fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result {
87//!         write!(f, "embedding[{}]", self.0.len())
88//!     }
89//! }
90//!
91//! /// Entity handle.
92//! ///
93//! /// Inline type: zero allocation, rides in the payload.
94//! #[oxdock_type(name = "ENTITY", inline)]
95//! #[derive(Clone, Copy, PartialEq)]
96//! struct EntityId(u64);
97//!
98//! impl ::std::fmt::Display for EntityId {
99//!     fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result {
100//!         write!(f, "entity#{}", self.0)
101//!     }
102//! }
103//! impl ::std::fmt::Debug for EntityId {
104//!     fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result {
105//!         write!(f, "EntityId({})", self.0)
106//!     }
107//! }
108//!
109//! fn main() {
110//!     let entry: ::oxdock_core::HostRegistration<::oxdock_process::DefaultProcessManager> =
111//!         EchoVal::registration();
112//!     let ::oxdock_core::HostRegistration::Pure { meta, .. } = entry else {
113//!         panic!("pure functions register Pure entries");
114//!     };
115//!     assert_eq!(meta.name, "ECHO_VAL");
116//!     assert_eq!(meta.summary, "Echo one value back.");
117//!     assert_eq!(meta.params.expect("one param").len(), 1);
118//!
119//!     let stateful =
120//!         <EnvOr as OxDockFn<DefaultProcessManager>>::registration();
121//!     let ::oxdock_core::HostRegistration::Stateful { meta, .. } = stateful else {
122//!         panic!("context functions register Stateful entries");
123//!     };
124//!     assert_eq!(meta.name, "ENV_OR");
125//!
126//!     let descriptor = Embedding::descriptor();
127//!     assert_eq!(descriptor.name, "EMBEDDING");
128//!     assert_eq!(descriptor.summary, "Dense vector embedding.");
129//! }
130//! ```
131
132use std::collections::BTreeMap;
133use std::fmt;
134
135use anyhow::Result;
136use oxdock_fs::{GuardedPath, PathResolver, WorkspaceFs};
137use oxdock_parser::{Step, Value};
138use oxdock_process::{DefaultProcessManager, ProcessManager, default_process_manager};
139
140use super::io::ExecIo;
141use super::native::{HostModule, HostRegistration, std_module_table};
142use super::typing::{OxDockType, TypeDescriptor};
143
144/// Output of one [`Engine`] run: the final working directory, the filesystem
145/// handle the run executed against, and the top-level script variable
146/// bindings captured at completion.
147pub struct EngineOutput {
148    /// Working directory when the last step finished.
149    pub cwd: GuardedPath,
150    /// Filesystem handle the run executed against.
151    pub fs: Box<dyn WorkspaceFs>,
152    /// Top-level script variables (`FUNC` bodies and loop scopes excluded).
153    pub bindings: BTreeMap<String, Value>,
154}
155
156impl fmt::Debug for EngineOutput {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        f.debug_struct("EngineOutput")
159            .field("cwd", &self.cwd.as_path().display().to_string())
160            .field("bindings", &self.bindings)
161            .finish_non_exhaustive()
162    }
163}
164
165/// Host-extension engine: the single front door for registering Rust
166/// functions and types and running scripts against them.
167///
168/// The type parameter selects the process manager: the default
169/// [`DefaultProcessManager`] runs real subprocesses, while test harnesses
170/// pass their mock (`Engine::<MockProcessManager>::new_custom()` in
171/// `crates/oxdock-core/tests/integration/custom_types.rs` shows the shape).
172/// Staged IO applies to every run; the process manager and the filesystem
173/// are chosen per run, so one engine serves many roots.
174pub struct Engine<P: ProcessManager = DefaultProcessManager> {
175    modules: Vec<HostModule<P>>,
176    types: Vec<&'static TypeDescriptor>,
177    io: ExecIo,
178}
179
180impl<P: ProcessManager> Engine<P> {
181    /// Empty engine for a custom process manager, with default IO and no
182    /// host surface. Prefer [`Engine::new`] for the default manager: a
183    /// bare `Engine::new()` pins the manager type for inference, while
184    /// this constructor needs the turbofish (`Engine::<Mock>::new_custom()`).
185    /// `Engine::<P>::default()` works the same way.
186    pub fn new_custom() -> Self {
187        Self {
188            modules: Vec::new(),
189            types: Vec::new(),
190            io: ExecIo::new(),
191        }
192    }
193
194    /// Stage custom IO (for example inherited environment overrides) for
195    /// every run. Chainable.
196    pub fn with_io(mut self, io: ExecIo) -> Self {
197        self.io = io;
198        self
199    }
200
201    /// Register a host library module: its functions become callable as
202    /// `MODULE::NAME`, its types join the run's name directory. Chainable.
203    /// Group `#[oxdock_func]` markers (for example `MakeTag` for
204    /// `make_tag`) with their `#[oxdock_type]` payloads here. Panics on a
205    /// duplicate qualified name, including collisions with `STD` builtins;
206    /// the registry re-checks at run time for direct state users.
207    pub fn register_module(&mut self, module: HostModule<P>) -> &mut Self {
208        let mut seen: std::collections::HashSet<String> = super::builtin_function_names();
209        for staged in self.modules.iter().chain(std::iter::once(&module)) {
210            for registration in &staged.funcs {
211                let base = match registration {
212                    HostRegistration::Stateful { name, .. }
213                    | HostRegistration::Pure { name, .. } => name,
214                };
215                let qualified = format!("{}::{base}", staged.name);
216                if !seen.insert(qualified.clone()) {
217                    panic!("duplicate function registration `{qualified}`");
218                }
219            }
220        }
221        self.modules.push(module);
222        self
223    }
224
225    /// Register a `#[oxdock_type]` payload struct. Chainable. Staged for
226    /// the run: values mint straight from the payload type's own
227    /// descriptor, which needs no registration to exist.
228    pub fn register_type<T>(&mut self) -> &mut Self
229    where
230        T: OxDockType,
231    {
232        self.types.push(T::descriptor());
233        self
234    }
235
236    /// Parse-time module table for this engine: stock `STD` builtins plus
237    /// every staged host module (function names and RPN eligibility).
238    /// Unknown modules stay unknown: the `oxdock!` macro declares its own
239    /// opaque modules via the `modules:` prefix instead.
240    pub fn module_table(&self) -> oxdock_parser::ModuleTable {
241        let mut table = std_module_table();
242        for module in &self.modules {
243            let mut functions = std::collections::HashSet::new();
244            for registration in &module.funcs {
245                let name = match registration {
246                    HostRegistration::Stateful { name, .. } => name,
247                    HostRegistration::Pure { name, .. } => name,
248                };
249                functions.insert(name.clone());
250            }
251            table.modules.insert(
252                module.name.clone(),
253                Some(oxdock_parser::ModuleFuncs { functions }),
254            );
255        }
256        table
257    }
258
259    /// Parse and run `script` on a caller-built filesystem with `process`
260    /// as the manager and the registered host surface available.
261    pub fn run_script_on(
262        &self,
263        fs: Box<dyn WorkspaceFs>,
264        script: &str,
265        process: P,
266    ) -> Result<EngineOutput> {
267        let steps = crate::parse_script_with_modules(script, self.module_table())?;
268        self.run_steps_on(fs, &steps, process)
269    }
270
271    /// Run already parsed `steps` (for example from the `oxdock!` macro,
272    /// which builds the same DSL at compile time) on a caller-built
273    /// filesystem with `process` as the manager and the registered host
274    /// surface available.
275    pub fn run_steps_on(
276        &self,
277        fs: Box<dyn WorkspaceFs>,
278        steps: &[Step],
279        process: P,
280    ) -> Result<EngineOutput> {
281        let (cwd, fs, bindings) = super::run_steps_with_manager_with_modules(
282            fs,
283            steps,
284            process,
285            self.io.clone(),
286            self.modules.clone(),
287            self.types.clone(),
288        )?;
289        Ok(EngineOutput { cwd, fs, bindings })
290    }
291}
292
293impl Engine<DefaultProcessManager> {
294    /// Empty engine with default IO and no host surface. Concrete by
295    /// construction, so `let mut engine = Engine::new()` needs no
296    /// annotation; custom managers use `Engine::<P>::new_custom()`.
297    pub fn new() -> Self {
298        Self {
299            modules: Vec::new(),
300            types: Vec::new(),
301            io: ExecIo::new(),
302        }
303    }
304
305    /// Parse and run `script` with `root` as the workspace, with the
306    /// registered host surface available. Uses the default process manager
307    /// and a resolver built from `root`.
308    pub fn run_script(&self, root: &GuardedPath, script: &str) -> Result<EngineOutput> {
309        let steps = crate::parse_script_with_modules(script, self.module_table())?;
310        self.run_steps(root, &steps)
311    }
312
313    /// Run already parsed `steps` (for example from the `oxdock!` macro,
314    /// which builds the same DSL at compile time) with `root` as the
315    /// workspace and the registered host surface available. Uses the
316    /// default process manager and a resolver built from `root`.
317    pub fn run_steps(&self, root: &GuardedPath, steps: &[Step]) -> Result<EngineOutput> {
318        let resolver = PathResolver::new_guarded(root.clone(), root.clone())?;
319        let fs: Box<dyn WorkspaceFs> = Box::new(resolver);
320        self.run_steps_on(fs, steps, default_process_manager())
321    }
322}
323
324impl<P: ProcessManager> Default for Engine<P> {
325    fn default() -> Self {
326        Self::new_custom()
327    }
328}