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 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
13const FLOW_DSL_SRC: &str = include_str!("flow_dsl.lua");
14const BP_DSL_SRC: &str = include_str!("bp_dsl.lua");
15
16/// The wire-level key `F.obj()` (`flow_dsl.lua`) emits — must match the
17/// Lua-side `M.EMPTY_OBJECT_MARKER_KEY` literal exactly.
18const EMPTY_OBJECT_MARKER_KEY: &str = "__mse_empty_object__";
19
20/// Walk `value` in place and replace every JSON object shaped exactly
21/// like `{ "<EMPTY_OBJECT_MARKER_KEY>": true }` (the wire shape `F.obj()`
22/// emits) with a genuine empty JSON object (`{}`). Limited to that exact
23/// single-key shape so an ordinary data field that happens to carry a key
24/// with the same name is left untouched.
25fn replace_empty_object_markers(value: &mut serde_json::Value) {
26 match value {
27 serde_json::Value::Object(map) => {
28 let is_marker = map.len() == 1
29 && map.get(EMPTY_OBJECT_MARKER_KEY) == Some(&serde_json::Value::Bool(true));
30 if is_marker {
31 *value = serde_json::Value::Object(serde_json::Map::new());
32 return;
33 }
34 for v in map.values_mut() {
35 replace_empty_object_markers(v);
36 }
37 }
38 serde_json::Value::Array(arr) => {
39 for v in arr.iter_mut() {
40 replace_empty_object_markers(v);
41 }
42 }
43 _ => {}
44 }
45}
46
47/// Register `flow_dsl` and `bp_dsl` in `lua`'s `package.preload` table so
48/// `require("flow_dsl")` / `require("bp_dsl")` resolve to the baked-in Lua
49/// source. Idempotent to call more than once on the same `Lua` (each call
50/// simply re-sets the same two `preload` entries).
51pub fn preload(lua: &mlua::Lua) -> mlua::Result<()> {
52 let package: mlua::Table = lua.globals().get("package")?;
53 let preload: mlua::Table = package.get("preload")?;
54
55 preload.set(
56 "flow_dsl",
57 lua.create_function(|lua, ()| {
58 lua.load(FLOW_DSL_SRC)
59 .set_name("flow_dsl.lua")
60 .eval::<mlua::Value>()
61 })?,
62 )?;
63 preload.set(
64 "bp_dsl",
65 lua.create_function(|lua, ()| {
66 lua.load(BP_DSL_SRC)
67 .set_name("bp_dsl.lua")
68 .eval::<mlua::Value>()
69 })?,
70 )?;
71 Ok(())
72}
73
74/// Run a `.bp.lua` DSL script (source text, not a file path) in a fresh
75/// `mlua::Lua` VM and return its result as `serde_json::Value`.
76///
77/// The script is expected to `require("flow_dsl")` and/or
78/// `require("bp_dsl")` and `return` a Blueprint-shaped (or Expr/Node
79/// -shaped, for narrower scripts) Lua table as its last expression.
80///
81/// Empty Lua tables are treated as empty JSON arrays rather than empty
82/// objects (`encode_empty_tables_as_array`) — every plain empty table this
83/// DSL can emit is a `Node`/`Expr` list field (`seq.children`, `and.args`,
84/// `or.args`), never a legitimately-empty JSON object. A field that must
85/// serialize as an empty JSON object uses the `F.obj()` marker
86/// (`flow_dsl.lua`) instead of a bare `{}` table literal; this function
87/// replaces every occurrence of that marker with a genuine empty JSON
88/// object as a post-pass (`replace_empty_object_markers`) over the
89/// converted value.
90pub fn build_bp_from_script(script: &str) -> anyhow::Result<serde_json::Value> {
91 use mlua::LuaSerdeExt;
92
93 // `mlua::Error` wraps a boxed `dyn std::error::Error` without a
94 // `Send + Sync` bound, so it does not satisfy anyhow's blanket `From`
95 // impl (`?` cannot convert it directly) — stringify explicitly instead.
96 let lua = mlua::Lua::new();
97 preload(&lua).map_err(|e| anyhow::anyhow!("dsl preload failed: {e}"))?;
98 let result: mlua::Value = lua
99 .load(script)
100 .set_name("<bp-script>")
101 .eval()
102 .map_err(|e| anyhow::anyhow!("bp-script eval failed: {e}"))?;
103 let options = mlua::serde::de::Options::new().encode_empty_tables_as_array(true);
104 let mut value: serde_json::Value = lua
105 .from_value_with(result, options)
106 .map_err(|e| anyhow::anyhow!("lua value -> json conversion failed: {e}"))?;
107 replace_empty_object_markers(&mut value);
108 Ok(value)
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn preload_exposes_flow_dsl_and_bp_dsl() {
117 let lua = mlua::Lua::new();
118 preload(&lua).expect("preload must succeed");
119 let ok: bool = lua
120 .load(
121 r#"
122 local F = require("flow_dsl")
123 local B = require("bp_dsl")
124 return F ~= nil and B ~= nil
125 "#,
126 )
127 .eval()
128 .expect("require must succeed for both modules");
129 assert!(ok, "flow_dsl / bp_dsl must both resolve via require()");
130 }
131
132 #[test]
133 fn build_bp_from_script_returns_json_value() {
134 let out = build_bp_from_script(
135 r#"
136 local F = require("flow_dsl")
137 return { id = "t", flow = F.assign{ at = F.p("$.x"), value = F.lit(1) } }
138 "#,
139 )
140 .expect("script must build");
141 assert_eq!(out["id"], serde_json::json!("t"));
142 assert_eq!(out["flow"]["kind"], serde_json::json!("assign"));
143 assert_eq!(
144 out["flow"]["at"],
145 serde_json::json!({"op": "path", "at": "$.x"})
146 );
147 }
148
149 #[test]
150 fn build_bp_from_script_surfaces_lua_errors() {
151 let err = build_bp_from_script("error(\"boom\")").expect_err("must propagate the error");
152 assert!(err.to_string().contains("boom"));
153 }
154
155 #[test]
156 fn f_obj_marker_becomes_a_genuine_empty_json_object() {
157 let out = build_bp_from_script(
158 r#"
159 local F = require("flow_dsl")
160 return { spec = F.obj(), other = {} }
161 "#,
162 )
163 .expect("script must build");
164 assert_eq!(out["spec"], serde_json::json!({}));
165 assert!(
166 out["spec"].is_object(),
167 "F.obj() must become an object, not an array"
168 );
169 // A plain empty Lua table is still converted to an empty JSON
170 // array (the pre-existing `encode_empty_tables_as_array` rule),
171 // proving the marker replacement is scoped to `F.obj()`'s exact
172 // one-key shape and does not affect ordinary empty tables.
173 assert_eq!(out["other"], serde_json::json!([]));
174 }
175
176 #[test]
177 fn empty_object_marker_replacement_does_not_misfire_on_ordinary_data() {
178 // A field that legitimately reuses the marker key name for
179 // something other than `true` (or carries sibling keys) must not
180 // be collapsed to `{}`.
181 let out = build_bp_from_script(
182 r#"
183 return {
184 a = { __mse_empty_object__ = false },
185 b = { __mse_empty_object__ = true, extra = 1 },
186 }
187 "#,
188 )
189 .expect("script must build");
190 assert_eq!(out["a"], serde_json::json!({"__mse_empty_object__": false}));
191 assert_eq!(
192 out["b"],
193 serde_json::json!({"__mse_empty_object__": true, "extra": 1})
194 );
195 }
196}