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}