simple_editor/
scripting.rs

1//! Embedded Luau scripting: `.luau` files in the scripts folder drive the editor through the same
2//! tool catalogue the MCP server exposes (`editor.tool("timeline.add_clip", {...})`). The VM is
3//! sandboxed (no io/os/ffi) and interrupted after a wall-clock budget so a runaway loop cannot hang
4//! the UI. Scripts run on the UI thread against the live project; the app wraps each run in one
5//! undo step.
6
7use serde_json::Value;
8use std::path::PathBuf;
9use std::time::{Duration, Instant};
10
11/// Wall-clock budget for one script run (it executes on the UI thread).
12const BUDGET: Duration = Duration::from_secs(5);
13
14pub fn scripts_dir() -> PathBuf {
15    crate::settings::Settings::dir().join("scripts")
16}
17
18/// Every `.luau` file in the scripts folder, sorted by name. Creates the folder (and a starter
19/// example) the first time it is asked for.
20pub fn list() -> Vec<PathBuf> {
21    let dir = scripts_dir();
22    if !dir.exists() {
23        let _ = std::fs::create_dir_all(&dir);
24        let _ = std::fs::write(dir.join("example.luau"), EXAMPLE);
25    }
26    let mut out: Vec<PathBuf> = std::fs::read_dir(&dir)
27        .into_iter()
28        .flatten()
29        .flatten()
30        .map(|e| e.path())
31        .filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("luau")))
32        .collect();
33    out.sort();
34    out
35}
36
37const EXAMPLE: &str = r#"-- Simple Editor script. `editor.tool(name, args)` calls the same tools the MCP server exposes;
38-- `editor.tools()` lists them; `editor.log(text)` shows a toast.
39local s = editor.tool("project.summary", {})
40editor.log("Project: " .. tostring(s.duration or "?") .. " s, " .. tostring(#(s.tracks or {})) .. " tracks")
41"#;
42
43/// Run `src` with an `editor` global. `call` executes one tool against the live project and is
44/// invoked re-entrantly from inside the VM; `logs` collects `editor.log` lines for the app to show.
45pub fn run(
46    src: &str,
47    chunk_name: &str,
48    call: &mut dyn FnMut(&str, &Value) -> Result<Value, String>,
49    logs: &mut Vec<String>,
50) -> Result<(), String> {
51    let lua = mlua::Lua::new();
52    lua.sandbox(true).map_err(|e| e.to_string())?;
53    let start = Instant::now();
54    lua.set_interrupt(move |_| {
55        if start.elapsed() > BUDGET {
56            Err(mlua::Error::runtime("script took too long (5 s budget)"))
57        } else {
58            Ok(mlua::VmState::Continue)
59        }
60    });
61    let call = std::cell::RefCell::new(call);
62    let logs = std::cell::RefCell::new(logs);
63    lua.scope(|scope| {
64        let editor = lua.create_table()?;
65        editor.set(
66            "tool",
67            scope.create_function(|lua, (name, args): (String, Option<mlua::Table>)| {
68                let args = match args {
69                    Some(t) => lua_to_json(mlua::Value::Table(t))?,
70                    None => Value::Object(Default::default()),
71                };
72                let r = (call.borrow_mut())(&name, &args).map_err(mlua::Error::runtime)?;
73                json_to_lua(lua, &r)
74            })?,
75        )?;
76        editor.set(
77            "tools",
78            scope.create_function(|lua, ()| {
79                let t = lua.create_table()?;
80                for (i, (name, desc, _)) in crate::mcp::tools::TOOLS.iter().enumerate() {
81                    let row = lua.create_table()?;
82                    row.set("name", *name)?;
83                    row.set("description", *desc)?;
84                    t.set(i + 1, row)?;
85                }
86                Ok(t)
87            })?,
88        )?;
89        editor.set(
90            "log",
91            scope.create_function(|_, s: String| {
92                logs.borrow_mut().push(s);
93                Ok(())
94            })?,
95        )?;
96        lua.globals().set("editor", editor)?;
97        lua.load(src).set_name(chunk_name).exec()
98    })
99    .map_err(|e| e.to_string())
100}
101
102/// Lua value -> JSON. Tables with only positive-integer keys become arrays; everything else an object.
103fn lua_to_json(v: mlua::Value) -> mlua::Result<Value> {
104    Ok(match v {
105        mlua::Value::Nil => Value::Null,
106        mlua::Value::Boolean(b) => Value::Bool(b),
107        mlua::Value::Integer(i) => Value::from(i),
108        mlua::Value::Number(n) => serde_json::Number::from_f64(n).map(Value::Number).unwrap_or(Value::Null),
109        mlua::Value::String(s) => Value::String(s.to_str()?.to_string()),
110        mlua::Value::Table(t) => {
111            let len = t.raw_len();
112            let arrayish = len > 0
113                && t.pairs::<mlua::Value, mlua::Value>().all(|p| {
114                    p.map(|(k, _)| matches!(k, mlua::Value::Integer(i) if i >= 1 && i as usize <= len)).unwrap_or(false)
115                });
116            if arrayish {
117                let mut a = Vec::with_capacity(len);
118                for i in 1..=len {
119                    a.push(lua_to_json(t.raw_get(i)?)?);
120                }
121                Value::Array(a)
122            } else {
123                let mut m = serde_json::Map::new();
124                for p in t.pairs::<mlua::Value, mlua::Value>() {
125                    let (k, val) = p?;
126                    let key = match k {
127                        mlua::Value::String(s) => s.to_str()?.to_string(),
128                        mlua::Value::Integer(i) => i.to_string(),
129                        mlua::Value::Number(n) => n.to_string(),
130                        _ => continue, // unrepresentable key
131                    };
132                    m.insert(key, lua_to_json(val)?);
133                }
134                Value::Object(m)
135            }
136        }
137        _ => Value::Null, // functions / userdata have no JSON shape
138    })
139}
140
141/// JSON -> Lua value.
142fn json_to_lua(lua: &mlua::Lua, v: &Value) -> mlua::Result<mlua::Value> {
143    Ok(match v {
144        Value::Null => mlua::Value::Nil,
145        Value::Bool(b) => mlua::Value::Boolean(*b),
146        Value::Number(n) => {
147            // Luau integers are 32-bit; anything wider travels as a double
148            match n.as_i64().and_then(|i| i32::try_from(i).ok()) {
149                Some(i) => mlua::Value::Integer(i),
150                None => mlua::Value::Number(n.as_f64().unwrap_or(0.0)),
151            }
152        }
153        Value::String(s) => mlua::Value::String(lua.create_string(s)?),
154        Value::Array(a) => {
155            let t = lua.create_table_with_capacity(a.len(), 0)?;
156            for (i, v) in a.iter().enumerate() {
157                t.set(i + 1, json_to_lua(lua, v)?)?;
158            }
159            mlua::Value::Table(t)
160        }
161        Value::Object(m) => {
162            let t = lua.create_table_with_capacity(0, m.len())?;
163            for (k, v) in m {
164                t.set(k.as_str(), json_to_lua(lua, v)?)?;
165            }
166            mlua::Value::Table(t)
167        }
168    })
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use serde_json::json;
175
176    fn run_src(src: &str) -> (Result<(), String>, Vec<(String, Value)>, Vec<String>) {
177        let mut calls = Vec::new();
178        let mut logs = Vec::new();
179        let r = {
180            let mut call = |name: &str, args: &Value| {
181                calls.push((name.to_string(), args.clone()));
182                Ok(json!({"ok": true, "echo": args, "n": 3, "list": [1, 2, 3]}))
183            };
184            run(src, "test", &mut call, &mut logs)
185        };
186        (r, calls, logs)
187    }
188
189    /// Round trip: Lua args reach the tool as JSON, the JSON result comes back as a Lua table.
190    #[test]
191    fn tool_call_round_trips() {
192        let (r, calls, logs) = run_src(
193            r#"
194            local r = editor.tool("clip.set", { id = 7, speed = 2.0, tags = {"a", "b"} })
195            assert(r.ok == true)
196            assert(r.n == 3)
197            assert(r.list[2] == 2)
198            assert(r.echo.id == 7)
199            assert(r.echo.tags[1] == "a")
200            editor.log("done " .. tostring(r.n))
201            "#,
202        );
203        assert_eq!(r, Ok(()));
204        assert_eq!(calls.len(), 1);
205        assert_eq!(calls[0].0, "clip.set");
206        // Luau stores integral doubles as integers: 2.0 may arrive as 2 — same value either way
207        assert_eq!(calls[0].1["speed"].as_f64(), Some(2.0));
208        assert_eq!(calls[0].1["tags"], json!(["a", "b"]));
209        assert_eq!(logs, vec!["done 3"]);
210    }
211
212    /// A tool error surfaces as a script error; a runaway loop is cut off by the interrupt budget.
213    #[test]
214    fn errors_and_budget() {
215        let mut logs = Vec::new();
216        let mut fail = |_: &str, _: &Value| -> Result<Value, String> { Err("no such clip".into()) };
217        let e = run(r#"editor.tool("clip.set", {})"#, "t", &mut fail, &mut logs).unwrap_err();
218        assert!(e.contains("no such clip"), "{e}");
219        // sandbox: io/os are gone
220        let (r, _, _) = run_src(r#"assert(io == nil and os.exit == nil)"#);
221        assert_eq!(r, Ok(()));
222        // the 5 s budget is too slow for a unit test to exercise for real; trust set_interrupt and
223        // just confirm the catalogue is visible
224        let (r, _, _) = run_src(r#"assert(#editor.tools() > 10)"#);
225        assert_eq!(r, Ok(()));
226    }
227}