const FLOW_DSL_SRC: &str = include_str!("flow_dsl.lua");
const BP_DSL_SRC: &str = include_str!("bp_dsl.lua");
const EMPTY_OBJECT_MARKER_KEY: &str = "__mse_empty_object__";
fn replace_empty_object_markers(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
let is_marker = map.len() == 1
&& map.get(EMPTY_OBJECT_MARKER_KEY) == Some(&serde_json::Value::Bool(true));
if is_marker {
*value = serde_json::Value::Object(serde_json::Map::new());
return;
}
for v in map.values_mut() {
replace_empty_object_markers(v);
}
}
serde_json::Value::Array(arr) => {
for v in arr.iter_mut() {
replace_empty_object_markers(v);
}
}
_ => {}
}
}
pub fn preload(lua: &mlua::Lua) -> mlua::Result<()> {
let package: mlua::Table = lua.globals().get("package")?;
let preload: mlua::Table = package.get("preload")?;
preload.set(
"flow_dsl",
lua.create_function(|lua, ()| {
lua.load(FLOW_DSL_SRC)
.set_name("flow_dsl.lua")
.eval::<mlua::Value>()
})?,
)?;
preload.set(
"bp_dsl",
lua.create_function(|lua, ()| {
lua.load(BP_DSL_SRC)
.set_name("bp_dsl.lua")
.eval::<mlua::Value>()
})?,
)?;
Ok(())
}
pub fn build_bp_from_script(script: &str) -> anyhow::Result<serde_json::Value> {
use mlua::LuaSerdeExt;
let lua = mlua::Lua::new();
preload(&lua).map_err(|e| anyhow::anyhow!("dsl preload failed: {e}"))?;
let result: mlua::Value = lua
.load(script)
.set_name("<bp-script>")
.eval()
.map_err(|e| anyhow::anyhow!("bp-script eval failed: {e}"))?;
let options = mlua::serde::de::Options::new().encode_empty_tables_as_array(true);
let mut value: serde_json::Value = lua
.from_value_with(result, options)
.map_err(|e| anyhow::anyhow!("lua value -> json conversion failed: {e}"))?;
replace_empty_object_markers(&mut value);
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preload_exposes_flow_dsl_and_bp_dsl() {
let lua = mlua::Lua::new();
preload(&lua).expect("preload must succeed");
let ok: bool = lua
.load(
r#"
local F = require("flow_dsl")
local B = require("bp_dsl")
return F ~= nil and B ~= nil
"#,
)
.eval()
.expect("require must succeed for both modules");
assert!(ok, "flow_dsl / bp_dsl must both resolve via require()");
}
#[test]
fn build_bp_from_script_returns_json_value() {
let out = build_bp_from_script(
r#"
local F = require("flow_dsl")
return { id = "t", flow = F.assign{ at = F.p("$.x"), value = F.lit(1) } }
"#,
)
.expect("script must build");
assert_eq!(out["id"], serde_json::json!("t"));
assert_eq!(out["flow"]["kind"], serde_json::json!("assign"));
assert_eq!(
out["flow"]["at"],
serde_json::json!({"op": "path", "at": "$.x"})
);
}
#[test]
fn build_bp_from_script_surfaces_lua_errors() {
let err = build_bp_from_script("error(\"boom\")").expect_err("must propagate the error");
assert!(err.to_string().contains("boom"));
}
#[test]
fn f_obj_marker_becomes_a_genuine_empty_json_object() {
let out = build_bp_from_script(
r#"
local F = require("flow_dsl")
return { spec = F.obj(), other = {} }
"#,
)
.expect("script must build");
assert_eq!(out["spec"], serde_json::json!({}));
assert!(
out["spec"].is_object(),
"F.obj() must become an object, not an array"
);
assert_eq!(out["other"], serde_json::json!([]));
}
#[test]
fn empty_object_marker_replacement_does_not_misfire_on_ordinary_data() {
let out = build_bp_from_script(
r#"
return {
a = { __mse_empty_object__ = false },
b = { __mse_empty_object__ = true, extra = 1 },
}
"#,
)
.expect("script must build");
assert_eq!(out["a"], serde_json::json!({"__mse_empty_object__": false}));
assert_eq!(
out["b"],
serde_json::json!({"__mse_empty_object__": true, "extra": 1})
);
}
}