Files
designcraft/crates/engine/src/script.rs
T

425 lines
19 KiB
Rust

//! Command scripts for agents and shells: a list of `{command, params}` steps where later steps
//! can use earlier results.
//!
//! **References.** A string parameter that is exactly `"$N.path"` (or `"$last.path"`, `"$N"`)
//! becomes the JSON value at `path` in the result of step `N` (0-based): after
//! `frame.create → {"id": 8, "story": 9}`, `{"story": "$0.story"}` passes `9`. Paths are dotted
//! keys and array indices (`$2.ids.0`). Inside longer strings `${N.path}` is replaced by the
//! value's text (`"Frame ${0.id}"`). Any other string starting with `$` is plain text (`"$1-$2"`,
//! `"$5 off"`), and `$$` anywhere writes a literal `$` (`"$$1"` passes `$1`, a GREP group).
//!
//! **Script text** ([`parse`]): a JSON array of steps, JSON lines (`{"command": …, "params": …}`),
//! or compact lines `command.id {json}` / `command.id={json}` / `command.id`; blank lines and
//! lines starting with `#` or `//` are ignored. A leading byte order mark and CRLF line endings are
//! accepted. A JSON step takes only `command` and `params`; any other key is an error.
use serde_json::{Value, json};
/// One step of a script.
#[derive(Clone, Debug, PartialEq)]
pub struct Step {
pub command: String,
pub params: Value,
}
/// Parse script text (see the module docs).
pub fn parse(text: &str) -> Result<Vec<Step>, String> {
// Windows tools save "UTF-8 with BOM"; `trim` keeps U+FEFF, which then became part of the
// first command id.
let body = text.trim_start_matches('\u{feff}');
let t = body.trim();
if t.starts_with('[') {
let v: Value = serde_json::from_str(t).map_err(|e| format!("script: {e}"))?;
return v
.as_array()
.ok_or("script: expected an array")?
.iter()
.enumerate()
.map(|(i, s)| step_of(s).map_err(|e| format!("step {i}: {e}")))
.collect();
}
let mut out = Vec::new();
// Number lines from the untrimmed text so leading blank lines still count.
for (n, line) in body.lines().enumerate() {
let l = line.trim();
if l.is_empty() || l.starts_with('#') || l.starts_with("//") {
continue;
}
let step = if l.starts_with('{') {
serde_json::from_str::<Value>(l).map_err(|e| e.to_string()).and_then(|v| step_of(&v))
} else {
// `id {json}`, `id={json}` or `id`.
let split = l.find([' ', '=', '\t']).unwrap_or(l.len());
let (id, rest) = l.split_at(split);
let rest = rest.trim_start_matches(['=', ' ', '\t']).trim();
let params = if rest.is_empty() { Ok(json!({})) } else { serde_json::from_str(rest).map_err(|e| e.to_string()) };
params.map(|p: Value| if p.is_null() { json!({}) } else { p }).and_then(|params: Value| {
if params.is_object() { Ok(Step { command: id.to_string(), params }) } else { Err("`params` must be an object".to_string()) }
})
};
out.push(step.map_err(|e| format!("line {}: {e}", n + 1))?);
}
Ok(out)
}
fn step_of(v: &Value) -> Result<Step, String> {
// A misspelt key (`parms`) would otherwise run the command with its defaults.
if let Some(key) = v.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "command" | "params"))) {
return Err(format!("unknown key `{key}` (a step takes `command` and `params`)"));
}
let command = v.get("command").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or("missing `command`")?.to_string();
let params = v.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}));
if !params.is_object() {
return Err("`params` must be an object".into());
}
Ok(Step { command, params })
}
/// The value a reference (`N.path`, `last.path`) points at, borrowed from `results`.
fn lookup<'a>(r: &str, results: &'a [Value]) -> Result<&'a Value, String> {
let mut parts = r.split('.');
let head = parts.next().unwrap_or("");
let idx = if head == "last" {
results.len().checked_sub(1).ok_or("`$last` before any step")?
} else {
head.parse::<usize>().map_err(|_| format!("bad reference `${r}`"))?
};
let mut v = results.get(idx).ok_or_else(|| format!("`${r}`: step {idx} hasn't run (only {} have)", results.len()))?;
for p in parts {
v = match (v, p.parse::<usize>()) {
(Value::Array(a), Ok(i)) => a.get(i),
(Value::Object(o), _) => o.get(p),
_ => None,
}
.ok_or_else(|| format!("`${r}`: no `{p}` in {}", short(v)))?;
}
Ok(v)
}
fn short(v: &Value) -> String {
let s = v.to_string();
if s.len() > 120 { format!("{}…", &s[..s.floor_char_boundary(120)]) } else { s }
}
/// Whether `r` (the text after `$`) is a whole reference: digits or `last`, then zero or more
/// `.segment` of ASCII letters, digits, `_` or `-`.
fn is_reference(r: &str) -> bool {
let mut parts = r.split('.');
let head = parts.next().unwrap_or("");
let head_ok = head == "last" || (!head.is_empty() && head.bytes().all(|b| b.is_ascii_digit()));
head_ok && parts.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'))
}
/// Longest string a `${…}` interpolation may build: a step that pastes an earlier result into a
/// story, then interpolates that story into the next, could otherwise double its size each step.
pub const MAX_INTERPOLATED_BYTES: usize = 16 << 20;
fn too_long(s: &str) -> String {
// Cut at a char boundary: a preview of a long Unicode input mustn't copy all of it.
let preview = &s[..s.floor_char_boundary(40)];
format!("the interpolated text of {preview} is longer than {MAX_INTERPOLATED_BYTES} bytes").replace('\n', " ")
}
/// Longest string a whole reference (`$last.text`) may copy in. A story is far smaller; the cap
/// stops a loop of "read the story, paste it back" from doubling it until memory runs out.
pub const MAX_REFERENCED_BYTES: usize = 64 << 20;
/// Most bytes of strings one step's parameters may hold once its references are resolved: the
/// limits above apply to each string, this one to a whole parameter tree (an array of 256
/// references to a large string would pass each of them).
pub const MAX_STEP_PARAM_BYTES: usize = 256 << 20;
/// Roughly how many bytes cloning `v` allocates (its strings, plus a word per node).
fn weight(v: &Value) -> usize {
match v {
Value::String(s) => s.len(),
Value::Array(a) => a.iter().fold(8, |n, x| n.saturating_add(weight(x))),
Value::Object(o) => o.iter().fold(8, |n, (k, x)| n.saturating_add(k.len()).saturating_add(weight(x))),
_ => 8,
}
}
fn charge(budget: &mut usize, bytes: usize) -> Result<(), String> {
*budget =
budget.checked_sub(bytes).ok_or_else(|| format!("the parameters of a step are longer than {MAX_STEP_PARAM_BYTES} bytes once resolved"))?;
Ok(())
}
/// Replace references in `params` with values from earlier `results`.
pub fn resolve(params: &Value, results: &[Value]) -> Result<Value, String> {
resolve_in(params, results, MAX_REFERENCED_BYTES, &mut { MAX_STEP_PARAM_BYTES })
}
fn resolve_in(params: &Value, results: &[Value], max_ref: usize, budget: &mut usize) -> Result<Value, String> {
Ok(match params {
Value::String(s) => {
if let Some(r) = s.strip_prefix('$').filter(|r| is_reference(r)) {
let v = lookup(r, results)?;
if matches!(v, Value::String(t) if t.len() > max_ref) {
return Err(format!("`${r}` is longer than {max_ref} bytes"));
}
charge(budget, weight(v))?;
return Ok(v.clone());
}
if !s.contains("${") && !s.contains("$$") {
charge(budget, s.len())?;
return Ok(params.clone());
}
let mut out = String::new();
let mut rest = s.as_str();
while let Some(i) = rest.find('$') {
out.push_str(&rest[..i]);
let after = &rest[i + 1..];
if let Some(tail) = after.strip_prefix('$') {
out.push('$');
rest = tail;
} else if let Some(inner) = after.strip_prefix('{') {
let end = inner.find('}').ok_or_else(|| format!("unclosed `${{` in {s:?}"))?;
// The length is checked before the value is copied in.
let piece = match lookup(&inner[..end], results)? {
Value::String(t) => std::borrow::Cow::Borrowed(t.as_str()),
other => std::borrow::Cow::Owned(other.to_string()),
};
if out.len().saturating_add(piece.len()) > MAX_INTERPOLATED_BYTES {
return Err(too_long(s));
}
out.push_str(&piece);
rest = &inner[end + 1..];
} else {
out.push('$');
rest = after;
}
}
if out.len().saturating_add(rest.len()) > MAX_INTERPOLATED_BYTES {
return Err(too_long(s));
}
out.push_str(rest);
charge(budget, out.len())?;
Value::String(out)
}
Value::Array(a) => Value::Array(a.iter().map(|v| resolve_in(v, results, max_ref, budget)).collect::<Result<_, _>>()?),
Value::Object(o) => {
Value::Object(o.iter().map(|(k, v)| Ok((k.clone(), resolve_in(v, results, max_ref, budget)?))).collect::<Result<_, String>>()?)
}
v => v.clone(),
})
}
/// Outcome of running a script.
#[derive(Clone, Debug, PartialEq)]
pub struct Report {
pub results: Vec<Value>,
/// The failing step (index, command, message); later steps didn't run.
pub failed: Option<(usize, String, String)>,
}
impl Report {
pub fn to_json(&self) -> Value {
let mut v = json!({"completed": self.results.len(), "results": self.results});
if let Some((i, c, e)) = &self.failed {
v["failedIndex"] = json!(i);
v["failedCommand"] = json!(c);
v["error"] = json!(e);
}
v
}
}
/// Run `steps` through `exec`, resolving references; stops at the first error.
pub fn run(steps: &[Step], mut exec: impl FnMut(&str, Value) -> Result<Value, String>) -> Report {
let mut results = Vec::with_capacity(steps.len());
for (i, s) in steps.iter().enumerate() {
let r = resolve(&s.params, &results).and_then(|p| exec(&s.command, p));
match r {
Ok(v) => results.push(v),
Err(e) => return Report { results, failed: Some((i, s.command.clone(), e)) },
}
}
Report { results, failed: None }
}
/// How deep scripts may run scripts (`script.run` is a command a step may run).
pub const MAX_SCRIPT_DEPTH: usize = 8;
/// Steps one outermost script may run, the scripts it runs included: a script that runs itself
/// (its text read back from a story, say) ends here rather than never.
pub const MAX_SCRIPT_STEPS: usize = 100_000;
impl crate::Session {
/// Run a script on this session (see [`crate::script`]).
pub fn run_script(&mut self, steps: &[Step]) -> Report {
if self.script_depth >= MAX_SCRIPT_DEPTH {
let command = steps.first().map(|s| s.command.clone()).unwrap_or_default();
return Report {
results: vec![],
failed: Some((0, command, format!("scripts nest deeper than {MAX_SCRIPT_DEPTH} (a script running a script…)"))),
};
}
if self.script_depth == 0 {
self.script_steps = MAX_SCRIPT_STEPS;
}
self.script_depth += 1;
let report = run(steps, |c, p| {
self.script_steps =
self.script_steps.checked_sub(1).ok_or_else(|| format!("the script and the scripts it runs exceed {MAX_SCRIPT_STEPS} steps"))?;
self.execute(c, &p).map_err(|e| e.to_string())
});
self.script_depth -= 1;
report
}
}
#[cfg(test)]
mod tests {
#[test]
fn the_limit_error_previews_a_multibyte_input_in_bounds() {
let e = super::too_long(&"€".repeat(1000));
assert!(e.len() < 150, "{e}");
}
use super::*;
/// A whole reference to a long string is refused, and the strings of one step's parameters
/// share a budget (small limits here: the real ones would mean allocating them).
#[test]
fn whole_references_and_the_parameter_tree_are_bounded() {
let results = vec![json!({"text": "x".repeat(100)})];
assert!(resolve_in(&json!("$0.text"), &results, 100, &mut 1000).is_ok());
assert!(resolve_in(&json!("$0.text"), &results, 99, &mut 1000).unwrap_err().contains("longer than 99"));
let many = json!(["$0.text", "$0.text", "${0.text}"]);
assert!(resolve_in(&many, &results, 100, &mut 300).is_ok());
assert!(resolve_in(&many, &results, 100, &mut 299).unwrap_err().contains("once resolved"));
}
#[test]
fn leading_blank_lines_keep_line_numbers() {
let e = parse("\n\n# c\nfile.new\nframe.create {bad").unwrap_err();
assert!(e.contains("line 5"), "{e}");
}
#[test]
fn parses_all_forms() {
let a = parse(r#"[{"command": "file.new"}, {"command": "frame.create", "params": {"rect": [0, 0, 10, 10]}}]"#).unwrap();
let b = parse("# comment\nfile.new\n\nframe.create {\"rect\": [0, 0, 10, 10]}\n").unwrap();
let c = parse("{\"command\": \"file.new\"}\nframe.create={\"rect\": [0, 0, 10, 10]}").unwrap();
assert_eq!(a, b);
assert_eq!(a, c);
assert!(parse("frame.create {bad").unwrap_err().contains("line 1"));
}
/// A misspelt step key ran the command with its defaults.
#[test]
fn a_step_with_an_unknown_key_is_refused() {
for text in [r#"[{"command": "file.new", "parms": {"width": 300}}]"#, r#"{"command": "file.new", "parms": {"width": 300}}"#] {
let e = parse(text).unwrap_err();
assert!(e.contains("unknown key `parms`"), "{e}");
}
assert!(parse(r#"[{"command": "file.new", "params": {"width": 300}}]"#).is_ok());
}
#[test]
fn compact_lines_require_object_params() {
for bad in ["a.b 5", "a.b [1]", "a.b \"x\"", "a.b=true"] {
assert_eq!(parse(bad), Err("line 1: `params` must be an object".to_string()), "{bad}");
}
assert_eq!(parse("\na.b={\"x\":1}").unwrap()[0].params, json!({"x": 1}));
assert_eq!(parse("a.b null").unwrap()[0].params, json!({}));
}
/// Windows editors and PowerShell 5.1 save "UTF-8 with BOM" with CRLF line endings; the BOM
/// became part of the first command id (#654).
#[test]
fn a_leading_bom_and_crlf_parse_like_plain_text() {
let plain = "file.new {\"width\": 100}\nframe.create {\"rect\": [0, 0, 10, 10]}\n";
let windows = format!("\u{feff}{}", plain.replace('\n', "\r\n"));
assert_eq!(parse(&windows).unwrap(), parse(plain).unwrap());
let array = r#"[{"command": "file.new"}]"#;
assert_eq!(parse(&format!("\u{feff}{array}")).unwrap(), parse(array).unwrap());
}
#[test]
fn resolves_references() {
let results = vec![json!({"id": 8, "story": 9, "ids": [4, 5]}), json!({"name": "A"})];
let p = json!({"story": "$0.story", "id": "$0.ids.1", "label": "Frame ${0.id} of ${last.name}", "keep": "$", "list": ["$1.name"]});
let r = resolve(&p, &results).unwrap();
assert_eq!(r, json!({"story": 9, "id": 5, "label": "Frame 8 of A", "keep": "$", "list": ["A"]}));
assert!(resolve(&json!({"x": "$5.id"}), &results).unwrap_err().contains("hasn't run"));
assert!(resolve(&json!({"x": "$0.nope"}), &results).unwrap_err().contains("no `nope`"));
assert!(resolve(&json!({"x": "Frame ${0.id"}), &results).unwrap_err().contains("unclosed"));
}
/// GREP replacements start with `$` too; only a well-formed reference is looked up (#749).
#[test]
fn only_well_formed_references_resolve() {
let results = vec![json!({"id": 8})];
let p = json!({"a": "$1\u{a0}", "b": "$1-$2", "c": "$5 off", "d": "$$1", "e": "$${0.id}", "f": "$0.id", "g": "${0.id}x", "h": "$1$$", "i": "$1."});
let r = resolve(&p, &results).unwrap();
assert_eq!(r, json!({"a": "$1\u{a0}", "b": "$1-$2", "c": "$5 off", "d": "$1", "e": "${0.id}", "f": 8, "g": "8x", "h": "$1$", "i": "$1."}));
}
/// Czech typesetting: a nonbreaking space after one-letter prepositions.
#[test]
fn a_script_grep_change_keeps_its_group_reference() {
let mut s = crate::Session::new();
let steps = vec![
Step { command: "file.new".into(), params: json!({}) },
Step { command: "frame.create".into(), params: json!({"rect": [36, 36, 300, 300], "content": "text", "text": "a v Praze"}) },
Step { command: "find.change".into(), params: json!({"find": r"\b([av]) ", "change": "$1\u{a0}", "grep": true, "caseSensitive": true}) },
Step { command: "story.get".into(), params: json!({"story": "$1.story"}) },
];
let r = s.run_script(&steps);
assert!(r.failed.is_none(), "{r:?}");
assert_eq!(r.results[2]["count"], 2);
assert_eq!(r.results[3]["text"], "a\u{a0}v\u{a0}Praze");
}
/// A script that runs scripts stops at the depth limit, and the steps of all of them count
/// against one budget; the session goes on afterwards.
#[test]
fn nested_scripts_are_bounded() {
let mut s = crate::Session::new();
let steps = parse("file.new\ndocument.inspect\ndocument.inspect").unwrap();
s.script_depth = MAX_SCRIPT_DEPTH;
let e = s.run_script(&steps).failed.expect("must fail").2;
assert!(e.contains("nest deeper"), "{e}");
s.script_depth = 1;
s.script_steps = 2;
let r = s.run_script(&steps);
assert_eq!(r.results.len(), 2);
assert!(r.failed.as_ref().is_some_and(|f| f.2.contains("exceed")), "{r:?}");
s.script_depth = 0;
let r = s.run_script(&steps);
assert!(r.failed.is_none(), "{r:?}");
assert_eq!(s.script_depth, 0);
}
#[test]
fn interpolation_is_capped() {
let big = "x".repeat(MAX_INTERPOLATED_BYTES / 2 + 1);
let results = vec![json!({"text": big})];
assert!(resolve(&json!("${0.text}"), &results).is_ok());
let e = resolve(&json!("${0.text}${0.text}"), &results).unwrap_err();
assert!(e.contains("longer than"), "{e}");
}
#[test]
fn runs_on_a_session_with_references() {
let mut s = crate::Session::new();
let steps = parse(
"file.new {\"pages\": 2}\n\
frame.create {\"rect\": [72, 72, 300, 200], \"content\": \"text\", \"text\": \"Hello\"}\n\
text.select {\"story\": \"$1.story\", \"anchor\": 5, \"focus\": 5}\n\
text.insert {\"text\": \" world\"}\n\
story.get {\"story\": \"$1.story\"}",
)
.unwrap();
let r = s.run_script(&steps);
assert!(r.failed.is_none(), "{r:?}");
assert_eq!(r.results[4]["text"], "Hello world");
// Failure reports the step and keeps earlier results.
let r = s.run_script(&parse("document.inspect\nno.such.command\ndocument.inspect").unwrap());
assert_eq!(r.results.len(), 1);
assert_eq!(r.failed.as_ref().map(|f| (f.0, f.1.as_str())), Some((1, "no.such.command")));
assert_eq!(r.to_json()["failedIndex"], 1);
}
}