Skip to main content

mlua_swarm_dsl/
lib.rs

1//! Lua internal DSL for Blueprint authoring (`flow_dsl` + `bp_dsl`).
2//!
3//! Raw AST (JSON) authoring cost is high at real Blueprint scale (hundreds
4//! of lines and deep nesting for a multi-stage flow) — that cost motivated
5//! this module. `flow_dsl.lua` (flow.ir vocabulary) and `bp_dsl.lua`
6//! (Blueprint vocabulary, depends on `flow_dsl`) are baked into this
7//! binary via `include_str!` and preloaded into a fresh `mlua::Lua` VM so
8//! `require("flow_dsl")` / `require("bp_dsl")` resolve without touching
9//! the filesystem. The `flow-ir` / `mlua-swarm-schema` crates are not
10//! touched by this module — canonical JSON stays the wire format; the DSL
11//! is purely an authoring-time convenience that emits it.
12//!
13//! # Crate positioning
14//!
15//! This crate is the DSL frontend only (`.bp.lua` → `serde_json::Value`).
16//! The compile pipeline (linker → shape lint → BPReady) lives in the
17//! sibling `mlua-swarm-compile` crate, which consumes the JSON this crate
18//! produces. `mlua-swarm-schema` (types) stays free of the `mlua` runtime
19//! dep so that consumers who only need type surfaces (e.g. the server's
20//! wire codec) do not transitively pull the Lua interpreter.
21
22const FLOW_DSL_SRC: &str = include_str!("flow_dsl.lua");
23const BP_DSL_SRC: &str = include_str!("bp_dsl.lua");
24
25/// The wire-level key `F.obj()` (`flow_dsl.lua`) emits — must match the
26/// Lua-side `M.EMPTY_OBJECT_MARKER_KEY` literal exactly.
27const EMPTY_OBJECT_MARKER_KEY: &str = "__mse_empty_object__";
28
29/// Walk `value` in place and replace every JSON object shaped exactly
30/// like `{ "<EMPTY_OBJECT_MARKER_KEY>": true }` (the wire shape `F.obj()`
31/// emits) with a genuine empty JSON object (`{}`). Limited to that exact
32/// single-key shape so an ordinary data field that happens to carry a key
33/// with the same name is left untouched.
34fn replace_empty_object_markers(value: &mut serde_json::Value) {
35    match value {
36        serde_json::Value::Object(map) => {
37            let is_marker = map.len() == 1
38                && map.get(EMPTY_OBJECT_MARKER_KEY) == Some(&serde_json::Value::Bool(true));
39            if is_marker {
40                *value = serde_json::Value::Object(serde_json::Map::new());
41                return;
42            }
43            for v in map.values_mut() {
44                replace_empty_object_markers(v);
45            }
46        }
47        serde_json::Value::Array(arr) => {
48            for v in arr.iter_mut() {
49                replace_empty_object_markers(v);
50            }
51        }
52        _ => {}
53    }
54}
55
56/// Register `flow_dsl` and `bp_dsl` in `lua`'s `package.preload` table so
57/// `require("flow_dsl")` / `require("bp_dsl")` resolve to the baked-in Lua
58/// source. Idempotent to call more than once on the same `Lua` (each call
59/// simply re-sets the same two `preload` entries).
60pub fn preload(lua: &mlua::Lua) -> mlua::Result<()> {
61    let package: mlua::Table = lua.globals().get("package")?;
62    let preload: mlua::Table = package.get("preload")?;
63
64    preload.set(
65        "flow_dsl",
66        lua.create_function(|lua, ()| {
67            lua.load(FLOW_DSL_SRC)
68                .set_name("flow_dsl.lua")
69                .eval::<mlua::Value>()
70        })?,
71    )?;
72    preload.set(
73        "bp_dsl",
74        lua.create_function(|lua, ()| {
75            lua.load(BP_DSL_SRC)
76                .set_name("bp_dsl.lua")
77                .eval::<mlua::Value>()
78        })?,
79    )?;
80    Ok(())
81}
82
83/// Run a `.bp.lua` DSL script (source text, not a file path) in a fresh
84/// `mlua::Lua` VM and return its result as `serde_json::Value`.
85///
86/// The script is expected to `require("flow_dsl")` and/or
87/// `require("bp_dsl")` and `return` a Blueprint-shaped (or Expr/Node
88/// -shaped, for narrower scripts) Lua table as its last expression.
89///
90/// Empty Lua tables are treated as empty JSON arrays rather than empty
91/// objects (`encode_empty_tables_as_array`) — every plain empty table this
92/// DSL can emit is a `Node`/`Expr` list field (`seq.children`, `and.args`,
93/// `or.args`), never a legitimately-empty JSON object. A field that must
94/// serialize as an empty JSON object uses the `F.obj()` marker
95/// (`flow_dsl.lua`) instead of a bare `{}` table literal; this function
96/// replaces every occurrence of that marker with a genuine empty JSON
97/// object as a post-pass (`replace_empty_object_markers`) over the
98/// converted value.
99pub fn build_bp_from_script(script: &str) -> anyhow::Result<serde_json::Value> {
100    use mlua::LuaSerdeExt;
101
102    // `mlua::Error` wraps a boxed `dyn std::error::Error` without a
103    // `Send + Sync` bound, so it does not satisfy anyhow's blanket `From`
104    // impl (`?` cannot convert it directly) — stringify explicitly instead.
105    let lua = mlua::Lua::new();
106    preload(&lua).map_err(|e| anyhow::anyhow!("dsl preload failed: {e}"))?;
107    let result: mlua::Value = lua
108        .load(script)
109        .set_name("<bp-script>")
110        .eval()
111        .map_err(|e| anyhow::anyhow!("bp-script eval failed: {e}"))?;
112    let options = mlua::serde::de::Options::new().encode_empty_tables_as_array(true);
113    let mut value: serde_json::Value = lua
114        .from_value_with(result, options)
115        .map_err(|e| anyhow::anyhow!("lua value -> json conversion failed: {e}"))?;
116    replace_empty_object_markers(&mut value);
117    Ok(value)
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn preload_exposes_flow_dsl_and_bp_dsl() {
126        let lua = mlua::Lua::new();
127        preload(&lua).expect("preload must succeed");
128        let ok: bool = lua
129            .load(
130                r#"
131                local F = require("flow_dsl")
132                local B = require("bp_dsl")
133                return F ~= nil and B ~= nil
134                "#,
135            )
136            .eval()
137            .expect("require must succeed for both modules");
138        assert!(ok, "flow_dsl / bp_dsl must both resolve via require()");
139    }
140
141    #[test]
142    fn build_bp_from_script_returns_json_value() {
143        let out = build_bp_from_script(
144            r#"
145            local F = require("flow_dsl")
146            return { id = "t", flow = F.assign{ at = F.p("$.x"), value = F.lit(1) } }
147            "#,
148        )
149        .expect("script must build");
150        assert_eq!(out["id"], serde_json::json!("t"));
151        assert_eq!(out["flow"]["kind"], serde_json::json!("assign"));
152        assert_eq!(
153            out["flow"]["at"],
154            serde_json::json!({"op": "path", "at": "$.x"})
155        );
156    }
157
158    #[test]
159    fn build_bp_from_script_surfaces_lua_errors() {
160        let err = build_bp_from_script("error(\"boom\")").expect_err("must propagate the error");
161        assert!(err.to_string().contains("boom"));
162    }
163
164    #[test]
165    fn f_obj_marker_becomes_a_genuine_empty_json_object() {
166        let out = build_bp_from_script(
167            r#"
168            local F = require("flow_dsl")
169            return { spec = F.obj(), other = {} }
170            "#,
171        )
172        .expect("script must build");
173        assert_eq!(out["spec"], serde_json::json!({}));
174        assert!(
175            out["spec"].is_object(),
176            "F.obj() must become an object, not an array"
177        );
178        // A plain empty Lua table is still converted to an empty JSON
179        // array (the pre-existing `encode_empty_tables_as_array` rule),
180        // proving the marker replacement is scoped to `F.obj()`'s exact
181        // one-key shape and does not affect ordinary empty tables.
182        assert_eq!(out["other"], serde_json::json!([]));
183    }
184
185    /// GH #76 DSL sugar: `skip_on = { "SKIP", ... }` on a stage record wraps
186    /// the stage's own body (step + optional retry loop) in a Branch
187    /// whose `cond` is `in(<input>.parts["verdict"], <skip_on_list>)`.
188    /// When the skip check hits, the stage body is elided (`then` =
189    /// empty `Seq`); the gate/rest chain continues unchanged.
190    #[test]
191    fn bp_dsl_skip_on_compiles_to_branch_in_verdict_skip_on_list() {
192        let out = build_bp_from_script(
193            r#"
194            local F = require("flow_dsl")
195            local B = require("bp_dsl")
196            return B.pipeline({
197              B.stage "gate" { agent = "mock-gate" },
198              B.stage "worker" {
199                agent = "mock-worker",
200                input = B.from "gate",
201                skip_on = { "SKIP", "NOT_APPLICABLE" },
202              },
203              halted_at = "$.halted_at",
204            })
205            "#,
206        )
207        .expect("skip_on pipeline must build");
208
209        // Top-level seq: [gate_step, rest].
210        assert_eq!(out["kind"], serde_json::json!("seq"));
211        let top_children = out["children"].as_array().expect("top seq children");
212        assert_eq!(top_children.len(), 2);
213        assert_eq!(top_children[0]["kind"], serde_json::json!("step"));
214        assert_eq!(top_children[0]["ref"], serde_json::json!("mock-gate"));
215
216        // `rest` = worker stage's compiled form. With skip_on, the
217        // stage's body is wrapped in a branch whose cond is `in(...)`.
218        let rest = &top_children[1];
219        assert_eq!(rest["kind"], serde_json::json!("seq"));
220        let rest_children = rest["children"].as_array().expect("rest seq children");
221        // No gate/rest chain past worker (last stage, no halt_on / retry).
222        let worker_guarded = &rest_children[0];
223        assert_eq!(worker_guarded["kind"], serde_json::json!("branch"));
224
225        // cond: in(needle=path("$.gate.parts[\"verdict\"]"),
226        //         haystack=lit(["SKIP", "NOT_APPLICABLE"])).
227        let cond = &worker_guarded["cond"];
228        assert_eq!(cond["op"], serde_json::json!("in"));
229        assert_eq!(
230            cond["needle"],
231            serde_json::json!({"op": "path", "at": "$.gate.parts[\"verdict\"]"})
232        );
233        assert_eq!(cond["haystack"]["op"], serde_json::json!("lit"));
234        assert_eq!(
235            cond["haystack"]["value"],
236            serde_json::json!(["SKIP", "NOT_APPLICABLE"])
237        );
238
239        // then = empty seq (skip elides body).
240        assert_eq!(
241            worker_guarded["then"],
242            serde_json::json!({"kind": "seq", "children": []})
243        );
244
245        // else = the original stage body (just the worker step here —
246        // no retry, no gate).
247        let body = &worker_guarded["else"];
248        assert_eq!(body["kind"], serde_json::json!("seq"));
249        assert_eq!(body["children"][0]["ref"], serde_json::json!("mock-worker"));
250    }
251
252    /// GH #76 DSL sugar: `skip_on` may coexist with `halt_on` on the same
253    /// stage — the skip guard wraps the stage's OWN body (step +
254    /// optional retry loop) and sits INSIDE the enclosing gate/rest
255    /// chain, so a skipped stage still lets `halt_on`'s gate cond be
256    /// evaluated against the (absent) `<out>` and thread through to
257    /// `rest`.
258    #[test]
259    fn bp_dsl_skip_on_coexists_with_halt_on() {
260        let out = build_bp_from_script(
261            r#"
262            local F = require("flow_dsl")
263            local B = require("bp_dsl")
264            return B.pipeline({
265              B.stage "planner" { agent = "mock-planner" },
266              B.stage "worker" {
267                agent = "mock-worker",
268                input = B.from "planner",
269                skip_on = { "SKIP" },
270                halt_on = { "BLOCKED" },
271              },
272              B.stage "publisher" { agent = "mock-publisher" },
273              halted_at = "$.halted_at",
274            })
275            "#,
276        )
277        .expect("skip_on + halt_on pipeline must build");
278
279        // Walk to the worker stage. Structure: top seq -> [planner,
280        // rest]; rest = seq -> [worker_body, gate]; worker_body =
281        // branch (skip guard).
282        let rest = &out["children"][1];
283        let worker_seq = rest;
284        assert_eq!(worker_seq["kind"], serde_json::json!("seq"));
285        let worker_children = worker_seq["children"]
286            .as_array()
287            .expect("worker seq children");
288        assert_eq!(
289            worker_children.len(),
290            2,
291            "skip guard + halt_on gate (with publisher threaded into gate else)"
292        );
293
294        // Child 0 = skip guard branch (skip_on).
295        let skip_branch = &worker_children[0];
296        assert_eq!(skip_branch["kind"], serde_json::json!("branch"));
297        assert_eq!(skip_branch["cond"]["op"], serde_json::json!("in"));
298
299        // Child 1 = halt_on gate (`branch`) whose cond is `eq` against
300        // the current stage's own out.parts["verdict"].
301        let halt_gate = &worker_children[1];
302        assert_eq!(halt_gate["kind"], serde_json::json!("branch"));
303        assert_eq!(halt_gate["cond"]["op"], serde_json::json!("eq"));
304        assert_eq!(
305            halt_gate["cond"]["lhs"],
306            serde_json::json!({"op": "path", "at": "$.worker.parts[\"verdict\"]"})
307        );
308        // gate's else is publisher's compiled form (the pipeline tail).
309        let gate_else = &halt_gate["else"];
310        assert_eq!(gate_else["kind"], serde_json::json!("seq"));
311        // publisher's step should be somewhere in that seq's children.
312        let contains_publisher = gate_else["children"]
313            .as_array()
314            .map(|arr| {
315                arr.iter()
316                    .any(|c| c["ref"] == serde_json::json!("mock-publisher"))
317            })
318            .unwrap_or(false);
319        assert!(
320            contains_publisher,
321            "halt_on gate else must thread the publisher stage through: {gate_else}"
322        );
323    }
324
325    /// GH #76 DSL sugar: `skip_on = {}` is a no-op (equivalent to omitting
326    /// the option). No branch is emitted, the stage compiles exactly
327    /// as if `skip_on` were absent.
328    #[test]
329    fn bp_dsl_skip_on_empty_list_is_noop() {
330        let with_empty = build_bp_from_script(
331            r#"
332            local B = require("bp_dsl")
333            return B.pipeline({
334              B.stage "worker" { agent = "mock-worker", skip_on = {} },
335              halted_at = "$.halted_at",
336            })
337            "#,
338        )
339        .expect("skip_on={} pipeline must build");
340
341        // Without any gate, retry, or a firing skip_on, the pipeline
342        // compiles to [step, <final_else>] (no branch wrapping).
343        let children = with_empty["children"].as_array().expect("seq children");
344        assert_eq!(
345            children.len(),
346            2,
347            "no skip guard emitted for empty skip_on: {with_empty}"
348        );
349        assert_eq!(children[0]["kind"], serde_json::json!("step"));
350        assert_eq!(children[0]["ref"], serde_json::json!("mock-worker"));
351
352        // Byte-identical to the same script without skip_on.
353        let baseline = build_bp_from_script(
354            r#"
355            local B = require("bp_dsl")
356            return B.pipeline({
357              B.stage "worker" { agent = "mock-worker" },
358              halted_at = "$.halted_at",
359            })
360            "#,
361        )
362        .expect("baseline pipeline must build");
363        assert_eq!(with_empty, baseline, "skip_on = {{}} must be a no-op");
364    }
365
366    #[test]
367    fn empty_object_marker_replacement_does_not_misfire_on_ordinary_data() {
368        // A field that legitimately reuses the marker key name for
369        // something other than `true` (or carries sibling keys) must not
370        // be collapsed to `{}`.
371        let out = build_bp_from_script(
372            r#"
373            return {
374              a = { __mse_empty_object__ = false },
375              b = { __mse_empty_object__ = true, extra = 1 },
376            }
377            "#,
378        )
379        .expect("script must build");
380        assert_eq!(out["a"], serde_json::json!({"__mse_empty_object__": false}));
381        assert_eq!(
382            out["b"],
383            serde_json::json!({"__mse_empty_object__": true, "extra": 1})
384        );
385    }
386}