Skip to main content

oxdock_core/exec/
native.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::sync::Arc;
3
4use anyhow::Result;
5use oxdock_func_macro::oxdock_func;
6use oxdock_parser::{
7    KEYWORD_INSPECT, SCRIPT_MODULE_NAME, STD_MODULE_NAME, Step, Value, base_name, qualify,
8    split_qualified,
9};
10use oxdock_process::{CommandStdin, DefaultProcessManager, ProcessManager};
11
12use super::io::StreamHandle;
13use super::state::ExecState;
14use super::steps::StepCtx;
15use super::typing::TypeDescriptor;
16
17/// Origin of a callable in the unified function registry. `Script` is an
18/// interpreted `FUNC` body; `HostCtx` and `HostPure` are compiled Rust
19/// functions (the `#[oxdock_func]` host export macro). Builtins and
20/// runtime-registered hosts share the host kinds; only `DESCRIBE` output
21/// shows the label, and both host kinds keep rendering as `host`.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum FuncKind {
24    Script,
25    HostCtx,
26    HostPure,
27}
28
29impl FuncKind {
30    pub fn label(&self) -> &'static str {
31        match self {
32            FuncKind::Script => "script",
33            FuncKind::HostCtx | FuncKind::HostPure => "host",
34        }
35    }
36}
37
38/// One declared parameter of a registered function. `param_type` is `None`
39/// for unconstrained `Value` parameters (the macro accepts any value).
40#[derive(Debug, Clone)]
41pub struct FuncParam {
42    pub name: String,
43    pub param_type: Option<String>,
44}
45
46/// Introspectable metadata for one function. Single source for
47/// `DESCRIBE(name)` output and the static function reference. `rpn` names
48/// whether the function also runs on the compiled math path (`true` for
49/// pure functions and opted-in stateful ones); everything runs on the AST
50/// path.
51#[derive(Debug, Clone)]
52pub struct FuncMeta {
53    pub name: String,
54    /// Owning module (`STD` for builtins, `SCRIPT` for DSL definitions,
55    /// the host module name otherwise). `name` is always the qualified
56    /// `MODULE::BASE` form, so listings and `DESCRIBE` never lose origin.
57    pub module: String,
58    pub kind: FuncKind,
59    pub params: Option<Vec<FuncParam>>,
60    pub returns: Option<String>,
61    pub rpn: bool,
62    pub summary: &'static str,
63    pub docs: &'static str,
64}
65
66/// Pure scalar function: no filesystem, no scope, no process access.
67/// Usable from both AST evaluation and compiled RPN math.
68pub type PureFn = Arc<dyn Fn(Vec<Value>) -> Result<Value> + Send + Sync>;
69
70/// Stateful/IO function with full step context (fs, cwd, envs, vars, pipes).
71pub type NativeFn<P> = Arc<dyn Fn(&mut StepCtx<P>, Vec<Value>) -> Result<Value> + Send + Sync>;
72
73/// Export hook for a DSL function, implemented by `#[oxdock_func]` on a
74/// registration marker. The engine calls `registration()` and never names
75/// a generated symbol.
76pub trait OxDockFn<P: ProcessManager> {
77    /// The registry entry deriving from the Rust signature plus doc
78    /// comments: name, metadata, and entry point, composed.
79    fn registration() -> HostRegistration<P>;
80}
81
82/// One host-registered function, grouped into a [`HostModule`] and passed
83/// to `Engine::register_module`. Build the entry with the `#[oxdock_func]`-
84/// generated registration marker, or by hand. `Pure` entries run
85/// on both the AST and the compiled RPN math paths; `Stateful` entries run
86/// on the AST path with full step context.
87pub enum HostRegistration<P: ProcessManager> {
88    Stateful {
89        name: String,
90        meta: FuncMeta,
91        func: NativeFn<P>,
92    },
93    Pure {
94        name: String,
95        meta: FuncMeta,
96        func: PureFn,
97    },
98}
99
100// Manual `Clone` (a derive would demand `P: Clone`): entries share their
101// function pointers through the `Arc`s and deep-copy the metadata.
102impl<P: ProcessManager> Clone for HostRegistration<P> {
103    fn clone(&self) -> Self {
104        match self {
105            HostRegistration::Stateful { name, meta, func } => HostRegistration::Stateful {
106                name: name.clone(),
107                meta: meta.clone(),
108                func: Arc::clone(func),
109            },
110            HostRegistration::Pure { name, meta, func } => HostRegistration::Pure {
111                name: name.clone(),
112                meta: meta.clone(),
113                func: Arc::clone(func),
114            },
115        }
116    }
117}
118
119impl<P: ProcessManager> HostRegistration<P> {
120    /// Introspectable metadata for this entry, for documentation tooling
121    /// that reads modules without registering them.
122    pub fn meta(&self) -> &FuncMeta {
123        match self {
124            HostRegistration::Stateful { meta, .. } => meta,
125            HostRegistration::Pure { meta, .. } => meta,
126        }
127    }
128}
129
130/// One user-defined function body (`FUNC NAME($p: TYPE, ...) { ... }`).
131#[derive(Debug, Clone)]
132pub(super) struct FuncDefData {
133    pub(super) params: Vec<(String, String)>,
134    pub(super) body: Vec<Step>,
135}
136
137/// Executable body behind one registry entry: an interpreted script, a
138/// pure scalar function, or a stateful function with step context.
139pub(super) enum FuncBody<P: ProcessManager> {
140    Script(FuncDefData),
141    Pure(PureFn),
142    Ctx(NativeFn<P>),
143}
144
145// Manual `Clone` (a derive would demand `P: Clone`).
146impl<P: ProcessManager> Clone for FuncBody<P> {
147    fn clone(&self) -> Self {
148        match self {
149            FuncBody::Script(def) => FuncBody::Script(def.clone()),
150            FuncBody::Pure(func) => FuncBody::Pure(Arc::clone(func)),
151            FuncBody::Ctx(func) => FuncBody::Ctx(Arc::clone(func)),
152        }
153    }
154}
155
156/// One entry in the unified function registry: introspectable metadata
157/// (mandatory for every entry, backing the pre-evaluation arity gate)
158/// plus the executable body.
159pub(super) struct FuncEntry<P: ProcessManager> {
160    pub(super) meta: FuncMeta,
161    pub(super) body: FuncBody<P>,
162}
163
164// Manual `Clone` (a derive would demand `P: Clone`).
165impl<P: ProcessManager> Clone for FuncEntry<P> {
166    fn clone(&self) -> Self {
167        Self {
168            meta: self.meta.clone(),
169            body: self.body.clone(),
170        }
171    }
172}
173
174/// Names defined in one lexical scope frame: `defined` rejects duplicates
175/// in the same scope, `shadowed` restores outer definitions on exit.
176struct ScopeFrame<P: ProcessManager> {
177    defined: HashSet<String>,
178    shadowed: Vec<(String, FuncEntry<P>)>,
179}
180
181// Manual `Clone` (a derive would demand `P: Clone`).
182impl<P: ProcessManager> Clone for ScopeFrame<P> {
183    fn clone(&self) -> Self {
184        Self {
185            defined: self.defined.clone(),
186            shadowed: self.shadowed.clone(),
187        }
188    }
189}
190
191/// The single function registry: DSL `FUNC` definitions, builtins, and
192/// host extensions share one lookup table, one metadata path, and one
193/// dispatch order. Script entries scope lexically (defined names revert on
194/// scope exit, shadowing an outer definition restores it); native entries
195/// persist for the run. Shared across `fork()` via clone; the entry maps
196/// clone while scope frames stay per state.
197pub struct FunctionRegistry<P: ProcessManager> {
198    entries: HashMap<String, FuncEntry<P>>,
199    scopes: Vec<ScopeFrame<P>>,
200}
201
202impl<P: ProcessManager> FunctionRegistry<P> {
203    pub(super) fn with_builtins() -> Self {
204        let mut reg = Self {
205            entries: HashMap::new(),
206            scopes: vec![ScopeFrame {
207                defined: HashSet::new(),
208                shadowed: Vec::new(),
209            }],
210        };
211        // Every builtin registers through the same `HostRegistration`
212        // entries hosts use: no separate authoring path for engine natives.
213        // Builtins land in the `STD` module, exactly like a host module.
214        for host in Self::builtin_registrations() {
215            match host {
216                HostRegistration::Stateful { name, meta, func } => {
217                    reg.insert_qualified(STD_MODULE_NAME, name, meta, FuncBody::Ctx(func));
218                }
219                HostRegistration::Pure { name, meta, func } => {
220                    reg.insert_qualified(STD_MODULE_NAME, name, meta, FuncBody::Pure(func));
221                }
222            }
223        }
224        reg
225    }
226
227    /// All builtins as host-style registrations, built from the same
228    /// `#[oxdock_func]`-generated markers hosts use. `with_builtins`
229    /// consumes this list, so builtins and hosts share one registration
230    /// pathway instead of two authoring models.
231    pub(super) fn builtin_registrations() -> Vec<HostRegistration<P>> {
232        vec![
233            Int::registration(),
234            Float::registration(),
235            Types::registration(),
236            TypeDescribe::registration(),
237            Glob::registration(),
238            LoadToml::registration(),
239            LoadJson::registration(),
240            PathType::registration(),
241            Functions::registration(),
242            Describe::registration(),
243            IsTerminal::registration(),
244            SemaphoreNew::registration(),
245            SemaphoreTryAcquire::registration(),
246            SemaphoreAvailable::registration(),
247        ]
248    }
249
250    /// Every name the registry answers to. Backs parse-time shadow
251    /// validation through `builtin_function_names`.
252    pub(super) fn keys(&self) -> HashSet<String> {
253        self.entries.keys().cloned().collect()
254    }
255
256    /// Single lookup for every callable: script, pure, or stateful.
257    pub(super) fn get(&self, name: &str) -> Option<FuncEntry<P>> {
258        self.entries.get(name).cloned()
259    }
260
261    fn insert_native(&mut self, name: String, meta: FuncMeta, body: FuncBody<P>) {
262        self.entries.insert(name, FuncEntry { meta, body });
263    }
264
265    /// Insert under `MODULE::BASE`, stamping provenance on the metadata.
266    /// The single choke point for every registry entry: builtins pass
267    /// `STD`, hosts pass their module, scripts pass `SCRIPT`.
268    fn insert_qualified(
269        &mut self,
270        module: &str,
271        base: String,
272        mut meta: FuncMeta,
273        body: FuncBody<P>,
274    ) {
275        meta.name = qualify(module, &base);
276        meta.module = module.to_string();
277        // Last-write-wins would silently reroute calls, so a repeated
278        // qualified name is a programmer error, never a shadow: panic like
279        // conflicting type registrations do. `SCRIPT` definitions bypass
280        // this path (`define_script` owns their scoped shadowing).
281        if self.entries.contains_key(&meta.name) {
282            panic!("duplicate function registration `{}`", meta.name);
283        }
284        self.insert_native(meta.name.clone(), meta, body);
285    }
286
287    pub(super) fn register_host(
288        &mut self,
289        module: &str,
290        name: String,
291        mut meta: FuncMeta,
292        func: NativeFn<P>,
293    ) {
294        meta.kind = FuncKind::HostCtx;
295        self.insert_qualified(module, name, meta, FuncBody::Ctx(func));
296    }
297
298    pub(super) fn register_pure_host(
299        &mut self,
300        module: &str,
301        name: String,
302        mut meta: FuncMeta,
303        func: PureFn,
304    ) {
305        meta.kind = FuncKind::HostPure;
306        self.insert_qualified(module, name, meta, FuncBody::Pure(func));
307    }
308
309    /// Define a DSL `FUNC`: names colliding with any known base name cannot
310    /// shadow, same-scope duplicates cannot redefine, and nested shadowing
311    /// of an outer script definition restores on scope exit. Stored as
312    /// `SCRIPT::NAME`, exactly like every other qualified entry.
313    pub(super) fn define_script(
314        &mut self,
315        name: &str,
316        params: &[(String, String)],
317        body: &[Step],
318    ) -> Result<()> {
319        let qualified = qualify(SCRIPT_MODULE_NAME, name);
320        let shadowable = matches!(
321            self.entries.get(&qualified).map(|entry| &entry.body),
322            Some(FuncBody::Script(_))
323        );
324        // Reserved spans every module: compare base names so the message
325        // keeps naming the bare script identifier.
326        let reserved = self
327            .entries
328            .keys()
329            .any(|key| split_qualified(key).is_some_and(|(_, base)| base == name));
330        if reserved && !shadowable {
331            anyhow::bail!("cannot shadow reserved function `{name}`");
332        }
333        if self
334            .scopes
335            .last()
336            .is_some_and(|frame| frame.defined.contains(&qualified))
337        {
338            anyhow::bail!("duplicate function `{name}` in same scope");
339        }
340        let old = self.entries.insert(
341            qualified.clone(),
342            FuncEntry {
343                meta: FuncMeta {
344                    name: qualified.clone(),
345                    module: SCRIPT_MODULE_NAME.to_string(),
346                    kind: FuncKind::Script,
347                    params: Some(
348                        params
349                            .iter()
350                            .map(|(name, param_type)| FuncParam {
351                                name: name.clone(),
352                                param_type: Some(param_type.clone()),
353                            })
354                            .collect(),
355                    ),
356                    returns: None,
357                    rpn: false,
358                    summary: "DSL-defined function.",
359                    docs: "Defined via FUNC in script.",
360                },
361                body: FuncBody::Script(FuncDefData {
362                    params: params.to_vec(),
363                    body: body.to_vec(),
364                }),
365            },
366        );
367        if let Some(frame) = self.scopes.last_mut() {
368            frame.defined.insert(qualified.clone());
369            if let Some(old) = old {
370                frame.shadowed.push((qualified, old));
371            }
372        }
373        Ok(())
374    }
375
376    /// Open a lexical scope frame for script definitions. Native entries
377    /// persist; only script names track here.
378    pub(super) fn push_scope(&mut self) {
379        self.scopes.push(ScopeFrame {
380            defined: HashSet::new(),
381            shadowed: Vec::new(),
382        });
383    }
384
385    /// Close a lexical scope frame: names defined inside revert, and any
386    /// outer definition they shadowed is restored.
387    pub(super) fn pop_scope(&mut self) {
388        let Some(frame) = self.scopes.pop() else {
389            return;
390        };
391        for name in frame.defined {
392            self.entries.remove(&name);
393        }
394        for (name, old) in frame.shadowed {
395            self.entries.insert(name, old);
396        }
397    }
398
399    /// True when `name` is an interpreted script definition (needing call
400    /// scoping and depth budgeting through `call_func_value` rather than
401    /// inline evaluation).
402    pub(super) fn contains_script(&self, name: &str) -> bool {
403        matches!(
404            self.entries.get(name).map(|entry| &entry.body),
405            Some(FuncBody::Script(_))
406        )
407    }
408
409    /// Clone the pure fn for `name` (ending the registry borrow) so callers
410    /// can invoke it without holding `&self` across a `&mut StepCtx` use.
411    fn clone_pure_fn(&self, name: &str) -> Option<PureFn> {
412        match self.entries.get(name)?.body {
413            FuncBody::Pure(ref func) => Some(Arc::clone(func)),
414            _ => None,
415        }
416    }
417
418    /// Clone the ctx fn for `name` (ending the registry borrow) so callers
419    /// can invoke it with `&mut StepCtx` without double-borrowing state.
420    fn clone_ctx_fn(&self, name: &str) -> Option<NativeFn<P>> {
421        match self.entries.get(name)?.body {
422            FuncBody::Ctx(ref func) => Some(Arc::clone(func)),
423            _ => None,
424        }
425    }
426
427    fn meta(&self, name: &str) -> Option<FuncMeta> {
428        self.entries.get(name).map(|entry| entry.meta.clone())
429    }
430
431    fn native_metas(&self) -> Vec<FuncMeta> {
432        let mut out: Vec<FuncMeta> = Vec::new();
433        for entry in self.entries.values() {
434            if !matches!(entry.body, FuncBody::Script(_)) {
435                out.push(entry.meta.clone());
436            }
437        }
438        out.sort_by(|a, b| a.name.cmp(&b.name));
439        out
440    }
441
442    /// Every entry's metadata, scripts included, sorted by name. Backs
443    /// runtime `FUNCTIONS()` listings.
444    fn entries_metas(&self) -> Vec<FuncMeta> {
445        let mut out: Vec<FuncMeta> = self
446            .entries
447            .values()
448            .map(|entry| entry.meta.clone())
449            .collect();
450        out.sort_by(|a, b| a.name.cmp(&b.name));
451        out
452    }
453}
454
455impl<P: ProcessManager> Clone for FunctionRegistry<P> {
456    fn clone(&self) -> Self {
457        Self {
458            entries: self.entries.clone(),
459            scopes: self.scopes.clone(),
460        }
461    }
462}
463
464/// Names of all compiled-in builtins plus `INSPECT` (a dedicated AST/RPN
465/// node, not a registry entry). Read straight off a stock registry, so the
466/// `#[oxdock_func]` annotations stay the single source of truth: adding a
467/// builtin extends this set with no parallel list to update. Seeds
468/// parse-time shadow validation, so the parser crate keeps zero
469/// compile-time knowledge of builtin names.
470pub fn builtin_function_names() -> HashSet<String> {
471    let mut names = FunctionRegistry::<DefaultProcessManager>::with_builtins().keys();
472    names.insert(KEYWORD_INSPECT.to_string());
473    names
474}
475
476/// Metadata of every builtin function, sorted by name, for static
477/// rendering (docs-gen). Same single source as `builtin_function_names`:
478/// the `#[oxdock_func]` annotations, never a parallel list.
479pub fn builtin_function_metas() -> Vec<FuncMeta> {
480    FunctionRegistry::<DefaultProcessManager>::with_builtins().native_metas()
481}
482
483/// Stock `STD` module table derived from the `#[oxdock_func]` builtins:
484/// the single source of truth for builtin membership and RPN eligibility.
485/// Seeds parse-time module resolution, so the parser crate keeps zero
486/// compile-time knowledge of builtin names.
487pub fn std_module_table() -> oxdock_parser::ModuleTable {
488    // Registry names are qualified (`STD::GLOB`); the table holds bases.
489    let functions: HashSet<String> = builtin_function_metas()
490        .into_iter()
491        .map(|meta| base_name(&meta.name).to_string())
492        .collect();
493    oxdock_parser::ModuleTable {
494        modules: HashMap::from([(
495            STD_MODULE_NAME.to_string(),
496            Some(oxdock_parser::ModuleFuncs { functions }),
497        )]),
498    }
499}
500
501// Builtins below use the `#[oxdock_func]` host export macro, the exact same authoring
502// model as host-registered functions: metadata, arity checks, and argument
503// unpacking derive from the signature plus doc comments, so adding a native
504// means writing one small typed function plus one line each in
505// `builtin_registrations` (consumed by `with_builtins`) and the
506// `FUNCTIONS`/`DESCRIBE`/`TYPES` surface, which all read the same symbols.
507
508/// Convert a value to INT.
509///
510/// Trims ASCII whitespace and parses i64. Passes Int through; Float only
511/// when integral and finite.
512#[oxdock_func(pure, returns = "INT")]
513fn int(val: Value) -> Result<Value> {
514    super::args::int_from_value(val)
515}
516
517/// Convert a value to FLOAT.
518///
519/// Parses f64 (accepts int strings), bails on non-finite or non-numeric.
520#[oxdock_func(pure, returns = "FLOAT")]
521fn float(val: Value) -> Result<Value> {
522    super::args::float_from_value(val)
523}
524
525/// List workspace paths matching a glob pattern.
526///
527/// Sorted, root-relative LIST; empty on no match or `..` escape.
528#[oxdock_func(rpn, returns = "LIST")]
529fn glob<P: ProcessManager>(cx: &mut StepCtx<P>, pattern: String) -> Result<Value> {
530    super::args::glob_from_value(&[Value::string(pattern)], cx)
531}
532
533/// Load and parse a TOML file.
534///
535/// Reads a workspace file and parses TOML into a DSL value.
536#[oxdock_func(rpn, returns = "MAP")]
537fn load_toml<P: ProcessManager>(cx: &mut StepCtx<P>, path: String) -> Result<Value> {
538    super::args::load_toml_from_value(&[Value::string(path)], cx)
539}
540
541/// Load and parse a JSON file.
542///
543/// Reads a workspace file and parses JSON into a DSL value.
544#[oxdock_func(rpn, returns = "MAP")]
545fn load_json<P: ProcessManager>(cx: &mut StepCtx<P>, path: String) -> Result<Value> {
546    super::args::load_json_from_value(&[Value::string(path)], cx)
547}
548
549/// Describe a filesystem entry.
550///
551/// Reports file, dir, symlink (no-follow), or absent. AST-only by design;
552/// there is no RPN arm for filesystem IO.
553#[oxdock_func(returns = "STRING")]
554fn path_type<P: ProcessManager>(cx: &mut StepCtx<P>, path: String) -> Result<Value> {
555    super::args::path_type_from_value(&[Value::string(path)], cx)
556}
557
558/// List all visible function names.
559///
560/// Sorted LIST of qualified `MODULE::NAME` entries: DSL-defined plus native
561/// plus host-registered names.
562#[oxdock_func(returns = "LIST")]
563fn functions<P: ProcessManager>(cx: &mut StepCtx<P>) -> Result<Value> {
564    let mut names: Vec<String> = cx
565        .state
566        .list_functions()
567        .into_iter()
568        .map(|meta| meta.name)
569        .collect();
570    names.sort();
571    names.dedup();
572    Ok(Value::list(names.into_iter().map(Value::string).collect()))
573}
574
575/// Describe one function by qualified name.
576///
577/// Returns a MAP with name, module, kind, params, returns, and summary.
578/// Bare names fail closed: `DESCRIBE` requires the qualified form (except
579/// `INSPECT`, which is syntax rather than a registry entry). Errors on
580/// unknown function.
581#[oxdock_func(returns = "MAP")]
582fn describe<P: ProcessManager>(cx: &mut StepCtx<P>, name: String) -> Result<Value> {
583    if split_qualified(&name).is_none() && name != KEYWORD_INSPECT {
584        anyhow::bail!(
585            "unknown function `{name}`: DESCRIBE requires a qualified name (e.g. `STD::{name}`)"
586        );
587    }
588    cx.state
589        .describe_function(&name)
590        .ok_or_else(|| anyhow::anyhow!("unknown function {name}"))
591}
592
593/// List all known type names.
594///
595/// Sorted LIST of startup plus host-registered type descriptors. Reads the
596/// run's name directory, so it runs on the AST path like the other
597/// introspection functions.
598#[oxdock_func(returns = "LIST")]
599fn types<P: ProcessManager>(cx: &mut StepCtx<P>) -> Result<Value> {
600    Ok(Value::list(
601        cx.state
602            .type_names()
603            .into_iter()
604            .map(Value::string)
605            .collect(),
606    ))
607}
608
609/// Describe one type by name.
610///
611/// Returns a MAP with name, summary, and docs. Errors on unknown type.
612/// Reads the run's name directory, so it runs on the AST path.
613#[oxdock_func(returns = "MAP")]
614fn type_describe<P: ProcessManager>(cx: &mut StepCtx<P>, name: String) -> Result<Value> {
615    cx.state
616        .describe_type(&name)
617        .map(|descriptor| {
618            let mut map = BTreeMap::new();
619            map.insert(
620                "name".to_string(),
621                Value::string(descriptor.name.to_string()),
622            );
623            map.insert(
624                "summary".to_string(),
625                Value::string(descriptor.summary.to_string()),
626            );
627            map.insert(
628                "docs".to_string(),
629                Value::string(descriptor.docs.to_string()),
630            );
631            Value::map(map)
632        })
633        .ok_or_else(|| anyhow::anyhow!("unknown type {name}"))
634}
635
636/// Report whether a standard stream is a terminal.
637///
638/// `IS_TERMINAL("stdin")`, `IS_TERMINAL("stdout")`, or `IS_TERMINAL("stderr")`
639/// answers for the step's stream as currently bound, so scripts can adapt
640/// prompts, colors, and progress output. Anything diverted from the
641/// terminal reports false without touching host handles: `WITH_IO` pipe
642/// bindings (script backends and OS pairs), `LET`-capture sinks, staged
643/// runner sinks, and any materialized stdin stream (only a directly
644/// inherited fd falls back to the process check). A transparent root tee
645/// still answers the session question via the process check. The name
646/// matches exactly (no case folding): anything else bails. AST-only:
647/// reads the step context like the other introspection functions.
648#[oxdock_func(returns = "BOOL")]
649fn is_terminal<P: ProcessManager>(cx: &mut StepCtx<P>, stream: String) -> Result<Value> {
650    use std::io::IsTerminal;
651    let terminal = match stream.as_str() {
652        "stdin" => {
653            // A script-pipe backend is definitive; Null is /dev/null.
654            // Only a directly inherited fd answers the process check: a
655            // materialized Stream is always a binding (staged input,
656            // WITH_IO pipe, or OS half), never the raw fd.
657            if cx.stdin_pipe.is_some() {
658                false
659            } else {
660                match &cx.stdin {
661                    CommandStdin::Null => false,
662                    #[cfg(not(miri))]
663                    CommandStdin::OsPipe(_) => false,
664                    CommandStdin::Stream(_) => false,
665                    CommandStdin::Inherit => std::io::stdin().is_terminal(),
666                }
667            }
668        }
669        "stdout" => {
670            if cx.out_pipe.is_some() {
671                // WITH_IO script-pipe binding: never a terminal.
672                false
673            } else if cx.state.io.stdout().is_some() {
674                // Staged runner sink: the root tee diverts bytes to the
675                // sink only, never to real stdout.
676                false
677            } else {
678                match &cx.out {
679                    // Unbound: inherited straight through.
680                    None => std::io::stdout().is_terminal(),
681                    // OS kernel pipe: never a terminal.
682                    #[cfg(not(miri))]
683                    Some(StreamHandle::Os(_)) => false,
684                    // Root tee (transparent: forwards to real stdout when
685                    // unstaged, so terminal-ness survives) versus a genuine
686                    // diversion. WITH_IO bindings never reach here (script
687                    // pipes trip out_pipe, OS pipes trip Os, staged sinks
688                    // trip above). LET-capture cannot reach here either:
689                    // assign_capture installs no sink for a bare NAME(...)
690                    // call, so a direct query always observes the ambient
691                    // routing. The process check answers the session
692                    // question.
693                    Some(StreamHandle::Stream(_)) => std::io::stdout().is_terminal(),
694                }
695            }
696        }
697        "stderr" => {
698            if cx.state.io.stderr().is_some() {
699                // Staged runner sink: diverted, never a terminal.
700                false
701            } else {
702                match &cx.err {
703                    // Unbound: inherited straight through.
704                    None => std::io::stderr().is_terminal(),
705                    // OS kernel pipe: never a terminal.
706                    #[cfg(not(miri))]
707                    Some(StreamHandle::Os(_)) => false,
708                    // Root never tees stderr and LET never captures it,
709                    // so a bound handle here is always a WITH_IO binding.
710                    Some(StreamHandle::Stream(_)) => false,
711                }
712            }
713        }
714        _ => anyhow::bail!(
715            "IS_TERMINAL expects \"stdin\", \"stdout\", or \"stderr\", got {stream:?}"
716        ),
717    };
718    Ok(Value::bool(terminal))
719}
720
721/// Create a counting semaphore admitting at most `max` concurrent holders.
722///
723/// Non-positive maxima bail. The word names a shared backend: every clone
724/// observes the same count, and admission runs through
725/// `SEMAPHORE_TRY_ACQUIRE`, never through the `SEMAPHORE_AVAILABLE`
726/// readout.
727///
728/// ```text
729/// LET $sem: SEMAPHORE = SEMAPHORE_NEW(10)
730/// ```
731#[oxdock_func(returns = "SEMAPHORE")]
732fn semaphore_new<P: ProcessManager>(cx: &mut StepCtx<P>, max: i64) -> Result<Value> {
733    let _ = cx;
734    if max <= 0 {
735        return Err(anyhow::anyhow!(
736            "SEMAPHORE_NEW() requires a positive max, got {max}"
737        ));
738    }
739    Ok(Value::semaphore(max as usize))
740}
741
742/// Attempt one non-blocking acquire, always answering a MAP.
743///
744/// `held` is `1` with the permit under the `permit` key, or `0` with no
745/// `permit` key: branch on `$m.held` (the DSL has no null, so the absent
746/// key is the miss shape, and missing-key access already bails strictly).
747/// Never waits, so no wait can wedge.
748///
749/// ```text
750/// LET $acq: MAP = SEMAPHORE_TRY_ACQUIRE($sem)
751/// IF $acq.held == 0 {
752///   ECHO "at cap, rejecting"
753/// } ELSE {
754///   LET $permit: PERMIT = $acq.permit
755///   ASYNC { session work }
756/// }
757/// ```
758#[oxdock_func(returns = "MAP")]
759fn semaphore_try_acquire<P: ProcessManager>(cx: &mut StepCtx<P>, sem: Value) -> Result<Value> {
760    let _ = cx;
761    let Some(sem) = sem.as_semaphore() else {
762        return Err(anyhow::anyhow!(
763            "SEMAPHORE_TRY_ACQUIRE() argument `$sem` must be a SEMAPHORE, got {}",
764            sem.type_name(),
765        ));
766    };
767    let mut map = BTreeMap::new();
768    if sem.try_acquire() {
769        map.insert("held".to_string(), Value::int(1));
770        map.insert("permit".to_string(), Value::permit(&sem));
771    } else {
772        map.insert("held".to_string(), Value::int(0));
773    }
774    Ok(Value::map(map))
775}
776
777/// Read free permits under the lock, with no mutation.
778///
779/// Observability only (audit lines, healthchecks: `active = max - free`).
780/// Exact at read time and stale the instant the caller acts on it, so it
781/// must never drive admission: that is `SEMAPHORE_TRY_ACQUIRE`'s job.
782///
783/// ```text
784/// LET $free: INT = SEMAPHORE_AVAILABLE($sem)
785/// ```
786#[oxdock_func(pure, returns = "INT")]
787fn semaphore_available(sem: Value) -> Result<Value> {
788    let Some(sem) = sem.as_semaphore() else {
789        return Err(anyhow::anyhow!(
790            "SEMAPHORE_AVAILABLE() argument `$sem` must be a SEMAPHORE, got {}",
791            sem.type_name(),
792        ));
793    };
794    Ok(Value::int(sem.available() as i64))
795}
796
797fn meta_to_value(meta: &FuncMeta) -> Value {
798    let mut map = BTreeMap::new();
799    map.insert("name".to_string(), Value::string(meta.name.clone()));
800    map.insert("module".to_string(), Value::string(meta.module.clone()));
801    map.insert(
802        "kind".to_string(),
803        Value::string(meta.kind.label().to_string()),
804    );
805    let params = match &meta.params {
806        Some(params) => Value::list(
807            params
808                .iter()
809                .map(|p| {
810                    let mut entry = BTreeMap::new();
811                    entry.insert("name".to_string(), Value::string(p.name.clone()));
812                    entry.insert(
813                        "param_type".to_string(),
814                        Value::string(p.param_type.clone().unwrap_or_default()),
815                    );
816                    Value::map(entry)
817                })
818                .collect(),
819        ),
820        None => Value::string(String::new()),
821    };
822    map.insert("params".to_string(), params);
823    map.insert(
824        "returns".to_string(),
825        Value::string(meta.returns.clone().unwrap_or_default()),
826    );
827    map.insert("rpn".to_string(), Value::bool(meta.rpn));
828    map.insert(
829        "summary".to_string(),
830        Value::string(meta.summary.to_string()),
831    );
832    Value::map(map)
833}
834
835/// One host library: functions and types registered under a single module
836/// name. `Engine::register_module` stages these; runs expose them as
837/// `MODULE::NAME` calls with `MODULE` provenance on every entry.
838#[derive(Clone)]
839pub struct HostModule<P: ProcessManager> {
840    pub name: String,
841    pub funcs: Vec<HostRegistration<P>>,
842    pub types: Vec<&'static TypeDescriptor>,
843}
844
845impl<P: ProcessManager> ExecState<P> {
846    /// Register one [`HostModule`]: every function becomes callable as
847    /// `MODULE::NAME`, every type joins the run's name directory.
848    pub fn register_module(&mut self, module: HostModule<P>) {
849        for registration in module.funcs {
850            match registration {
851                HostRegistration::Stateful { name, meta, func } => {
852                    self.functions.register_host(&module.name, name, meta, func);
853                }
854                HostRegistration::Pure { name, meta, func } => {
855                    self.functions
856                        .register_pure_host(&module.name, name, meta, func);
857                }
858            }
859        }
860        for descriptor in module.types {
861            self.register_type(descriptor);
862        }
863    }
864
865    /// All visible functions: natives plus hosts plus current DSL definitions.
866    pub fn list_functions(&self) -> Vec<FuncMeta> {
867        let mut out: Vec<FuncMeta> = self.functions.entries_metas().into_iter().collect();
868        // TODO: Make a "virtual function" and don't hardcode
869        if !out.iter().any(|m| m.name == KEYWORD_INSPECT) {
870            out.push(FuncMeta {
871                name: KEYWORD_INSPECT.to_string(),
872                module: STD_MODULE_NAME.to_string(),
873                kind: FuncKind::HostCtx,
874                params: None,
875                returns: Some("MAP".to_string()),
876                rpn: false,
877                summary: "Inspect a variable binding.",
878                docs: "INSPECT($var): dedicated AST node taking a variable, not a value.",
879            });
880        }
881        out.sort_by(|a, b| a.name.cmp(&b.name));
882        out
883    }
884
885    /// Describe one function by name, or `None` when unknown.
886    pub fn describe_function(&self, name: &str) -> Option<Value> {
887        if let Some(meta) = self.functions.meta(name) {
888            return Some(meta_to_value(&meta));
889        }
890        // TODO: Make a "virtual function" and don't hardcode
891        if name == KEYWORD_INSPECT {
892            return Some(meta_to_value(&FuncMeta {
893                name: KEYWORD_INSPECT.to_string(),
894                module: STD_MODULE_NAME.to_string(),
895                kind: FuncKind::HostCtx,
896                params: None,
897                returns: Some("MAP".to_string()),
898                rpn: false,
899                summary: "Inspect a variable binding.",
900                docs: "INSPECT($var): dedicated AST node taking a variable, not a value.",
901            }));
902        }
903        None
904    }
905
906    pub(super) fn clone_native_pure(&self, name: &str) -> Option<PureFn> {
907        self.functions.clone_pure_fn(name)
908    }
909
910    pub(super) fn clone_native_ctx(&self, name: &str) -> Option<NativeFn<P>> {
911        self.functions.clone_ctx_fn(name)
912    }
913
914    pub(super) fn native_meta(&self, name: &str) -> Option<FuncMeta> {
915        self.functions.meta(name)
916    }
917}