flow_ir_core/lib.rs
1#![deny(unsafe_code)]
2#![warn(missing_docs)]
3//! flow.ir Pure Rust schema + sync interpreter.
4//!
5//! Node kinds (Step / Seq / Branch / Fanout / Loop / Try / Assign) + Expr ops
6//! (canonical wire format — comparison / boolean / existence / arithmetic /
7//! aggregate / `call_extern`) + sync `eval` + `Dispatcher` trait + `Externs`
8//! registry + typed [`Path`] read/write (see [`Path`] for the full path
9//! syntax + uniform malformed-path rejection rules — the single authority,
10//! rather than restating them here).
11//!
12//! mlua / futures / async 依存ゼロ。 async runtime + mlua binding は上流
13//! `mlua-flow-ir` crate が担当する 4 層 stack の core 層。
14//!
15//! # Quick start
16//!
17//! ```
18//! use flow_ir_core::{eval, Dispatcher, EvalError, Expr, Node};
19//! use serde_json::{json, Value};
20//!
21//! let node: Node = serde_json::from_value(json!({
22//! "kind": "step",
23//! "ref": "uppercase",
24//! "in": { "op": "path", "at": "$.input" },
25//! "out": { "op": "path", "at": "$.output" },
26//! })).unwrap();
27//!
28//! struct Fixture;
29//! impl Dispatcher for Fixture {
30//! fn dispatch(&self, _r: &str, input: Value) -> Result<Value, EvalError> {
31//! if let Value::String(s) = input {
32//! Ok(Value::String(s.to_uppercase()))
33//! } else {
34//! Ok(input)
35//! }
36//! }
37//! }
38//!
39//! let out = eval(&node, json!({ "input": "hello" }), &Fixture).unwrap();
40//! assert_eq!(out, json!({ "input": "hello", "output": "HELLO" }));
41//! ```
42
43use serde::{Deserialize, Serialize};
44use serde_json::Value;
45use thiserror::Error;
46
47mod path;
48pub use path::{Path, PathParseError};
49
50// ──────────────────────────────────────────────────────────────────────────
51// IR: 7 Node kinds + 20 Expr ops
52// ──────────────────────────────────────────────────────────────────────────
53
54/// flow.ir Node kind.
55///
56/// Discriminated with `kind` tag, `deny_unknown_fields` (open=false),
57/// `rename_all = "snake_case"`. Covers the 7 supported kinds: `Step` / `Seq`
58/// / `Branch` / `Fanout` (canonical schema の `fanout` Node、 4 join mode) /
59/// `Loop` / `Try` / `Assign`. Additional kinds may be added in future
60/// versions.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62#[serde(tag = "kind", deny_unknown_fields, rename_all = "snake_case")]
63pub enum Node {
64 /// `Step` — dispatch a referenced operation with `in` input, write result to `out`.
65 Step {
66 /// Dispatcher key (wire field `ref`), resolved via [`Dispatcher::dispatch`].
67 #[serde(rename = "ref")]
68 ref_: String,
69 /// Input `Expr`, evaluated against the ctx snapshot before dispatch (wire field `in`).
70 #[serde(rename = "in")]
71 in_: Expr,
72 /// `Path` `Expr` the dispatcher's output is written to.
73 out: Expr,
74 },
75 /// `Seq` — evaluate children in order, threading the context value through.
76 Seq {
77 /// Child nodes, evaluated in order.
78 children: Vec<Node>,
79 },
80 /// `Branch` — eval `cond`; if `true` run `then`, else run `else`.
81 Branch {
82 /// Condition `Expr`; must evaluate to a JSON boolean.
83 cond: Expr,
84 /// Branch taken when `cond` is `true` (wire field `then`).
85 #[serde(rename = "then")]
86 then_: Box<Node>,
87 /// Branch taken when `cond` is `false` (wire field `else`).
88 #[serde(rename = "else")]
89 else_: Box<Node>,
90 },
91 /// `Fanout` — eval `items` to an array, run `body` per item against a
92 /// branch-local ctx (caller ctx + item written to `bind`), join results
93 /// per `join` mode into `out`. Async parallel runner uses
94 /// `futures::future::{try_join_all|select_ok|join_all}` (executor-agnostic).
95 Fanout {
96 /// `Expr` evaluated to a JSON array; one branch runs per element.
97 items: Expr,
98 /// `Path` `Expr` each branch's item is written to before running `body`.
99 bind: Expr,
100 /// Node run once per `items` element, against a disjoint branch ctx.
101 body: Box<Node>,
102 /// How per-branch results are combined into `out`.
103 join: JoinMode,
104 /// `Path` `Expr` the joined result is written to.
105 out: Expr,
106 },
107 /// `Loop` — counter を 0 から、 `cond` が truthy かつ `counter < max` の間
108 /// `body` を eval。 各 iter 後 counter を increment して `counter` path に書く。
109 /// VerdictLoop 等の retry/poll パターン primitive (canonical schema 整合)。
110 Loop {
111 /// `Path` `Expr` the iteration counter is written to (starts at `0`).
112 counter: Expr,
113 /// Condition re-evaluated before each iteration; loop stops once falsy.
114 cond: Expr,
115 /// Node evaluated once per iteration.
116 body: Box<Node>,
117 /// Hard iteration cap (loop stops once `counter >= max`, regardless of `cond`).
118 max: u32,
119 },
120 /// `Try` — `body` を eval、 raise した場合 `catch` を eval。
121 /// `err_at` が Some なら catch 開始前に error message を ctx に書く。
122 Try {
123 /// Node evaluated first; failures trigger a ctx rollback + `catch`.
124 body: Box<Node>,
125 /// Node evaluated when `body` raises.
126 catch: Box<Node>,
127 /// Optional `Path` `Expr` the error message is written to before `catch` runs.
128 #[serde(default)]
129 err_at: Option<Expr>,
130 },
131 /// `Assign` — pure transform Node。 `value` Expr を ctx snapshot 上で評価し、
132 /// 結果を `at` (Path Expr) に write する。 dispatcher 不要、 副作用は
133 /// `CtxStorage.write` 1 回のみ。 `Seq` の中で Step 間の Adhoc update 表現に
134 /// 使う (= IR primitive、 Command 履歴は CtxStorage の write hook 経由で取得)。
135 Assign {
136 /// `Path` `Expr` the evaluated `value` is written to.
137 at: Expr,
138 /// `Expr` evaluated against the ctx snapshot.
139 value: Expr,
140 },
141}
142
143/// Fanout join semantics (Promise / futures combinators).
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146pub enum JoinMode {
147 /// every branch runs; out is an array of per-branch final ctx
148 /// (Promise.all / `futures::try_join_all`).
149 All,
150 /// first non-raising branch's ctx wins; all-fail raises
151 /// (Promise.any / `futures::future::select_ok`).
152 Any,
153 /// first branch to settle wins, success OR raise
154 /// (Promise.race / `futures::future::select`).
155 Race,
156 /// every branch runs, never raises; per-item record
157 /// `{status: fulfilled|rejected, value|reason}` (Promise.allSettled).
158 AllSettled,
159}
160
161/// flow.ir Expr op.
162///
163/// Discriminated with `op` tag, `deny_unknown_fields`, `rename_all = "snake_case"`.
164/// Wire format (op tag / field names) follows the canonical `flow-ir-lua`
165/// schema (`flow/ir/schema.lua`) verbatim: `gte`/`lte` (not `ge`/`le`),
166/// `args` on `and`/`or`, `arg` on `not`/`len`/`exists`.
167///
168/// Ops:
169/// - read / literal: `Path` / `Lit`
170/// - comparison: `Eq` / `Ne` / `Lt` / `Lte` / `Gt` / `Gte` (numbers or strings)
171/// - boolean: `Not` / `And` / `Or`
172/// - existence: `Exists` (truthy iff `arg` evaluates to a non-null value)
173/// - arithmetic: `Add` / `Sub` / `Mul` / `Div` / `Mod`
174/// - aggregate: `Len` (length of array / string / object) / `In` (membership in array)
175/// - hatch: `CallExtern` (host-registered pure function, resolved via `Externs`)
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
177#[serde(tag = "op", deny_unknown_fields, rename_all = "snake_case")]
178pub enum Expr {
179 /// `Path` — read a value from ctx by simple `$.a.b.c` form.
180 Path {
181 /// The parsed context path — see [`Path`] for syntax + rejection
182 /// rules. Deserialized (and syntax-validated) once, at parse time.
183 at: Path,
184 },
185 /// `Lit` — literal JSON value.
186 Lit {
187 /// The literal value, returned as-is on evaluation.
188 value: Value,
189 },
190 /// `Eq` — boolean equality of two sub-expressions. Numbers compare by
191 /// f64 value (`5 == 5.0` is `true`), matching the ordering ops' numeric
192 /// coercion; integers above 2^53 may lose precision (same caveat as
193 /// `Lt`/`Lte`/`Gt`/`Gte`).
194 Eq {
195 /// Left-hand operand.
196 lhs: Box<Expr>,
197 /// Right-hand operand.
198 rhs: Box<Expr>,
199 },
200 /// `Ne` — boolean inequality. Same numeric coercion as `Eq` (`5 != 5.0`
201 /// is `false`); precision caveat above 2^53 shared with ordering ops.
202 Ne {
203 /// Left-hand operand.
204 lhs: Box<Expr>,
205 /// Right-hand operand.
206 rhs: Box<Expr>,
207 },
208 /// `Lt` — `lhs < rhs`. Both numbers (f64) or both strings (lexicographic),
209 /// mirroring canonical Lua `<` semantics. Mixed / other types raise.
210 Lt {
211 /// Left-hand operand.
212 lhs: Box<Expr>,
213 /// Right-hand operand.
214 rhs: Box<Expr>,
215 },
216 /// `Lte` — `lhs <= rhs` (canonical wire tag `lte`).
217 Lte {
218 /// Left-hand operand.
219 lhs: Box<Expr>,
220 /// Right-hand operand.
221 rhs: Box<Expr>,
222 },
223 /// `Gt` — `lhs > rhs`.
224 Gt {
225 /// Left-hand operand.
226 lhs: Box<Expr>,
227 /// Right-hand operand.
228 rhs: Box<Expr>,
229 },
230 /// `Gte` — `lhs >= rhs` (canonical wire tag `gte`).
231 Gte {
232 /// Left-hand operand.
233 lhs: Box<Expr>,
234 /// Right-hand operand.
235 rhs: Box<Expr>,
236 },
237 /// `Not` — boolean negation of `arg` (truthy-based; null/false → true).
238 Not {
239 /// Operand negated by truthiness.
240 arg: Box<Expr>,
241 },
242 /// `And` — variadic boolean conjunction (short-circuit). Empty list → true.
243 And {
244 /// Operands evaluated left-to-right until one is falsy.
245 args: Vec<Expr>,
246 },
247 /// `Or` — variadic boolean disjunction (short-circuit). Empty list → false.
248 Or {
249 /// Operands evaluated left-to-right until one is truthy.
250 args: Vec<Expr>,
251 },
252 /// `Exists` — evaluate `arg`; `true` iff it resolves to a non-null value.
253 /// A `Path` arg that raises `PathNotFound` yields `false` (canonical
254 /// `arg ~= nil` semantics — JSON null maps to Lua nil).
255 Exists {
256 /// Operand whose presence (non-null, resolvable) is tested.
257 arg: Box<Expr>,
258 },
259 /// `Add` — numeric `lhs + rhs` (f64).
260 Add {
261 /// Left-hand operand.
262 lhs: Box<Expr>,
263 /// Right-hand operand.
264 rhs: Box<Expr>,
265 },
266 /// `Sub` — numeric `lhs - rhs`.
267 Sub {
268 /// Left-hand operand.
269 lhs: Box<Expr>,
270 /// Right-hand operand.
271 rhs: Box<Expr>,
272 },
273 /// `Mul` — numeric `lhs * rhs`.
274 Mul {
275 /// Left-hand operand.
276 lhs: Box<Expr>,
277 /// Right-hand operand.
278 rhs: Box<Expr>,
279 },
280 /// `Div` — numeric `lhs / rhs`. Division by zero raises `ArithError`.
281 Div {
282 /// Left-hand operand (dividend).
283 lhs: Box<Expr>,
284 /// Right-hand operand (divisor).
285 rhs: Box<Expr>,
286 },
287 /// `Mod` — numeric `lhs % rhs` (Lua `%` semantics: result takes the sign
288 /// of `rhs`). Modulo by zero raises `ArithError`.
289 Mod {
290 /// Left-hand operand (dividend).
291 lhs: Box<Expr>,
292 /// Right-hand operand (divisor).
293 rhs: Box<Expr>,
294 },
295 /// `Len` — length of `arg`: array → element count, string → char count,
296 /// object → key count. Other types raise `TypeError`.
297 Len {
298 /// Operand whose length is computed.
299 arg: Box<Expr>,
300 },
301 /// `In` — `true` if `needle` equals any element of `haystack` (which must
302 /// evaluate to an array). Rust-side extension (not in canonical schema).
303 /// Element equality uses the same numeric coercion as `Eq` (`5 == 5.0`);
304 /// precision caveat above 2^53 shared with ordering ops.
305 In {
306 /// Value tested for membership.
307 needle: Box<Expr>,
308 /// Operand that must evaluate to a JSON array.
309 haystack: Box<Expr>,
310 },
311 /// `CallExtern` — value-shape Hatch: resolve a host-injected pure function
312 /// by opaque key via the `Externs` registry, apply it to evaluated args,
313 /// return the value. The registered function MUST be pure (no side
314 /// effects, no flow control) — see canonical `doc/ir.md §call_extern`.
315 CallExtern {
316 /// Extern registry key (wire field `ref`), resolved via [`Externs::call`].
317 #[serde(rename = "ref")]
318 ref_: String,
319 /// Argument expressions, evaluated before the extern call.
320 args: Vec<Expr>,
321 },
322}
323
324// ──────────────────────────────────────────────────────────────────────────
325// Dispatcher trait + EvalError
326// ──────────────────────────────────────────────────────────────────────────
327
328/// Dispatcher callback: resolves a `Step.ref` against the provided input,
329/// returns the step's raw output value.
330///
331/// Host crates (e.g. `mlua-swarm-engine`) provide concrete implementations:
332/// agent-block process spawn, mlua callback, MCP call, direct LLM, etc.
333/// `Fn(&str, Value) -> Result<Value, EvalError>` closures also implement this
334/// trait via the blanket impl below.
335pub trait Dispatcher {
336 /// Resolve `ref_` against `input`, returning the step's raw output value.
337 fn dispatch(&self, ref_: &str, input: Value) -> Result<Value, EvalError>;
338}
339
340impl<F> Dispatcher for F
341where
342 F: Fn(&str, Value) -> Result<Value, EvalError>,
343{
344 fn dispatch(&self, ref_: &str, input: Value) -> Result<Value, EvalError> {
345 self(ref_, input)
346 }
347}
348
349/// Evaluation error.
350///
351/// `#[non_exhaustive]`: new variants may be added in a minor release: match
352/// with a wildcard arm.
353#[derive(Debug, Error)]
354#[non_exhaustive]
355pub enum EvalError {
356 /// A `Path` read did not resolve — the requested key is missing from ctx.
357 #[error("path not found: {0}")]
358 PathNotFound(String),
359 /// A `Path` string is malformed — see [`Path`] for the full syntax +
360 /// rejection rules. Raised by the `read_path` / `write_path` compat
361 /// wrappers when their `&str` argument fails to parse; never raised by
362 /// [`Path::read`] / [`Path::write`] themselves (an already-parsed
363 /// `Path` cannot represent malformed syntax).
364 #[error("invalid path syntax: {0}")]
365 InvalidPath(String),
366 /// `Branch.cond` evaluated to a non-boolean value.
367 #[error("branch cond must be boolean, got: {0}")]
368 NonBoolCond(Value),
369 /// An expression or node received a value of the wrong type (e.g. a
370 /// `Len`/`In`/comparison/arithmetic operand of the wrong JSON type, or a
371 /// `Fanout.items` result that did not evaluate to an array).
372 #[error("type error in '{op}': {msg}")]
373 TypeError {
374 /// The op (or synthetic label, e.g. `"fanout.any"`) that raised.
375 op: String,
376 /// Human-readable description of the type mismatch.
377 msg: String,
378 },
379 /// An arithmetic operation failed (division/modulo by zero, or a
380 /// numeric result/operand that cannot be represented as `f64`).
381 #[error("arithmetic error in '{op}': {msg}")]
382 ArithError {
383 /// The op (e.g. `"div"`, `"mod"`, `"cmp"`) that raised.
384 op: String,
385 /// Human-readable description of the arithmetic failure.
386 msg: String,
387 },
388 /// The [`Dispatcher`] returned an error for the given `Step.ref`.
389 #[error("dispatcher error for ref '{ref_}': {msg}")]
390 DispatcherError {
391 /// The `Step.ref` that raised.
392 ref_: String,
393 /// The dispatcher's error message.
394 msg: String,
395 },
396 /// The [`Externs`] registry raised for the given `call_extern.ref` (e.g.
397 /// unregistered ref, or the extern fn itself returned an error).
398 #[error("extern error for ref '{ref_}': {msg}")]
399 ExternError {
400 /// The `call_extern.ref` that raised.
401 ref_: String,
402 /// The extern fn's error message.
403 msg: String,
404 },
405}
406
407// ──────────────────────────────────────────────────────────────────────────
408// Externs — whitelist registry for `call_extern` Expr (canonical opts.externs)
409// ──────────────────────────────────────────────────────────────────────────
410
411/// Extern registry: resolves a `call_extern.ref` against evaluated args and
412/// returns the value. Mirror of canonical `opts.externs` (flow-ir-lua
413/// `interpreter.lua`): each entry MUST be a pure function — no side effects,
414/// no flow control, value-shape manipulation only.
415///
416/// Same DI pattern as [`Dispatcher`]: host crates provide concrete
417/// implementations ([`ExternMap`] for plain Rust closures, mlua bridge for
418/// Lua functions upstream).
419pub trait Externs {
420 /// Invoke the extern registered under `ref_` with already-evaluated args.
421 /// Unregistered refs raise [`EvalError::ExternError`].
422 fn call(&self, ref_: &str, args: &[Value]) -> Result<Value, EvalError>;
423}
424
425/// Empty registry — every `call_extern` raises `ExternError` (parity with
426/// canonical "requires opts.externs" error). Used by the externs-less
427/// compat wrappers (`eval` / `eval_expr` / `eval_with_storage`).
428pub struct NoExterns;
429
430impl Externs for NoExterns {
431 fn call(&self, ref_: &str, _args: &[Value]) -> Result<Value, EvalError> {
432 Err(EvalError::ExternError {
433 ref_: ref_.into(),
434 msg: "no externs registry configured".into(),
435 })
436 }
437}
438
439/// Boxed pure extern function stored in [`ExternMap`].
440pub type ExternFn = Box<dyn Fn(&[Value]) -> Result<Value, EvalError> + Send + Sync>;
441
442/// `HashMap`-backed [`Externs`] impl for host-side Rust closures.
443///
444/// ```
445/// use flow_ir_core::{eval_expr_with_externs, EvalError, Expr, ExternMap};
446/// use serde_json::{json, Value};
447///
448/// let mut externs = ExternMap::new();
449/// externs.register("math.sqrt", |args: &[Value]| {
450/// let x = args
451/// .first()
452/// .and_then(|v| v.as_f64())
453/// .ok_or_else(|| EvalError::ExternError {
454/// ref_: "math.sqrt".into(),
455/// msg: "expected number".into(),
456/// })?;
457/// Ok(json!(x.sqrt()))
458/// });
459///
460/// let expr: Expr = serde_json::from_value(json!({
461/// "op": "call_extern", "ref": "math.sqrt",
462/// "args": [{ "op": "lit", "value": 9.0 }],
463/// })).unwrap();
464/// let out = eval_expr_with_externs(&expr, &json!({}), &externs).unwrap();
465/// assert_eq!(out, json!(3.0));
466/// ```
467#[derive(Default)]
468pub struct ExternMap {
469 fns: std::collections::HashMap<String, ExternFn>,
470}
471
472impl ExternMap {
473 /// Create an empty registry.
474 pub fn new() -> Self {
475 Self::default()
476 }
477
478 /// Register a pure function under `name` (overwrites an existing entry).
479 pub fn register<F>(&mut self, name: impl Into<String>, f: F)
480 where
481 F: Fn(&[Value]) -> Result<Value, EvalError> + Send + Sync + 'static,
482 {
483 self.fns.insert(name.into(), Box::new(f));
484 }
485
486 /// Whether `name` is registered (compile-time whitelist check parity).
487 pub fn contains(&self, name: &str) -> bool {
488 self.fns.contains_key(name)
489 }
490}
491
492impl Externs for ExternMap {
493 fn call(&self, ref_: &str, args: &[Value]) -> Result<Value, EvalError> {
494 let f = self.fns.get(ref_).ok_or_else(|| EvalError::ExternError {
495 ref_: ref_.into(),
496 msg: "not registered in externs".into(),
497 })?;
498 f(args)
499 }
500}
501
502// ──────────────────────────────────────────────────────────────────────────
503// CtxStorage — Backend DI for ctx state
504// ──────────────────────────────────────────────────────────────────────────
505
506/// Ctx backend trait — `eval(_with_storage)` 系が ctx state を touch する
507/// 唯一の経路。 `&self` write (interior mutability) で **走行中の Flow と
508/// 外部 task が同じ ctx を共有** できる (= dispatch().await suspend 中に外部
509/// task が `ctx.write` で State 注入 → resume 後 Step が read で観測、 という
510/// dynamic injection 経路を成立させる)。
511///
512/// Default impl は `MemoryCtx` (`Arc<Mutex<Value>>` wrapper、 既存
513/// `serde_json::Value` 直保持と挙動互換)。 consumer は typed struct / KV /
514/// 外部 store / observer wrap / event log 等を custom impl で持ち込める。
515pub trait CtxStorage: Send + Sync {
516 /// Read a single path (`$.a.b.c` 形式) from ctx.
517 fn read(&self, path: &str) -> Result<Value, EvalError>;
518 /// Write `value` to `path` (`$.a.b.c` 形式).
519 fn write(&self, path: &str, value: Value) -> Result<(), EvalError>;
520 /// Take a snapshot of the entire ctx (= Expr eval / Fanout fork で使う pure read view).
521 fn snapshot(&self) -> Value;
522 /// Replace the entire ctx with the given value (= Fanout branch restore 等).
523 fn replace(&self, value: Value);
524}
525
526/// Default `CtxStorage` impl — `Arc<Mutex<Value>>` wrapper。
527///
528/// Send + Sync かつ `&self` write OK = `Arc<MemoryCtx>` で外部 task と共有可能。
529pub struct MemoryCtx {
530 inner: std::sync::Mutex<Value>,
531}
532
533impl MemoryCtx {
534 /// Create a new MemoryCtx initialised with `ctx`.
535 pub fn new(ctx: Value) -> Self {
536 Self {
537 inner: std::sync::Mutex::new(ctx),
538 }
539 }
540
541 /// Convenience: wrap in `Arc<dyn CtxStorage>`.
542 pub fn shared(ctx: Value) -> std::sync::Arc<dyn CtxStorage> {
543 std::sync::Arc::new(Self::new(ctx))
544 }
545}
546
547impl CtxStorage for MemoryCtx {
548 fn read(&self, path: &str) -> Result<Value, EvalError> {
549 let guard = self.inner.lock().expect("ctx mutex poisoned");
550 read_path(path, &guard)
551 }
552
553 fn write(&self, path: &str, value: Value) -> Result<(), EvalError> {
554 let parsed: Path = path
555 .parse()
556 .map_err(|e: PathParseError| EvalError::InvalidPath(e.to_string()))?;
557 let mut guard = self.inner.lock().expect("ctx mutex poisoned");
558 parsed.write(&mut guard, value)
559 }
560
561 fn snapshot(&self) -> Value {
562 let guard = self.inner.lock().expect("ctx mutex poisoned");
563 guard.clone()
564 }
565
566 fn replace(&self, value: Value) {
567 let mut guard = self.inner.lock().expect("ctx mutex poisoned");
568 *guard = value;
569 }
570}
571
572/// Extract the already-parsed [`Path`] out of a `Path` `Expr`, or
573/// `InvalidPath` if `expr` is some other `Expr` variant. No parsing happens
574/// here — the `Path` was parsed once, at deserialize (or `Path::from_str`)
575/// time.
576fn path_of(expr: &Expr) -> Result<&Path, EvalError> {
577 match expr {
578 Expr::Path { at } => Ok(at),
579 _ => Err(EvalError::InvalidPath(
580 "expected Path expr for write target".into(),
581 )),
582 }
583}
584
585// ──────────────────────────────────────────────────────────────────────────
586// Evaluator — storage-backed (canonical) + legacy Value-passing wrapper
587// ──────────────────────────────────────────────────────────────────────────
588
589/// Storage-backed sync evaluator — `CtxStorage` 経由で ctx を touch する正本。
590///
591/// 各 Node 評価開始時に `ctx.snapshot()` で Expr eval 用の pure view を取り、
592/// write は `ctx.write(path, value)` 経由。 これにより同じ `Arc<dyn CtxStorage>`
593/// を共有する外部 task が、 Step 間 (sync の場合は 1 Step 評価内では touch
594/// しないが) や eval 間で ctx state を変更できる。
595pub fn eval_with_storage<D: Dispatcher>(
596 node: &Node,
597 ctx: &dyn CtxStorage,
598 dispatcher: &D,
599) -> Result<(), EvalError> {
600 eval_with_storage_externs(node, ctx, dispatcher, &NoExterns)
601}
602
603/// `eval_with_storage` + externs registry for `call_extern` Expr resolution.
604pub fn eval_with_storage_externs<D: Dispatcher>(
605 node: &Node,
606 ctx: &dyn CtxStorage,
607 dispatcher: &D,
608 externs: &dyn Externs,
609) -> Result<(), EvalError> {
610 match node {
611 Node::Step { ref_, in_, out } => {
612 let snap = ctx.snapshot();
613 let input = eval_expr_with_externs(in_, &snap, externs)?;
614 let output =
615 dispatcher
616 .dispatch(ref_, input)
617 .map_err(|e| EvalError::DispatcherError {
618 ref_: ref_.clone(),
619 msg: e.to_string(),
620 })?;
621 ctx.write(&path_of(out)?.to_string(), output)
622 }
623 Node::Seq { children } => {
624 for child in children {
625 eval_with_storage_externs(child, ctx, dispatcher, externs)?;
626 }
627 Ok(())
628 }
629 Node::Branch { cond, then_, else_ } => {
630 let snap = ctx.snapshot();
631 match eval_expr_with_externs(cond, &snap, externs)? {
632 Value::Bool(true) => eval_with_storage_externs(then_, ctx, dispatcher, externs),
633 Value::Bool(false) => eval_with_storage_externs(else_, ctx, dispatcher, externs),
634 other => Err(EvalError::NonBoolCond(other)),
635 }
636 }
637 Node::Fanout {
638 items,
639 bind,
640 body,
641 join,
642 out,
643 } => {
644 // Fanout fork = 各 branch を disjoint MemoryCtx に切り出して逐次
645 // (sync) evaluate、 集約結果を共有 ctx の `out` path に書く。
646 let snap = ctx.snapshot();
647 let items_val = eval_expr_with_externs(items, &snap, externs)?;
648 let items_arr = match items_val {
649 Value::Array(a) => a,
650 other => {
651 return Err(EvalError::TypeError {
652 op: "fanout.items".into(),
653 msg: format!("expected array, got {other:?}"),
654 })
655 }
656 };
657 let joined =
658 fanout_eval_sync(bind, body, *join, &snap, items_arr, dispatcher, externs)?;
659 ctx.write(&path_of(out)?.to_string(), joined)
660 }
661 Node::Loop {
662 counter,
663 cond,
664 body,
665 max,
666 } => {
667 let counter_path = path_of(counter)?.to_string();
668 ctx.write(&counter_path, Value::Number(serde_json::Number::from(0u32)))?;
669 let mut n: u32 = 0;
670 loop {
671 if n >= *max {
672 break;
673 }
674 let snap = ctx.snapshot();
675 if !is_truthy(&eval_expr_with_externs(cond, &snap, externs)?) {
676 break;
677 }
678 eval_with_storage_externs(body, ctx, dispatcher, externs)?;
679 n += 1;
680 ctx.write(&counter_path, Value::Number(serde_json::Number::from(n)))?;
681 }
682 Ok(())
683 }
684 Node::Try {
685 body,
686 catch,
687 err_at,
688 } => {
689 // body 失敗時の rollback 用 snapshot
690 let snap_before = ctx.snapshot();
691 match eval_with_storage_externs(body, ctx, dispatcher, externs) {
692 Ok(()) => Ok(()),
693 Err(e) => {
694 // body の途中 write を破棄 (Try semantic: rollback)
695 ctx.replace(snap_before);
696 if let Some(at) = err_at {
697 ctx.write(&path_of(at)?.to_string(), Value::String(e.to_string()))?;
698 }
699 eval_with_storage_externs(catch, ctx, dispatcher, externs)
700 }
701 }
702 }
703 Node::Assign { at, value } => {
704 let snap = ctx.snapshot();
705 let v = eval_expr_with_externs(value, &snap, externs)?;
706 ctx.write(&path_of(at)?.to_string(), v)
707 }
708 }
709}
710
711/// Internal: fanout per-item sync evaluator (disjoint branch ctx).
712fn fanout_eval_sync<D: Dispatcher>(
713 bind: &Expr,
714 body: &Node,
715 join: JoinMode,
716 base_snap: &Value,
717 items_arr: Vec<Value>,
718 dispatcher: &D,
719 externs: &dyn Externs,
720) -> Result<Value, EvalError> {
721 match join {
722 JoinMode::All => {
723 let mut results = Vec::with_capacity(items_arr.len());
724 for item in items_arr {
725 let branch_ctx = write_path(bind, base_snap.clone(), item)?;
726 let storage = MemoryCtx::new(branch_ctx);
727 eval_with_storage_externs(body, &storage, dispatcher, externs)?;
728 results.push(storage.snapshot());
729 }
730 Ok(Value::Array(results))
731 }
732 JoinMode::Any => {
733 // Promise.any parity: zero items can never produce a winner, so
734 // (unlike All/AllSettled, whose empty-array result shape is
735 // still meaningful) this raises rather than returning `[]`.
736 if items_arr.is_empty() {
737 return Err(EvalError::TypeError {
738 op: "fanout.any".into(),
739 msg: "requires at least one item".into(),
740 });
741 }
742 let mut winner: Option<Value> = None;
743 let mut last_err: Option<EvalError> = None;
744 for item in items_arr {
745 let branch_ctx = write_path(bind, base_snap.clone(), item)?;
746 let storage = MemoryCtx::new(branch_ctx);
747 match eval_with_storage_externs(body, &storage, dispatcher, externs) {
748 Ok(()) => {
749 winner = Some(storage.snapshot());
750 last_err = None;
751 break;
752 }
753 Err(e) => last_err = Some(e),
754 }
755 }
756 if let Some(e) = last_err {
757 return Err(e);
758 }
759 Ok(winner.expect("non-empty items_arr always assigns a winner or returns an error"))
760 }
761 JoinMode::Race => {
762 // Same rationale as Any: zero branches means there is nothing to
763 // race, so this raises rather than returning `[]`.
764 let Some(first) = items_arr.into_iter().next() else {
765 return Err(EvalError::TypeError {
766 op: "fanout.race".into(),
767 msg: "requires at least one item".into(),
768 });
769 };
770 let branch_ctx = write_path(bind, base_snap.clone(), first)?;
771 let storage = MemoryCtx::new(branch_ctx);
772 eval_with_storage_externs(body, &storage, dispatcher, externs)?;
773 Ok(storage.snapshot())
774 }
775 JoinMode::AllSettled => {
776 let mut records = Vec::with_capacity(items_arr.len());
777 for item in items_arr {
778 let branch_ctx = write_path(bind, base_snap.clone(), item)?;
779 let storage = MemoryCtx::new(branch_ctx);
780 match eval_with_storage_externs(body, &storage, dispatcher, externs) {
781 Ok(()) => records.push(
782 serde_json::json!({"status": "fulfilled", "value": storage.snapshot()}),
783 ),
784 Err(e) => records
785 .push(serde_json::json!({"status": "rejected", "reason": e.to_string()})),
786 }
787 }
788 Ok(Value::Array(records))
789 }
790 }
791}
792
793/// Legacy Value-passing sync evaluator — backward compat wrapper around
794/// `eval_with_storage` + `MemoryCtx`. `Value` を所有権で受け取り、 内部で
795/// `MemoryCtx::new(ctx)` を使って storage 版に委譲、 終了後の snapshot を返す。
796///
797/// 既存 caller (= dynamic injection を要求しない、 1-shot pure eval 用途) は
798/// 引き続きこの API で OK。 動的注入が要る場合は `eval_with_storage` を直接
799/// 呼ぶ。
800///
801/// Returns the updated context (= ctx with `Step.out` path written for each step traversed).
802pub fn eval<D: Dispatcher>(node: &Node, ctx: Value, dispatcher: &D) -> Result<Value, EvalError> {
803 eval_externs(node, ctx, dispatcher, &NoExterns)
804}
805
806/// `eval` + externs registry for `call_extern` Expr resolution.
807pub fn eval_externs<D: Dispatcher>(
808 node: &Node,
809 ctx: Value,
810 dispatcher: &D,
811 externs: &dyn Externs,
812) -> Result<Value, EvalError> {
813 let storage = MemoryCtx::new(ctx);
814 eval_with_storage_externs(node, &storage, dispatcher, externs)?;
815 Ok(storage.snapshot())
816}
817
818/// JSON value の truthy 判定 (= flow.ir Branch cond / Loop cond で使う)。
819/// Bool は値そのまま、 null/false 以外は truthy (Lua / JS と整合)。
820pub fn is_truthy(v: &Value) -> bool {
821 match v {
822 Value::Null => false,
823 Value::Bool(b) => *b,
824 _ => true,
825 }
826}
827
828/// Evaluate an `Expr` against a context value, returning the resolved JSON
829/// value. Externs-less compat wrapper — `call_extern` raises `ExternError`.
830pub fn eval_expr(expr: &Expr, ctx: &Value) -> Result<Value, EvalError> {
831 eval_expr_with_externs(expr, ctx, &NoExterns)
832}
833
834/// `eval_expr` + externs registry for `call_extern` Expr resolution.
835pub fn eval_expr_with_externs(
836 expr: &Expr,
837 ctx: &Value,
838 externs: &dyn Externs,
839) -> Result<Value, EvalError> {
840 let ev = |e: &Expr| eval_expr_with_externs(e, ctx, externs);
841 match expr {
842 Expr::Lit { value } => Ok(value.clone()),
843 // `at` is an already-parsed `Path` — no re-parse in this hot path.
844 Expr::Path { at } => at.read(ctx).cloned(),
845 Expr::Eq { lhs, rhs } => Ok(Value::Bool(json_eq(&ev(lhs)?, &ev(rhs)?))),
846 Expr::Ne { lhs, rhs } => Ok(Value::Bool(!json_eq(&ev(lhs)?, &ev(rhs)?))),
847 Expr::Lt { lhs, rhs } => ord_cmp(&ev(lhs)?, &ev(rhs)?, |o| o.is_lt()),
848 Expr::Lte { lhs, rhs } => ord_cmp(&ev(lhs)?, &ev(rhs)?, |o| o.is_le()),
849 Expr::Gt { lhs, rhs } => ord_cmp(&ev(lhs)?, &ev(rhs)?, |o| o.is_gt()),
850 Expr::Gte { lhs, rhs } => ord_cmp(&ev(lhs)?, &ev(rhs)?, |o| o.is_ge()),
851 Expr::Not { arg } => Ok(Value::Bool(!is_truthy(&ev(arg)?))),
852 Expr::And { args } => {
853 for a in args {
854 if !is_truthy(&ev(a)?) {
855 return Ok(Value::Bool(false));
856 }
857 }
858 Ok(Value::Bool(true))
859 }
860 Expr::Or { args } => {
861 for a in args {
862 if is_truthy(&ev(a)?) {
863 return Ok(Value::Bool(true));
864 }
865 }
866 Ok(Value::Bool(false))
867 }
868 Expr::Exists { arg } => match ev(arg) {
869 Ok(Value::Null) => Ok(Value::Bool(false)),
870 Ok(_) => Ok(Value::Bool(true)),
871 // canonical: a path to a missing key reads as nil → exists=false
872 Err(EvalError::PathNotFound(_)) => Ok(Value::Bool(false)),
873 Err(e) => Err(e),
874 },
875 Expr::Add { lhs, rhs } => num_arith(&ev(lhs)?, &ev(rhs)?, "add", |a, b| Some(a + b)),
876 Expr::Sub { lhs, rhs } => num_arith(&ev(lhs)?, &ev(rhs)?, "sub", |a, b| Some(a - b)),
877 Expr::Mul { lhs, rhs } => num_arith(&ev(lhs)?, &ev(rhs)?, "mul", |a, b| Some(a * b)),
878 Expr::Div { lhs, rhs } => num_arith(&ev(lhs)?, &ev(rhs)?, "div", |a, b| {
879 if b == 0.0 {
880 None
881 } else {
882 Some(a / b)
883 }
884 }),
885 // Lua `%` semantics (canonical): a - floor(a/b)*b, sign follows rhs.
886 Expr::Mod { lhs, rhs } => num_arith(&ev(lhs)?, &ev(rhs)?, "mod", |a, b| {
887 if b == 0.0 {
888 None
889 } else {
890 Some(a - (a / b).floor() * b)
891 }
892 }),
893 Expr::Len { arg } => {
894 let v = ev(arg)?;
895 let n = match &v {
896 Value::Array(a) => a.len(),
897 Value::String(s) => s.chars().count(),
898 Value::Object(o) => o.len(),
899 other => {
900 return Err(EvalError::TypeError {
901 op: "expr.len".into(),
902 msg: format!("len: unsupported type {other:?}"),
903 })
904 }
905 };
906 Ok(Value::Number(serde_json::Number::from(n as u64)))
907 }
908 Expr::In { needle, haystack } => {
909 let n = ev(needle)?;
910 let h = ev(haystack)?;
911 match h {
912 Value::Array(a) => Ok(Value::Bool(a.iter().any(|e| json_eq(e, &n)))),
913 other => Err(EvalError::TypeError {
914 op: "expr.in".into(),
915 msg: format!("in: haystack must be array, got {other:?}"),
916 }),
917 }
918 }
919 Expr::CallExtern { ref_, args } => {
920 let mut vals = Vec::with_capacity(args.len());
921 for a in args {
922 vals.push(ev(a)?);
923 }
924 externs.call(ref_, &vals)
925 }
926 }
927}
928
929/// Deep equality with Lua-parity numeric coercion: numbers compare by
930/// f64 value (so `5 == 5.0`), matching the coercion Lt/Lte/Gt/Gte use.
931/// Integers above 2^53 may lose precision — same caveat as ordering ops.
932fn json_eq(a: &Value, b: &Value) -> bool {
933 match (a, b) {
934 (Value::Number(na), Value::Number(nb)) => match (na.as_f64(), nb.as_f64()) {
935 (Some(fa), Some(fb)) => fa == fb,
936 // non-f64-representable (e.g. integers beyond 2^53): fall back
937 // to exact comparison rather than a lossy f64 round-trip.
938 _ => na == nb,
939 },
940 (Value::Array(aa), Value::Array(ab)) => {
941 aa.len() == ab.len() && aa.iter().zip(ab.iter()).all(|(x, y)| json_eq(x, y))
942 }
943 (Value::Object(oa), Value::Object(ob)) => {
944 oa.len() == ob.len()
945 && oa
946 .iter()
947 .all(|(k, v)| ob.get(k).is_some_and(|ov| json_eq(v, ov)))
948 }
949 _ => a == b,
950 }
951}
952
953/// Coerce a JSON value to f64 for numeric ops. A non-`Number` value is a
954/// `TypeError` (wrong JSON type); a `Number` that itself cannot be
955/// represented as `f64` (e.g. an integer beyond `f64`'s exact range) is an
956/// `ArithError` (right type, unrepresentable value).
957fn to_f64(v: &Value, op: &str) -> Result<f64, EvalError> {
958 match v {
959 Value::Number(n) => n.as_f64().ok_or_else(|| EvalError::ArithError {
960 op: op.into(),
961 msg: format!("non-f64-representable number: {n}"),
962 }),
963 other => Err(EvalError::TypeError {
964 op: op.into(),
965 msg: format!("expected number, got {other:?}"),
966 }),
967 }
968}
969
970/// Ordering comparison over two evaluated values. Mirrors canonical Lua
971/// `< / <= / > / >=`: both numbers (f64) or both strings (lexicographic
972/// byte order, same as Lua's string comparison for UTF-8); anything else
973/// raises.
974fn ord_cmp<F>(lv: &Value, rv: &Value, f: F) -> Result<Value, EvalError>
975where
976 F: Fn(std::cmp::Ordering) -> bool,
977{
978 let ord = match (lv, rv) {
979 (Value::Number(_), Value::Number(_)) => {
980 let l = to_f64(lv, "cmp")?;
981 let r = to_f64(rv, "cmp")?;
982 l.partial_cmp(&r).ok_or_else(|| EvalError::ArithError {
983 op: "cmp".into(),
984 msg: "non-comparable numbers (NaN)".into(),
985 })?
986 }
987 (Value::String(l), Value::String(r)) => l.cmp(r),
988 (l, r) => {
989 return Err(EvalError::TypeError {
990 op: "cmp".into(),
991 msg: format!("cmp: both sides must be numbers or strings, got {l:?} vs {r:?}"),
992 })
993 }
994 };
995 Ok(Value::Bool(f(ord)))
996}
997
998fn num_arith<F>(lv: &Value, rv: &Value, op: &str, f: F) -> Result<Value, EvalError>
999where
1000 F: Fn(f64, f64) -> Option<f64>,
1001{
1002 let l = to_f64(lv, op)?;
1003 let r = to_f64(rv, op)?;
1004 let result = f(l, r).ok_or_else(|| EvalError::ArithError {
1005 op: op.into(),
1006 msg: "arithmetic failure (e.g. division by zero)".into(),
1007 })?;
1008 let n = serde_json::Number::from_f64(result).ok_or_else(|| EvalError::ArithError {
1009 op: op.into(),
1010 msg: format!("result not f64-representable: {result}"),
1011 })?;
1012 Ok(Value::Number(n))
1013}
1014
1015// ──────────────────────────────────────────────────────────────────────────
1016// Path compat wrappers — thin `&str` entry points over the typed `Path` in
1017// `path.rs`, which is the single authority for path syntax + rejection
1018// rules. See [`Path`] docs for the full syntax (dot form, RFC 9535-style
1019// bracket notation, uniform malformed-path rejections).
1020// ──────────────────────────────────────────────────────────────────────────
1021
1022/// Parse `path` and read the value it resolves to inside `ctx`.
1023///
1024/// Thin wrapper: parses `path` via [`str::parse`] (surfacing a parse
1025/// failure as [`EvalError::InvalidPath`]) then delegates to [`Path::read`].
1026/// Callers holding an already-parsed `Path` (e.g. from an `Expr::Path`)
1027/// should call [`Path::read`] directly to avoid re-parsing.
1028pub fn read_path(path: &str, ctx: &Value) -> Result<Value, EvalError> {
1029 let parsed: Path = path
1030 .parse()
1031 .map_err(|e: PathParseError| EvalError::InvalidPath(e.to_string()))?;
1032 parsed.read(ctx).cloned()
1033}
1034
1035/// Write a value at the path location inside ctx, returning the updated ctx.
1036/// `out` must be a `Path` Expr (its `at` field is an already-parsed `Path`,
1037/// so no re-parsing happens here — this is a thin wrapper around
1038/// [`Path::write`], adapted to the `Value`-in/`Value`-out shape the rest of
1039/// this crate's legacy (non-`CtxStorage`) API uses).
1040pub fn write_path(out: &Expr, ctx: Value, value: Value) -> Result<Value, EvalError> {
1041 let path = path_of(out)?;
1042 let mut root = ctx;
1043 path.write(&mut root, value)?;
1044 Ok(root)
1045}