Skip to main content

mlua_swarm_cli/dsl/
mod.rs

1//! Lua internal DSL for Blueprint authoring (`flow_dsl` + `bp_dsl`).
2//!
3//! Raw AST (JSON) authoring cost is high at real BP scale — see the
4//! `flow-dsl` design issue for the phase-b/c flow numbers that 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
13const FLOW_DSL_SRC: &str = include_str!("flow_dsl.lua");
14const BP_DSL_SRC: &str = include_str!("bp_dsl.lua");
15
16/// Register `flow_dsl` and `bp_dsl` in `lua`'s `package.preload` table so
17/// `require("flow_dsl")` / `require("bp_dsl")` resolve to the baked-in Lua
18/// source. Idempotent to call more than once on the same `Lua` (each call
19/// simply re-sets the same two `preload` entries).
20pub fn preload(lua: &mlua::Lua) -> mlua::Result<()> {
21    let package: mlua::Table = lua.globals().get("package")?;
22    let preload: mlua::Table = package.get("preload")?;
23
24    preload.set(
25        "flow_dsl",
26        lua.create_function(|lua, ()| {
27            lua.load(FLOW_DSL_SRC)
28                .set_name("flow_dsl.lua")
29                .eval::<mlua::Value>()
30        })?,
31    )?;
32    preload.set(
33        "bp_dsl",
34        lua.create_function(|lua, ()| {
35            lua.load(BP_DSL_SRC)
36                .set_name("bp_dsl.lua")
37                .eval::<mlua::Value>()
38        })?,
39    )?;
40    Ok(())
41}
42
43/// Run a `.bp.lua` DSL script (source text, not a file path) in a fresh
44/// `mlua::Lua` VM and return its result as `serde_json::Value`.
45///
46/// The script is expected to `require("flow_dsl")` and/or
47/// `require("bp_dsl")` and `return` a Blueprint-shaped (or Expr/Node
48/// -shaped, for narrower scripts) Lua table as its last expression.
49///
50/// Empty Lua tables are treated as empty JSON arrays rather than empty
51/// objects (`encode_empty_tables_as_array`) — every empty table this DSL
52/// can emit is a `Node`/`Expr` list field (`seq.children`, `and.args`,
53/// `or.args`), never a legitimately-empty JSON object, so this is safe
54/// for every shape this module produces.
55pub fn build_bp_from_script(script: &str) -> anyhow::Result<serde_json::Value> {
56    use mlua::LuaSerdeExt;
57
58    // `mlua::Error` wraps a boxed `dyn std::error::Error` without a
59    // `Send + Sync` bound, so it does not satisfy anyhow's blanket `From`
60    // impl (`?` cannot convert it directly) — stringify explicitly instead.
61    let lua = mlua::Lua::new();
62    preload(&lua).map_err(|e| anyhow::anyhow!("dsl preload failed: {e}"))?;
63    let result: mlua::Value = lua
64        .load(script)
65        .set_name("<bp-script>")
66        .eval()
67        .map_err(|e| anyhow::anyhow!("bp-script eval failed: {e}"))?;
68    let options = mlua::serde::de::Options::new().encode_empty_tables_as_array(true);
69    let value: serde_json::Value = lua
70        .from_value_with(result, options)
71        .map_err(|e| anyhow::anyhow!("lua value -> json conversion failed: {e}"))?;
72    Ok(value)
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn preload_exposes_flow_dsl_and_bp_dsl() {
81        let lua = mlua::Lua::new();
82        preload(&lua).expect("preload must succeed");
83        let ok: bool = lua
84            .load(
85                r#"
86                local F = require("flow_dsl")
87                local B = require("bp_dsl")
88                return F ~= nil and B ~= nil
89                "#,
90            )
91            .eval()
92            .expect("require must succeed for both modules");
93        assert!(ok, "flow_dsl / bp_dsl must both resolve via require()");
94    }
95
96    #[test]
97    fn build_bp_from_script_returns_json_value() {
98        let out = build_bp_from_script(
99            r#"
100            local F = require("flow_dsl")
101            return { id = "t", flow = F.assign{ at = F.p("$.x"), value = F.lit(1) } }
102            "#,
103        )
104        .expect("script must build");
105        assert_eq!(out["id"], serde_json::json!("t"));
106        assert_eq!(out["flow"]["kind"], serde_json::json!("assign"));
107        assert_eq!(
108            out["flow"]["at"],
109            serde_json::json!({"op": "path", "at": "$.x"})
110        );
111    }
112
113    #[test]
114    fn build_bp_from_script_surfaces_lua_errors() {
115        let err = build_bp_from_script("error(\"boom\")").expect_err("must propagate the error");
116        assert!(err.to_string().contains("boom"));
117    }
118}