diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs new file mode 100644 index 000000000..2aba053b0 --- /dev/null +++ b/crates/engine/src/cmd/macros.rs @@ -0,0 +1,43 @@ +//! Macros: typed command lists that run as one undo step. See [`crate::macros`] for the model, +//! tokens, runner and policy. Automate scripts (`automate.*`) are a separate, older feature. + +use serde_json::Value as Json; + +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "macro.run", + "Run Macro", + [], + None, + "{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}", + has_doc, + run + ), + cmd!( + query "macro.check", + "Check Macro", + [], + None, + "{name, inputs?} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok, errors?, needsApproval?, inputs, output?, conflicts, footprint}", + has_doc, + check + ), + ] +} + +fn name_param<'a>(cmd: &str, p: &'a Json) -> Result<&'a str> { + str_param(p, "name").filter(|n| !n.trim().is_empty()).ok_or_else(|| bad(cmd, "missing `name`")) +} + +fn run(s: &mut Session, p: &Json) -> Result { + let name = name_param("macro.run", p)?; + crate::macros::run::run(s, name, p) +} + +fn check(s: &mut Session, p: &Json) -> Result { + let name = name_param("macro.check", p)?; + crate::macros::run::check(s, name, p) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index fadb72b6c..6aa9cac58 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -12,6 +12,7 @@ pub mod format; pub mod formulas; pub mod insert; pub mod inspect; +pub mod macros; pub mod pivot; pub mod print; pub mod review; @@ -143,6 +144,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(view::specs()); v.extend(inspect::specs()); v.extend(extra::specs()); + v.extend(macros::specs()); v.extend(draw::specs()); v.extend(spelling::specs()); v.extend(solver::specs()); @@ -365,6 +367,13 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) if !structural { check_arrays(&d.wb, &wb)?; } + if let Some(log) = d.touched.as_mut() { + for (si, c) in changed.iter().take(TOUCH_LOG_LIMIT.saturating_sub(log.len())) { + if let Some(sh) = wb.sheet(*si) { + log.push((sh.name.clone(), *c)); + } + } + } if changed.len() <= 200_000 { fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row))); } @@ -392,6 +401,9 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) Ok(r) } +/// The most written cells a macro's footprint check records. +const TOUCH_LOG_LIMIT: usize = 1_000_000; + /// Excel's message for an edit of a locked cell on a protected sheet. pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet."; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7f63001d2..d188161ee 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -52,6 +52,12 @@ pub enum EngineError { NoDocument, #[error("{0}")] Other(String), + /// The command would replace something; run it again with `overwrite: true` to go ahead. + #[error("confirm: {0} (run again with `overwrite: true`)")] + Confirm(String), + /// Active content (a macro) asks to run; run it again with `approve: true` to allow it. + #[error("approve: {0} (run again with `approve: true`)")] + Approve(String), #[error("internal error in `{0}` (the workbook was kept as it was): {1}")] Internal(String, String), } @@ -173,6 +179,9 @@ pub struct DocState { pub content_kind: Option, /// Whether the workbook's active content may run without asking. pub trust: Trust, + /// While a macro's footprint is measured: every cell a command wrote, by sheet name, even + /// where it wrote what was already there. + pub touched: Option>, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -199,6 +208,7 @@ impl DocState { unlocked_ranges: vec![], content_kind: None, trust: Trust::Local, + touched: None, } } pub fn is_dirty(&self) -> bool { @@ -312,6 +322,8 @@ pub struct Session { pub recorder: Option, /// How many `execute` calls are running (0 between top-level commands). depth: u32, + /// Macros the person allowed to run in this session: (document uid, macro content hash). + pub macro_approvals: std::collections::HashSet<(u64, u64)>, /// Windows hidden with View › Hide. pub hidden_windows: Vec, /// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width. diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index fee539201..55933c1ac 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -14,7 +14,9 @@ use serde_json::{Value as Json, json}; use crate::cmd::stash; +pub mod policy; pub mod resolve; +pub mod run; /// The hidden-data key prefix of a stored macro (`macro_`). const MACRO_KEY: &str = "macro_"; @@ -86,6 +88,14 @@ pub fn find(wb: &Workbook, name: &str) -> Option { wb.macros.iter().position(|m| m.name.eq_ignore_ascii_case(name)) } +/// A stable fingerprint of a macro's content: approval to run a macro from a file holds until +/// the macro changes. +pub fn hash(m: &Macro) -> u64 { + // FNV-1a over the macro's JSON. + let text = macro_json(m).to_string(); + text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)) +} + /// A copy of `wb` for xlsx with each macro in hidden names (`_gc_macro__`) instead of the /// field, or `None` when there is nothing to change. pub fn with_names(wb: &Workbook) -> crate::Result> { diff --git a/crates/engine/src/macros/policy.rs b/crates/engine/src/macros/policy.rs new file mode 100644 index 000000000..7329f567f --- /dev/null +++ b/crates/engine/src/macros/policy.rs @@ -0,0 +1,122 @@ +//! What a macro step may run. Macros run commands, so this is the one place that keeps a macro +//! from saving, opening or closing files, driving the app, running other macros, or reading and +//! writing files without the person knowing. + +use serde_json::Value as Json; + +use crate::Trust; + +/// Whether a command may run as a macro step. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Policy { + Allow, + /// Allowed once the person approves the macro; the text says why it asks ("came with a + /// workbook you opened"). + Ask(String), + /// Reads or writes files: the macro must list the command in its permissions, and the person + /// approves the macro before it runs. The text says what it does ("reads a file"). + NeedsPermission(String), + /// Never runs in a macro; the text says why. + Deny(String), +} + +/// Prefixes of commands that never run in a macro, with the reason. +const DENIED_PREFIXES: &[(&str, &str)] = &[ + ("file.", "macros don't open, save, close or create workbooks"), + ("app.", "macros don't change the app"), + ("window.", "macros don't switch windows"), + ("automate.", "macros don't record or run scripts or other macros"), + ("macro.", "macros don't record or run other macros"), +]; + +/// Single commands that never run in a macro. +const DENIED: &[(&str, &str)] = + &[("edit.undo", "a macro is undone as one step after it runs"), ("edit.redo", "a macro is undone as one step after it runs")]; + +/// Commands under a denied prefix that only change the workbook. +const ALLOWED: &[&str] = &["file.properties"]; + +/// Commands that read a file when given a `path`. +const READS_FILES: &[&str] = &["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background"]; + +/// File commands that only produce bytes unless given a `path` (then they write a file). +const EXPORTS: &[&str] = &["file.exportPdf", "file.exportCsv", "file.exportHtml", "file.print", "file.printPreview", "file.saveBytes"]; + +/// The policy for running command `id` with `params` as a macro step in a document with `trust`. +pub fn macro_policy(id: &str, params: &Json, trust: Trust) -> Policy { + let has_path = params.get("path").and_then(Json::as_str).is_some_and(|p| !p.trim().is_empty()); + let base = if EXPORTS.contains(&id) { + if has_path { Policy::NeedsPermission("writes a file".into()) } else { Policy::Allow } + } else if ALLOWED.contains(&id) { + Policy::Allow + } else if let Some((_, why)) = DENIED.iter().find(|(d, _)| *d == id) { + Policy::Deny((*why).into()) + } else if let Some((_, why)) = DENIED_PREFIXES.iter().find(|(p, _)| id.starts_with(p)) { + Policy::Deny((*why).into()) + } else if READS_FILES.contains(&id) && has_path { + Policy::NeedsPermission("reads a file".into()) + } else { + Policy::Allow + }; + match base { + Policy::Allow if !trust.allows_code() => Policy::Ask("came with a workbook you opened".into()), + other => other, + } +} + +/// Whether the footprint dry run can preview a command. Solver can take long, so a dry run stops +/// before it and reports the footprint as partial. +pub fn previewable(id: &str) -> bool { + id != "data.solver" +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn the_deny_and_permission_lists() { + let none = json!({}); + let path = json!({"path": "/tmp/x"}); + for id in [ + "file.saveAs", + "file.save", + "file.open", + "file.close", + "file.new", + "app.commands", + "window.activate", + "automate.runScript", + "automate.recordActions", + "macro.run", + "macro.record", + "edit.undo", + "edit.redo", + ] { + assert!(matches!(macro_policy(id, &none, Trust::Local), Policy::Deny(_)), "{id}"); + assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::Deny(_)), "{id}"); + } + for id in ["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background", "file.exportPdf", "file.print", "file.exportCsv"] { + assert!(matches!(macro_policy(id, &path, Trust::Local), Policy::NeedsPermission(_)), "{id}"); + assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::NeedsPermission(_)), "{id}"); + } + // Without a path they touch no file. + for id in ["data.getData", "insert.picture", "file.exportPdf", "file.saveBytes", "file.printPreview"] { + assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}"); + assert_eq!(macro_policy(id, &json!({"path": " "}), Trust::Local), Policy::Allow, "{id}"); + } + for id in ["cell.set", "home.bold", "view.macros", "edit.copy", "file.properties"] { + assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}"); + assert!(matches!(macro_policy(id, &none, Trust::FromFile), Policy::Ask(_)), "{id}"); + } + assert!(!previewable("data.solver") && previewable("cell.set")); + } + + #[test] + fn every_listed_command_exists() { + for id in ALLOWED.iter().chain(READS_FILES).chain(EXPORTS).chain(DENIED.iter().map(|(id, _)| id)).chain(&["data.solver"]) { + assert!(crate::find_command(id).is_some(), "{id}"); + } + } +} diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs new file mode 100644 index 000000000..3651deb0c --- /dev/null +++ b/crates/engine/src/macros/run.rs @@ -0,0 +1,1134 @@ +//! Running a macro. +//! +//! 1. **Gate**: every step's command goes through [`macro_policy`]. Denied commands stop the macro +//! before anything runs; commands that touch files must be listed in the macro's permissions; +//! a macro from a file someone else made, or one that touches files, runs only once the person +//! approves it (once per macro content, for the session). +//! 2. **Inputs**: each declared input is bound to a range (given, its default, or the selection) +//! and checked against its shape and column types, with messages that name the cells. +//! 3. **Footprint**: unless the macro may overwrite, it first runs on the real workbook in dry-run +//! mode, the changes are compared with `CellStore::diff`, and everything is put back. Cells it +//! would write outside its inputs that already hold data are conflicts, handled by the output +//! rule: ask, fail, overwrite, or shift the data down or right (and check again). +//! 4. **Run**: one undo step (`Session::grouped`); an error at any step rolls everything back and +//! names the step. +//! +//! The steps run on a small machine with an explicit frame stack, so the debugger can stop +//! between any two steps and pick up again on a later frame. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use gridcraft_core::{CellRef, RangeRef, Value}; +use gridcraft_model::{Cell, CompareOp, InputKind, Macro, OnConflict, RepeatOf, Sheet, Step, StepKind, Workbook}; +use serde::Serialize; +use serde_json::{Value as Json, json}; + +use super::policy::{Policy, macro_policy, previewable}; +use super::resolve::{self, Env, Place, ResolveError}; +use crate::{EngineError, Result, Selection, Session, Trust, UiRequest}; + +/// The most steps one run executes (counting each pass through a Repeat). +pub const MAX_EXECUTED_STEPS: u64 = 100_000; +/// How long a run may take, in milliseconds. The web build has no clock; there only the step +/// limit applies. +pub const DEFAULT_TIME_LIMIT_MS: f64 = 60_000.0; +/// The most cells an input may have for its content to be checked. +pub const MAX_INPUT_CELLS: u64 = 1_000_000; +/// The most ranges a footprint lists per sheet (the counts stay exact). +const MAX_LISTED_RANGES: usize = 500; + +/// An input bound to a range: name, where, whether its first row is a header. +pub type Bound = (String, Place, bool); + +/// Where and why a step failed. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct StepError { + /// The step's path: `3`, `2.1` inside a Repeat, `4.then.1` inside an If. + pub step: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + pub message: String, + /// The step ran past the edge of the sheet. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub edge: bool, +} + +impl StepError { + fn new(step: &str, command: Option<&str>, message: impl Into) -> StepError { + StepError { step: step.to_string(), command: command.map(str::to_string), message: message.into(), edge: false } + } + + fn resolve(step: &str, command: Option<&str>, e: ResolveError) -> StepError { + let edge = matches!(e, ResolveError::Edge(_)); + StepError { edge, ..StepError::new(step, command, e.message()) } + } + + /// `Step 3 (cell.set): …` + pub fn describe(&self) -> String { + match &self.command { + Some(c) => format!("Step {} ({c}): {}", self.step, self.message), + None => format!("Step {}: {}", self.step, self.message), + } + } + + pub fn into_engine(self, name: &str) -> EngineError { + EngineError::Other(format!("Macro '{name}' stopped. {} Nothing was changed.", self.describe())) + } +} + +// ---------------------------------------------------------------- gate + +/// Checks every enabled step against the policy: denied commands and undeclared file access are +/// errors; otherwise returns what the person must approve (empty when nothing). +pub fn gate(m: &Macro, trust: Trust) -> Result> { + let mut asks = BTreeSet::new(); + let mut err = None; + walk_enabled(&m.steps, "", &mut |path, id, params| { + if err.is_some() { + return; + } + match macro_policy(id, params, trust) { + Policy::Allow => {} + Policy::Ask(why) => { + asks.insert(why); + } + Policy::NeedsPermission(what) => { + if m.permissions.iter().any(|p| p == id) { + asks.insert(format!("runs {id}, which {what}")); + } else { + err = Some(format!("Step {path} ({id}) {what}. Add {id} to the macro's permissions to allow it.")); + } + } + Policy::Deny(why) => err = Some(format!("Step {path} ({id}) can't run in a macro: {why}.")), + } + }); + match err { + Some(e) => Err(EngineError::Other(format!("Macro '{}' can't run. {e}", m.name))), + None => Ok(asks.into_iter().collect()), + } +} + +/// Calls `f(path, id, params)` for each enabled command step (skipping disabled steps' bodies). +fn walk_enabled(steps: &[Step], prefix: &str, f: &mut impl FnMut(&str, &str, &Json)) { + for (i, st) in steps.iter().enumerate() { + if !st.enabled { + continue; + } + let path = child_path(prefix, i); + match &st.kind { + StepKind::Command { id, params } => f(&path, id, params), + StepKind::Repeat { body, .. } => walk_enabled(body, &path, f), + StepKind::If { then, otherwise, .. } => { + walk_enabled(then, &format!("{path}.then"), f); + walk_enabled(otherwise, &format!("{path}.else"), f); + } + StepKind::Stop { .. } => {} + } + } +} + +fn child_path(prefix: &str, i: usize) -> String { + if prefix.is_empty() { (i + 1).to_string() } else { format!("{prefix}.{}", i + 1) } +} + +/// Approves the macro for this session when `approve` is set, or asks (an +/// [`EngineError::Approve`]) when it needs approval it doesn't have. +pub fn approve(s: &mut Session, m: &Macro, doc: u64, asks: &[String], approve: bool) -> Result<()> { + if asks.is_empty() { + return Ok(()); + } + let key = (doc, super::hash(m)); + if s.macro_approvals.contains(&key) { + return Ok(()); + } + if approve { + s.macro_approvals.insert(key); + return Ok(()); + } + Err(EngineError::Approve(format!("Run macro '{}'? It {}.", m.name, join_and(asks)))) +} + +fn join_and(items: &[String]) -> String { + match items { + [] => String::new(), + [one] => one.clone(), + [init @ .., last] => format!("{} and {last}", init.join(", ")), + } +} + +// ---------------------------------------------------------------- inputs + +/// A macro ready to run: its inputs bound and checked, its output anchor resolved. +#[derive(Clone, Debug)] +pub struct Prepared { + pub mac: Macro, + pub doc: u64, + pub trust: Trust, + pub inputs: Vec, + pub output: Option, +} + +impl Prepared { + pub fn inputs_json(&self, wb: &Workbook) -> Json { + let env = Env { wb, active_sheet: wb.active_sheet, inputs: &[], output: None, item: None }; + Json::Object(self.inputs.iter().map(|(n, p, _)| (n.clone(), json!(resolve::a1(*p, &env)))).collect()) + } +} + +/// Binds the macro's inputs on the active document (`given`: `{"Input": "B2:D9"}`, or a reference +/// for the first input) and checks them. Errors list every problem found. +pub fn prepare(s: &Session, m: &Macro, given: Option<&Json>) -> Result { + let d = s.doc()?; + let wb = &d.wb; + let mut inputs: Vec = Vec::new(); + let mut problems = Vec::new(); + for (i, input) in m.inputs.iter().enumerate() { + let named = given.and_then(Json::as_object).and_then(|o| o.iter().find(|(k, _)| k.eq_ignore_ascii_case(&input.name)).map(|(_, v)| v)); + let text = named.and_then(Json::as_str).or_else(|| if i == 0 { given.and_then(Json::as_str) } else { None }).or(input.default_ref.as_deref()); + let place = match text { + Some(t) => { + let env = Env { wb, active_sheet: wb.active_sheet, inputs: &inputs, output: None, item: None }; + match resolve::resolve_ref(t, &env) { + Ok(p) => p, + Err(e) => { + problems.push(format!("{}: {}", input.name, e.message())); + continue; + } + } + } + None if i == 0 => Place { sheet: wb.active_sheet, range: d.selection.current() }, + None => { + problems.push(format!("{0} needs a range: pass inputs: {{\"{0}\": \"A1:B5\"}}.", input.name)); + continue; + } + }; + let place = trim(wb, place); + problems.extend(check_input(wb, input, place)); + inputs.push((input.name.clone(), place, input.header)); + } + if !problems.is_empty() { + return Err(EngineError::Other(format!("Macro '{}' can't run on these inputs. {}", m.name, problems.join(" ")))); + } + let output = match &m.output.anchor { + Some(a) if !a.trim().is_empty() => { + let env = Env { wb, active_sheet: wb.active_sheet, inputs: &inputs, output: None, item: None }; + Some( + resolve::resolve_ref(a, &env) + .map_err(|e| EngineError::Other(format!("Macro '{}': its output anchor {}", m.name, lower_first(e.message()))))?, + ) + } + _ => None, + }; + Ok(Prepared { mac: m.clone(), doc: d.uid, trust: d.trust, inputs, output }) +} + +fn lower_first(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + Some(f) => f.to_lowercase().chain(c).collect(), + None => String::new(), + } +} + +/// Whole rows or columns shrink to the part of the sheet in use. +fn trim(wb: &Workbook, p: Place) -> Place { + if !(p.range.is_full_cols() || p.range.is_full_rows()) { + return p; + } + let used = wb.sheet(p.sheet).and_then(Sheet::used_range); + let range = used.and_then(|u| p.range.intersection(&u)).unwrap_or(RangeRef::cell(p.range.start)); + Place { sheet: p.sheet, range } +} + +/// What is wrong with `place` as `input`, one sentence each. +fn check_input(wb: &Workbook, input: &gridcraft_model::NamedInput, place: Place) -> Vec { + let mut out = Vec::new(); + let r = place.range; + let name = &input.name; + let at = r.a1(); + if input.kind == InputKind::Cell && !r.is_single() { + out.push(format!("{name} must be a single cell; {at} is {} cells.", r.count())); + return out; + } + let header = u32::from(input.header && r.height() > 0); + let rows = r.height().saturating_sub(header); + let between = |what: &str, n: u32, [min, max]: [u32; 2]| -> Option { + let plural = |k: u32| if k == 1 { what.to_string() } else { format!("{what}s") }; + if max > 0 && min == max && n != min { + Some(format!("exactly {min} {}", plural(min))) + } else if n < min { + Some(format!("at least {min} {}", plural(min))) + } else if max > 0 && n > max { + Some(format!("at most {max} {}", plural(max))) + } else { + None + } + }; + if let Some(want) = between("data row", rows, input.rows) { + out.push(format!("{name} needs {want}; {at} has {rows}.")); + } + if let Some(want) = between("column", r.width(), input.cols) { + out.push(format!("{name} needs {want}; {at} has {}.", r.width())); + } + if r.count() > MAX_INPUT_CELLS { + out.push(format!("{name} is too large to check ({} cells; at most {}).", r.count(), MAX_INPUT_CELLS)); + return out; + } + let Some(sh) = wb.sheet(place.sheet) else { return out }; + let mut blanks = Vec::new(); + for (ci, col) in (r.start.col..=r.end.col).enumerate() { + let ty = input.col_types.get(ci).copied().unwrap_or_default(); + let mut wrong = Vec::new(); + for row in r.start.row + header..=r.end.row { + let c = CellRef::new(row, col); + let v = sh.value(c); + if v.is_empty() || v.as_text() == Some("") { + if input.non_empty { + blanks.push(c); + } + continue; + } + let date = || wb.styles.get(sh.style_id(c)).num_fmt.as_str().to_string(); + let ok = match ty { + gridcraft_model::ColType::Any => true, + gridcraft_model::ColType::Number => v.is_number(), + gridcraft_model::ColType::Text => v.is_text(), + gridcraft_model::ColType::Bool => matches!(v, Value::Bool(_)), + gridcraft_model::ColType::Date => v.is_number() && crate::display::number_format(&date()).is_date(), + }; + if !ok { + wrong.push((c, holds(&v, ty))); + } + } + if !wrong.is_empty() { + let head = if input.header { + Some(crate::display::cell_text(wb, sh, CellRef::new(r.start.row, col))).filter(|h| !h.trim().is_empty()) + } else { + None + }; + let column = match head { + Some(h) => format!("{name} column {} ({})", ci + 1, h.trim()), + None => format!("{name} column {}", ci + 1), + }; + let listed: Vec = wrong.iter().take(3).map(|(c, what)| format!("{} holds {what}", c.a1())).collect(); + let more = if wrong.len() > 3 { format!(" and {} more cells don't", wrong.len() - 3) } else { String::new() }; + out.push(format!("{column} must hold {}; {}{more}.", ty.noun(), listed.join(", "))); + } + } + if let Some(first) = blanks.first() { + let more = if blanks.len() > 1 { format!(" (and {} more)", blanks.len() - 1) } else { String::new() }; + out.push(format!("{name} must have no blank cells; {} is blank{more}.", first.a1())); + } + out +} + +/// What a cell holds, for "C7 holds text". +fn holds(v: &Value, wanted: gridcraft_model::ColType) -> &'static str { + match v { + Value::Number(_) if wanted == gridcraft_model::ColType::Date => "a number that isn't a date", + Value::Number(_) => "a number", + Value::Text(_) => "text", + Value::Bool(_) => "TRUE or FALSE", + Value::Error(_) => "an error", + Value::Array(_) => "an array", + Value::Empty => "nothing", + } +} + +// ---------------------------------------------------------------- the machine + +/// A stopwatch for the time limit. `Instant::now` traps on wasm, so there it measures nothing. +#[derive(Clone, Copy, Debug)] +struct Stopwatch { + #[cfg(not(target_arch = "wasm32"))] + start: std::time::Instant, +} + +impl Stopwatch { + fn start() -> Stopwatch { + #[cfg(not(target_arch = "wasm32"))] + #[allow(clippy::disallowed_methods)] // never on wasm (see above) + let w = Stopwatch { start: std::time::Instant::now() }; + #[cfg(target_arch = "wasm32")] + let w = Stopwatch {}; + w + } + + /// Milliseconds since the start, or `None` where there is no clock. + fn elapsed_ms(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + let ms = Some(self.start.elapsed().as_secs_f64() * 1000.0); + #[cfg(target_arch = "wasm32")] + let ms = None; + ms + } +} + +#[derive(Clone, Debug)] +struct Frame { + steps: Arc>, + next: usize, + /// Path of the step that opened this frame (`""` at the top, `3` for a Repeat's body, + /// `4.then` for an If's branch). + prefix: String, + repeat: Option, +} + +#[derive(Clone, Copy, Debug)] +struct Loop { + of: RepeatOf, + over: Place, + /// The current item (0-based) and how many there are. + at: u32, + count: u32, +} + +impl Loop { + fn item(&self) -> Place { + let r = self.over.range; + let range = match self.of { + RepeatOf::Rows => { + let row = r.start.row.saturating_add(self.at); + RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)) + } + RepeatOf::Columns => { + let col = r.start.col.saturating_add(self.at); + RangeRef::new(CellRef::new(r.start.row, col), CellRef::new(r.end.row, col)) + } + }; + Place { sheet: self.over.sheet, range } + } +} + +/// A macro run in progress: which step is next, and what has run. +#[derive(Clone, Debug)] +pub struct Machine { + pub name: String, + pub doc: u64, + pub inputs: Vec, + pub output: Option, + permissions: Vec, + trust: Trust, + frames: Vec, + /// Steps executed so far (each pass through a Repeat counts). + pub executed: u64, + clock: Stopwatch, + pub time_limit_ms: f64, + /// Dry run (the footprint check): steps that would touch files or can't be previewed stop + /// the run early instead of running. + pub dry_run: bool, + /// A Stop step ended the run: its message, and whether it asked for a rollback. + pub stopped: Option<(String, bool)>, + /// A dry run stopped before this step (path, reason): what follows isn't in the footprint. + pub partial: Option<(String, String)>, + /// The last step's resolved parameters (for the debugger). + pub last_params: Option, +} + +impl Machine { + pub fn new(p: &Prepared, dry_run: bool) -> Machine { + let mut m = Machine { + name: p.mac.name.clone(), + doc: p.doc, + inputs: p.inputs.clone(), + output: p.output, + permissions: p.mac.permissions.clone(), + trust: p.trust, + frames: vec![Frame { steps: Arc::new(p.mac.steps.clone()), next: 0, prefix: String::new(), repeat: None }], + executed: 0, + clock: Stopwatch::start(), + time_limit_ms: DEFAULT_TIME_LIMIT_MS, + dry_run, + stopped: None, + partial: None, + last_params: None, + }; + m.settle(); + m + } + + /// Moves to the next enabled step: past disabled steps, finished frames and loop passes. + fn settle(&mut self) { + while let Some(f) = self.frames.last_mut() { + if let Some(st) = f.steps.get(f.next) { + if st.enabled { + return; + } + f.next += 1; + continue; + } + if let Some(l) = f.repeat.as_mut() + && l.at.saturating_add(1) < l.count + { + l.at += 1; + f.next = 0; + continue; + } + self.frames.pop(); + } + } + + pub fn is_done(&self) -> bool { + self.frames.is_empty() + } + + /// The step that runs next, with its path. + pub fn current(&self) -> Option<(String, &Step)> { + let f = self.frames.last()?; + f.steps.get(f.next).map(|st| (child_path(&f.prefix, f.next), st)) + } + + /// The innermost Repeat's pass (1-based) and count. + pub fn iteration(&self) -> Option<(u32, u32)> { + self.frames.iter().rev().find_map(|f| f.repeat).map(|l| (l.at + 1, l.count)) + } + + fn item(&self) -> Option<(RepeatOf, Place)> { + self.frames.iter().rev().find_map(|f| f.repeat).map(|l| (l.of, l.item())) + } + + /// Runs every remaining step. + pub fn run_to_end(&mut self, s: &mut Session) -> std::result::Result<(), StepError> { + while !self.is_done() { + self.step(s)?; + } + Ok(()) + } + + /// Runs the current step and moves to the next one. + pub fn step(&mut self, s: &mut Session) -> std::result::Result<(), StepError> { + let Some((path, st)) = self.current().map(|(p, st)| (p, st.clone())) else { return Ok(()) }; + if let Some(f) = self.frames.last_mut() { + f.next += 1; + } + let r = self.exec(s, &path, &st); + if r.is_err() { + self.frames.clear(); + } + self.settle(); + r + } + + fn exec(&mut self, s: &mut Session, path: &str, st: &Step) -> std::result::Result<(), StepError> { + self.executed += 1; + let at = |msg: String| StepError::new(path, None, msg); + if self.executed > MAX_EXECUTED_STEPS { + return Err(at(format!("The macro ran more than {MAX_EXECUTED_STEPS} steps and was stopped."))); + } + if self.clock.elapsed_ms().is_some_and(|ms| ms > self.time_limit_ms) { + return Err(at(format!("The macro ran longer than {} seconds and was stopped.", (self.time_limit_ms / 1000.0).round()))); + } + // Steps run on the macro's workbook, whichever one is in front. + let Some(i) = s.docs.iter().position(|d| d.uid == self.doc) else { return Err(at("The workbook was closed.".into())) }; + s.set_active(i); + match &st.kind { + StepKind::Command { id, params } => self.command(s, path, id, params), + StepKind::Repeat { over, of, max, body } => { + let place = { + let wb = &s.doc().map_err(|e| at(e.to_string()))?.wb; + let env = self.env(wb); + let p = resolve::resolve_ref(over, &env).map_err(|e| StepError::resolve(path, None, e))?; + trim(wb, p) + }; + let items = match of { + RepeatOf::Rows => place.range.height(), + RepeatOf::Columns => place.range.width(), + }; + let cap = (*max).min(MAX_EXECUTED_STEPS as u32); + if items > cap { + let what = if *of == RepeatOf::Rows { "rows" } else { "columns" }; + return Err(at(format!("Repeat over {} has {items} {what}, more than its limit of {cap}.", place.range.a1()))); + } + if items > 0 && body.iter().any(|b| b.enabled) { + let repeat = Some(Loop { of: *of, over: place, at: 0, count: items }); + self.frames.push(Frame { steps: Arc::new(body.clone()), next: 0, prefix: path.to_string(), repeat }); + } + Ok(()) + } + StepKind::If { cell, op, value, then, otherwise } => { + let v = { + let wb = &s.doc().map_err(|e| at(e.to_string()))?.wb; + let env = self.env(wb); + let p = resolve::resolve_ref(cell, &env).map_err(|e| StepError::resolve(path, None, e))?; + if !p.range.is_single() { + return Err(at(format!("If looks at one cell; {} is {} cells.", p.range.a1(), p.range.count()))); + } + wb.sheet(p.sheet).map(|sh| sh.value(p.range.start)).unwrap_or_default() + }; + let (branch, name) = if compare(&v, *op, value) { (then, "then") } else { (otherwise, "else") }; + if branch.iter().any(|b| b.enabled) { + self.frames.push(Frame { steps: Arc::new(branch.clone()), next: 0, prefix: format!("{path}.{name}"), repeat: None }); + } + Ok(()) + } + StepKind::Stop { message, rollback } => { + self.stopped = Some((message.clone(), *rollback)); + self.frames.clear(); + Ok(()) + } + } + } + + fn env<'a>(&'a self, wb: &'a Workbook) -> Env<'a> { + Env { wb, active_sheet: wb.active_sheet, inputs: &self.inputs, output: self.output, item: self.item() } + } + + fn command(&mut self, s: &mut Session, path: &str, id: &str, params: &Json) -> std::result::Result<(), StepError> { + let fail = |msg: String| StepError::new(path, Some(id), msg); + match macro_policy(id, params, self.trust) { + Policy::Deny(why) => return Err(fail(format!("{id} can't run in a macro: {why}."))), + Policy::NeedsPermission(what) => { + if !self.permissions.iter().any(|p| p == id) { + return Err(fail(format!("{id} {what}. Add it to the macro's permissions to allow it."))); + } + if self.dry_run { + self.partial = Some((path.to_string(), format!("{id} {what}"))); + self.frames.clear(); + return Ok(()); + } + } + Policy::Allow | Policy::Ask(_) => {} + } + if self.dry_run && !previewable(id) { + self.partial = Some((path.to_string(), format!("{id} can't be previewed"))); + self.frames.clear(); + return Ok(()); + } + let resolved = { + let wb = &s.doc().map_err(|e| fail(e.to_string()))?.wb; + resolve::resolve_params(params, &self.env(wb)).map_err(|e| StepError::resolve(path, Some(id), e))? + }; + self.last_params = Some(resolved.clone()); + let queued = s.ui_requests.len(); + let r = s.execute(id, resolved); + // Macros never open dialogs or web pages; messages for the person stay queued. + let mut blocked = None; + let asked: Vec = s.ui_requests.drain(queued.min(s.ui_requests.len())..).collect(); + for q in asked { + match q { + UiRequest::Dialog(name, _) => blocked = Some(format!("{id} would open the {name} dialog; give the step the parameters it needs.")), + UiRequest::OpenUrl(_) => blocked = Some(format!("{id} would open a web page, which macros don't do.")), + UiRequest::EditCell(_) => {} + m @ UiRequest::Message(_) => s.ui_requests.push(m), + } + } + r.map_err(|e| fail(e.to_string()))?; + if let Some(b) = blocked { + return Err(fail(b)); + } + Ok(()) + } +} + +/// If's comparison of a cell's value with a step's `value`, the way Excel's `=`, `<`… compare. +fn compare(v: &Value, op: CompareOp, want: &Json) -> bool { + let empty = v.is_empty() || v.as_text() == Some(""); + let want_text = || match want { + Json::String(t) => t.clone(), + Json::Null => String::new(), + other => other.to_string(), + }; + let ord = || -> Option { + let w = match want { + Json::Null => Value::Empty, + Json::Bool(b) => Value::Bool(*b), + Json::Number(n) => Value::number(n.as_f64()?), + // "5" compares as a number with a number in the cell. + Json::String(t) => match (v, t.trim().parse::()) { + (Value::Number(_), Ok(n)) => Value::number(n), + _ => Value::text(t.as_str()), + }, + _ => return None, + }; + match (v, &w) { + (Value::Error(e), Value::Text(t)) => { + Some(if e.as_str().eq_ignore_ascii_case(t.trim()) { std::cmp::Ordering::Equal } else { std::cmp::Ordering::Less }) + } + (Value::Error(_), _) | (_, Value::Error(_)) | (Value::Array(_), _) => None, + _ => Some(gridcraft_core::value::compare(v, &w)), + } + }; + use std::cmp::Ordering::*; + match op { + CompareOp::Empty => empty, + CompareOp::NotEmpty => !empty, + CompareOp::Contains => v.display().to_lowercase().contains(&want_text().to_lowercase()), + CompareOp::Eq => ord() == Some(Equal), + CompareOp::Ne => ord() != Some(Equal), + CompareOp::Gt => ord() == Some(Greater), + CompareOp::Ge => matches!(ord(), Some(Greater | Equal)), + CompareOp::Lt => ord() == Some(Less), + CompareOp::Le => matches!(ord(), Some(Less | Equal)), + } +} + +// ---------------------------------------------------------------- footprint + +/// What a macro would change, from a dry run on the real workbook. +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Footprint { + pub sheets: Vec, + pub sheets_added: Vec, + pub sheets_removed: Vec, + pub objects_added: usize, + pub objects_changed: usize, + pub objects_removed: usize, + /// The dry run stopped early before this step; later steps aren't in the footprint. + #[serde(skip_serializing_if = "Option::is_none")] + pub partial: Option, + /// The step the run would fail at. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// A Stop step's message. + #[serde(skip_serializing_if = "Option::is_none")] + pub stopped: Option, +} + +/// The cells a macro would write on one sheet, outside its inputs. +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SheetFootprint { + pub sheet: String, + #[serde(skip)] + pub index: usize, + /// The written cells as ranges (at most 500 listed), and how many cells. + pub cells: Vec, + pub changed: usize, + /// Written cells that hold data now. + pub conflicts: Vec, + pub conflict_count: usize, + /// The block that holds every written cell. + #[serde(skip_serializing_if = "Option::is_none")] + pub bounds: Option, + #[serde(skip)] + pub bounds_range: Option, +} + +impl Footprint { + /// Cells that hold data and would be written, plus sheets and objects that would go. + pub fn conflict_count(&self) -> usize { + self.sheets.iter().map(|s| s.conflict_count).sum::() + self.sheets_removed.len() + self.objects_removed + } + + /// "C2:C9 on Sheet1, the sheet Old, 2 objects". + pub fn describe_conflicts(&self) -> String { + let mut parts: Vec = self + .sheets + .iter() + .filter(|s| s.conflict_count > 0) + .map(|s| { + let mut listed = s.conflicts.iter().take(3).cloned().collect::>().join(", "); + if s.conflicts.len() > 3 { + listed.push_str(", …"); + } + format!("{listed} on {}", s.sheet) + }) + .collect(); + parts.extend(self.sheets_removed.iter().map(|n| format!("the sheet {n}"))); + if self.objects_removed > 0 { + parts.push(format!("{} object{}", self.objects_removed, if self.objects_removed == 1 { "" } else { "s" })); + } + parts.join("; ") + } +} + +/// Session state a dry run may touch, put back afterwards. The workbook, its selection and its +/// history are put back by the dry run's undo group. +struct Saved { + active: usize, + docs: Vec, + sheet_selections: Vec, + unlocked_ranges: Vec<(String, String)>, + trust: Trust, + clipboard: Option, + prefs: crate::Prefs, + mode: crate::Mode, + journal: usize, + recording: Option, + ui_requests: usize, + format_painter: Option<(Arc, usize, RangeRef, bool)>, + last_find: Option, + hide_ruler: bool, + hidden_windows: Vec, + draw: (String, String, f32), + comments_pane: bool, + show_all_notes: bool, +} + +impl Saved { + fn take(s: &mut Session, doc: u64) -> Saved { + let d = s.docs.iter().find(|d| d.uid == doc); + Saved { + active: s.active, + docs: s.docs.iter().map(|d| d.uid).collect(), + sheet_selections: d.map(|d| d.sheet_selections.clone()).unwrap_or_default(), + unlocked_ranges: d.map(|d| d.unlocked_ranges.clone()).unwrap_or_default(), + trust: d.map_or(Trust::Local, |d| d.trust), + clipboard: s.clipboard.clone(), + prefs: s.prefs.clone(), + mode: s.mode, + journal: s.journal.len(), + recording: s.recording, + ui_requests: s.ui_requests.len(), + format_painter: s.format_painter.clone(), + last_find: s.last_find.clone(), + hide_ruler: s.hide_ruler, + hidden_windows: s.hidden_windows.clone(), + draw: (s.draw_tool.clone(), s.draw_color.clone(), s.draw_width), + comments_pane: s.comments_pane, + show_all_notes: s.show_all_notes, + } + } + + fn restore(self, s: &mut Session, doc: u64) { + s.docs.retain(|d| self.docs.contains(&d.uid)); + if let Some(d) = s.docs.iter_mut().find(|d| d.uid == doc) { + d.sheet_selections = self.sheet_selections; + d.unlocked_ranges = self.unlocked_ranges; + d.trust = self.trust; + } + s.active = self.active.min(s.docs.len().saturating_sub(1)); + s.clipboard = self.clipboard; + s.prefs = self.prefs; + s.mode = self.mode; + s.journal.truncate(self.journal); + s.recording = self.recording; + s.ui_requests.truncate(self.ui_requests); + s.format_painter = self.format_painter; + s.last_find = self.last_find; + s.hide_ruler = self.hide_ruler; + s.hidden_windows = self.hidden_windows; + (s.draw_tool, s.draw_color, s.draw_width) = self.draw; + s.comments_pane = self.comments_pane; + s.show_all_notes = self.show_all_notes; + } +} + +/// Runs the macro in dry-run mode on its workbook, puts everything back, and says what it would +/// change. +pub fn footprint(s: &mut Session, p: &Prepared) -> Result { + let i = s.docs.iter().position(|d| d.uid == p.doc).ok_or(EngineError::NoDocument)?; + s.set_active(i); + let saved = Saved::take(s, p.doc); + let before = s.doc()?.wb.clone(); + let token = s.group_begin("Check Macro")?; + if let Some(d) = s.docs.iter_mut().find(|d| d.uid == p.doc) { + d.touched = Some(Vec::new()); + } + let mut m = Machine::new(p, true); + let outcome = m.run_to_end(s); + let (after, touched) = match s.docs.iter_mut().find(|d| d.uid == p.doc) { + Some(d) => (d.wb.clone(), d.touched.take().unwrap_or_default()), + None => (before.clone(), Vec::new()), + }; + let rolled = s.group_rollback(token); + saved.restore(s, p.doc); + rolled?; + let (after, touched) = if matches!(m.stopped, Some((_, true))) { (before.clone(), Vec::new()) } else { (after, touched) }; + let mut fp = diff(&before, &after, &p.inputs, &touched); + fp.error = outcome.err(); + fp.stopped = m.stopped.map(|(msg, _)| msg); + fp.partial = m.partial.map(|(step, reason)| json!({"step": step, "reason": reason})); + Ok(fp) +} + +/// Compares the workbook before and after a dry run. `touched` adds the cells commands wrote +/// without changing them (a formula written again), so a shift moves everything the macro writes. +fn diff(before: &Workbook, after: &Workbook, inputs: &[Bound], touched: &[(String, CellRef)]) -> Footprint { + let mut fp = Footprint::default(); + // Sheets are matched by name; what's left is matched in order (a renamed sheet). + let mut pairs: Vec<(usize, Option)> = before.sheets.iter().enumerate().map(|(bi, sh)| (bi, after.sheet_index(&sh.name))).collect(); + let matched: BTreeSet = pairs.iter().filter_map(|(_, a)| *a).collect(); + let mut spare = (0..after.sheets.len()).filter(|a| !matched.contains(a)); + for (_, a) in pairs.iter_mut().filter(|(_, a)| a.is_none()) { + *a = spare.next(); + } + fp.sheets_added = spare.filter_map(|a| after.sheet(a).map(|s| s.name.clone())).collect(); + for (bi, ai) in pairs { + let Some(bs) = before.sheets.get(bi) else { continue }; + let Some(ai) = ai else { + fp.sheets_removed.push(bs.name.clone()); + continue; + }; + let Some(after_sheet) = after.sheets.get(ai) else { continue }; + let rewritten: BTreeSet = touched.iter().filter(|(n, _)| *n == after_sheet.name).map(|(_, c)| *c).collect(); + if Arc::ptr_eq(bs, after_sheet) && rewritten.is_empty() { + continue; + } + let (added, changed, removed) = objects(bs, after_sheet); + fp.objects_added += added; + fp.objects_changed += changed; + fp.objects_removed += removed; + let in_input = |c: CellRef| inputs.iter().any(|(_, p, _)| p.sheet == bi && p.range.contains(c)); + let mut written = Vec::new(); + let mut conflicts = Vec::new(); + let mut cells: BTreeSet = bs.cells.diff(&after_sheet.cells).into_iter().collect(); + cells.extend(rewritten.iter().copied()); + for c in cells { + if in_input(c) { + continue; + } + let (b, a) = (bs.cell(c), after_sheet.cell(c)); + if recalculated_only(b, a) && !rewritten.contains(&c) { + continue; + } + written.push(c); + if b.is_some_and(has_content) && content_differs(b, a) { + conflicts.push(c); + } + } + let Some(first) = written.first() else { continue }; + let bounds = written.iter().fold(RangeRef::cell(*first), |r, c| r.union(&RangeRef::cell(*c))); + fp.sheets.push(SheetFootprint { + sheet: bs.name.clone(), + index: bi, + changed: written.len(), + conflict_count: conflicts.len(), + cells: ranges(&mut written), + conflicts: ranges(&mut conflicts), + bounds: Some(bounds.a1()), + bounds_range: Some(bounds), + }); + } + fp +} + +/// Only a formula's cached value changed (a recalculation, not a write). +fn recalculated_only(b: Option<&Cell>, a: Option<&Cell>) -> bool { + match (b, a) { + (Some(b), Some(a)) => match (&b.formula, &a.formula) { + (Some(x), Some(y)) => x.text == y.text && x.array == y.array && b.style == a.style, + _ => false, + }, + _ => false, + } +} + +fn has_content(c: &Cell) -> bool { + c.formula.is_some() || !(c.value.is_empty() || c.value.as_text() == Some("")) +} + +fn content_differs(b: Option<&Cell>, a: Option<&Cell>) -> bool { + match (b, a) { + (Some(b), Some(a)) => match (&b.formula, &a.formula) { + (Some(x), Some(y)) => x.text != y.text, + (None, None) => b.value != a.value, + _ => true, + }, + (Some(b), None) => has_content(b), + _ => false, + } +} + +/// Objects added, changed and removed between two versions of a sheet. +fn objects(b: &Sheet, a: &Sheet) -> (usize, usize, usize) { + fn count(b: &[T], a: &[T], id: impl Fn(&T) -> u32) -> (usize, usize, usize) { + let added = a.iter().filter(|x| !b.iter().any(|y| id(y) == id(x))).count(); + let removed = b.iter().filter(|x| !a.iter().any(|y| id(y) == id(x))).count(); + let changed = b.iter().filter(|x| a.iter().any(|y| id(y) == id(x) && y != *x)).count(); + (added, changed, removed) + } + let parts = [count(&b.charts, &a.charts, |o| o.id), count(&b.images, &a.images, |o| o.id), count(&b.shapes, &a.shapes, |o| o.id)]; + parts.iter().fold((0, 0, 0), |t, p| (t.0 + p.0, t.1 + p.1, t.2 + p.2)) +} + +/// Cells as A1 ranges: runs along each row, then runs with the same columns on following rows. +pub(crate) fn ranges(cells: &mut [CellRef]) -> Vec { + cells.sort_unstable_by_key(|c| (c.row, c.col)); + let mut runs: Vec = Vec::new(); + for c in cells.iter() { + match runs.last_mut() { + Some(r) if r.end.row == c.row && r.end.col.checked_add(1) == Some(c.col) => r.end.col = c.col, + _ => runs.push(RangeRef::cell(*c)), + } + } + let mut merged: Vec = Vec::new(); + for r in runs { + // The block above with the same columns that ends on the row before. + match merged + .iter_mut() + .rev() + .take(64) + .find(|m| m.start.col == r.start.col && m.end.col == r.end.col && m.end.row.checked_add(1) == Some(r.start.row)) + { + Some(m) => m.end.row = r.end.row, + None => merged.push(r), + } + } + merged.iter().take(MAX_LISTED_RANGES).map(RangeRef::a1).collect() +} + +/// Inserts blank cells where the footprint lands (moving what is there down or right), so the +/// macro writes into empty cells. +fn make_room(s: &mut Session, fp: &Footprint, down: bool) -> Result<()> { + if !fp.sheets_removed.is_empty() || fp.objects_removed > 0 { + return Err(EngineError::Other("Shifting can't make room: the macro deletes sheets or objects.".into())); + } + let wb = s.doc()?.wb.clone(); + for sh in fp.sheets.iter().filter(|s| s.conflict_count > 0) { + let Some(bounds) = sh.bounds_range else { continue }; + let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; + let range = resolve::a1(Place { sheet: sh.index, range: bounds }, &env); + s.execute("home.insertCells", json!({"range": range, "shift": if down { "down" } else { "right" }}))?; + } + Ok(()) +} + +// ---------------------------------------------------------------- run + +/// A macro that passed its checks and is about to run. +pub struct Begun { + pub mac: Macro, + pub prepared: Prepared, + pub rule: OnConflict, + pub time_limit_ms: f64, + pub inputs: Json, +} + +/// The checks before a run, on the active workbook: the macro exists, the policy allows it, the +/// person approved it if it needs that, and its inputs are right. +pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result { + let d = s.doc()?; + let (doc, trust) = (d.uid, d.trust); + let m = super::find(&d.wb, name) + .and_then(|i| d.wb.macros.get(i)) + .cloned() + .ok_or_else(|| EngineError::Other(format!("There is no macro called '{name}'.")))?; + let asks = gate(&m, trust)?; + approve(s, &m, doc, &asks, p.get("approve").and_then(Json::as_bool).unwrap_or(false))?; + let prepared = prepare(s, &m, p.get("inputs"))?; + let rule = conflict_rule(p, &m)?; + let time_limit_ms = p.get("timeLimit").and_then(Json::as_f64).map(|t| (t * 1000.0).clamp(1.0, 600_000.0)).unwrap_or(DEFAULT_TIME_LIMIT_MS); + let inputs = prepared.inputs_json(&s.doc()?.wb); + Ok(Begun { mac: m, prepared, rule, time_limit_ms, inputs }) +} + +/// The footprint check and the output rule, inside the run's undo group: an error when the macro +/// would fail or write over data it may not, after shifting the data if the rule says so. Returns +/// whether it shifted. +pub fn make_way(s: &mut Session, b: &Begun) -> Result { + let (m, rule) = (&b.mac, b.rule); + if rule == OnConflict::Overwrite { + return Ok(false); + } + let fp = footprint(s, &b.prepared)?; + if let Some(e) = fp.error { + return Err(e.into_engine(&m.name)); + } + if fp.conflict_count() == 0 { + return Ok(false); + } + let what = fp.describe_conflicts(); + match rule { + OnConflict::Ask => Err(EngineError::Confirm(format!( + "Macro '{}' would write over cells that hold data ({what}). Overwrite them, or run again with onConflict shiftDown, shiftRight or fail", + m.name + ))), + OnConflict::Fail | OnConflict::Overwrite => { + Err(EngineError::Other(format!("Macro '{}' would write over cells that hold data ({what}). Nothing was changed.", m.name))) + } + OnConflict::ShiftDown | OnConflict::ShiftRight => { + make_room(s, &fp, rule == OnConflict::ShiftDown)?; + let again = footprint(s, &b.prepared)?; + if let Some(e) = again.error { + return Err(e.into_engine(&m.name)); + } + if again.conflict_count() > 0 { + return Err(EngineError::Other(format!( + "Macro '{}': shifting the data didn't make room ({}). Nothing was changed.", + m.name, + again.describe_conflicts() + ))); + } + Ok(true) + } + } +} + +/// Runs macro `name` on the active workbook: `{inputs?, onConflict?, overwrite?, approve?, +/// timeLimit?}` (see `macro.run`). +pub fn run(s: &mut Session, name: &str, p: &Json) -> Result { + let b = begin(s, name, p)?; + let label = format!("Run Macro: {}", b.mac.name); + s.grouped(&label, |s| { + let shifted = make_way(s, &b)?; + let mut machine = Machine::new(&b.prepared, false); + machine.time_limit_ms = b.time_limit_ms; + let name = &b.mac.name; + machine.run_to_end(s).map_err(|e| e.into_engine(name))?; + match machine.stopped { + Some((msg, true)) => Err(EngineError::Other(format!("Macro '{name}' stopped: {msg} Nothing was changed."))), + stopped => { + let mut out = json!({"macro": name, "ran": machine.executed, "inputs": b.inputs}); + if let Some((msg, _)) = stopped { + out["stopped"] = json!(msg); + } + if shifted { + out["shifted"] = json!(b.rule.name()); + } + Ok(out) + } + } + }) +} + +/// The conflict rule for this run: `onConflict`, or `overwrite: true` (the answer to the +/// question), else the macro's own. +fn conflict_rule(p: &Json, m: &Macro) -> Result { + if let Some(t) = p.get("onConflict").and_then(Json::as_str) { + return OnConflict::parse(t) + .ok_or_else(|| EngineError::Other(format!("onConflict must be ask, fail, overwrite, shiftDown or shiftRight, not '{t}'."))); + } + if p.get("overwrite").and_then(Json::as_bool) == Some(true) { + return Ok(OnConflict::Overwrite); + } + Ok(m.output.on_conflict) +} + +/// `macro.check`: what running the macro would do, without changing anything. +pub fn check(s: &mut Session, name: &str, p: &Json) -> Result { + let d = s.doc()?; + let (doc, trust) = (d.uid, d.trust); + let m = super::find(&d.wb, name) + .and_then(|i| d.wb.macros.get(i)) + .cloned() + .ok_or_else(|| EngineError::Other(format!("There is no macro called '{name}'.")))?; + let mut out = json!({"macro": m.name, "ok": false, "onConflict": m.output.on_conflict.name()}); + let asks = match gate(&m, trust) { + Ok(a) => a, + Err(e) => { + out["errors"] = json!([e.to_string()]); + return Ok(out); + } + }; + let approved = s.macro_approvals.contains(&(doc, super::hash(&m))); + if !asks.is_empty() && !approved { + // Nothing from an unapproved macro runs, not even in a dry run. + out["needsApproval"] = json!(asks); + } + let prepared = match prepare(s, &m, p.get("inputs")) { + Ok(pr) => pr, + Err(e) => { + out["errors"] = json!([e.to_string()]); + return Ok(out); + } + }; + out["inputs"] = prepared.inputs_json(&s.doc()?.wb); + if let Some(o) = prepared.output { + let wb = &s.doc()?.wb; + out["output"] = json!(resolve::a1(o, &Env { wb, active_sheet: wb.active_sheet, inputs: &[], output: None, item: None })); + } + if !asks.is_empty() && !approved { + return Ok(out); + } + let fp = footprint(s, &prepared)?; + out["ok"] = json!(fp.error.is_none()); + out["conflicts"] = json!(fp.conflict_count()); + if let Some(e) = &fp.error { + out["errors"] = json!([e.describe()]); + } + out["footprint"] = serde_json::to_value(&fp).unwrap_or(Json::Null); + Ok(out) +} diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 9a28f14f9..f0845a54b 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -213,6 +213,11 @@ fn names() { assert_eq!(unique_name(&wb, &long).chars().count(), MAX_MACRO_NAME); assert_eq!(find(&wb, " MACRO1 "), Some(0)); assert_eq!(find(&wb, "Macro2"), None); + let a = sample_macro("A"); + let mut b = a.clone(); + assert_eq!(hash(&a), hash(&b)); + b.steps[0].note = "changed".into(); + assert_ne!(hash(&a), hash(&b)); } #[test] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b5ac30d43..74b7621eb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3344,4 +3344,5 @@ fn csv_as_text_keeps_identifier_columns_as_written() { let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap(); assert_eq!(crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(), csv.replace('\n', "\r\n").as_bytes()); } +mod macros; mod seams; diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs new file mode 100644 index 000000000..fcc418b56 --- /dev/null +++ b/crates/engine/src/tests/macros.rs @@ -0,0 +1,436 @@ +//! Macros: running, input checks, the footprint dry run, the policy and the limits. + +use std::sync::Arc; + +use serde_json::{Value as Json, json}; + +use super::{s, v}; +use crate::{EngineError, Session}; + +fn undo_labels(s: &Session) -> Vec { + s.doc().unwrap().undo.iter().map(|e| e.label.clone()).collect() +} + +fn set(s: &mut Session, cell: &str, input: &str) { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); +} + +/// Puts a macro into the active workbook (replacing one with the same name). +pub(super) fn put(s: &mut Session, m: Json) { + let m = crate::macros::parse_macro(&m).unwrap(); + let d = s.doc_mut().unwrap(); + let mut wb = (*d.wb).clone(); + wb.macros.retain(|x| !x.name.eq_ignore_ascii_case(&m.name)); + wb.macros.push(m); + d.wb = Arc::new(wb); + d.saved = d.wb.clone(); +} + +/// Names in B2:B5 and amounts in C2:C5 under a header row. +fn data(s: &mut Session) { + for (cell, input) in [("B2", "Name"), ("C2", "Amount"), ("B3", "Ann"), ("C3", "120"), ("B4", "Bo"), ("C4", "80"), ("B5", "Cy"), ("C5", "200")] { + set(s, cell, input); + } +} + +/// A macro that writes a label and a total next to its input and bolds the header. +fn totals() -> Json { + json!({ + "name": "Totals", + "inputs": [{"name": "Input", "header": true, "cols": [2, 2], "colTypes": ["text", "number"]}], + "output": {"anchor": "Input.right(2).cell(1,2)"}, + "steps": [ + {"command": "cell.set", "params": {"cell": "Output", "input": "Total"}}, + {"command": "cell.set", "params": {"cell": "Output.offset(1,0)", "input": "=SUM({{Input.data.col(2)}})"}}, + {"command": "home.bold", "params": {"range": "Input.row(1)"}} + ] + }) +} + +fn bold(s: &Session, a: &str) -> bool { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(gridcraft_core::CellRef::parse(a).unwrap())).font.bold +} + +#[test] +fn a_run_resolves_tokens_and_is_one_undo_step() { + let mut s = s(); + data(&mut s); + put(&mut s, totals()); + let before = undo_labels(&s); + let r = s.execute("macro.run", json!({"name": "totals", "inputs": {"Input": "B2:C5"}})).unwrap(); + assert_eq!(r["ran"], 3); + assert_eq!(r["inputs"], json!({"Input": "B2:C5"})); + assert_eq!(v(&s, "E2").display(), "Total"); + assert_eq!(v(&s, "E3").display(), "400"); + assert_eq!(s.doc().unwrap().wb.active().unwrap().input_text(gridcraft_core::CellRef::parse("E3").unwrap()), "=SUM(C3:C5)"); + assert!(bold(&s, "B2") && bold(&s, "C2")); + let after = undo_labels(&s); + assert_eq!(after.len(), before.len() + 1); + assert_eq!(after.last().unwrap(), "Run Macro: Totals"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "E2").is_empty() && v(&s, "E3").is_empty() && !bold(&s, "B2")); + assert_eq!(undo_labels(&s), before); + // The selection is the default input. + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("macro.run", json!({"name": "Totals"})).unwrap(); + assert_eq!(v(&s, "E3").display(), "400"); +} + +#[test] +fn inputs_are_checked_with_messages_that_name_the_cells() { + let mut s = s(); + data(&mut s); + set(&mut s, "C4", "eighty"); + set(&mut s, "C5", "TRUE"); + put(&mut s, totals()); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string(); + assert!(err.contains("Input column 2 (Amount) must hold numbers; C4 holds text, C5 holds TRUE or FALSE."), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "nothing ran"); + + // Shape: width, rows, a single cell, blanks, dates. + let mut m = totals(); + m["inputs"][0] = json!({"name": "Input", "header": true, "rows": [1, 2], "cols": [3, 3], "nonEmpty": true, "colTypes": ["any", "date"]}); + put(&mut s, m); + set(&mut s, "B4", ""); + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string(); + for needle in [ + "Input needs at most 2 data rows; B2:C5 has 3.", + "Input needs exactly 3 columns; B2:C5 has 2.", + "Input column 2 (Amount) must hold dates; C3 holds a number that isn't a date, C4 holds text, C5 holds TRUE or FALSE.", + "Input must have no blank cells; B4 is blank.", + ] { + assert!(err.contains(needle), "missing `{needle}` in {err}"); + } + let mut m = totals(); + m["inputs"][0] = json!({"name": "Input", "kind": "cell"}); + put(&mut s, m); + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string(); + assert!(err.contains("Input must be a single cell; B2:C5 is 8 cells."), "{err}"); + // A second input with no range, and a bad reference. + put(&mut s, json!({"name": "Two", "inputs": [{"name": "Input"}, {"name": "Rates"}], "steps": []})); + let err = s.execute("macro.run", json!({"name": "Two"})).unwrap_err().to_string(); + assert!(err.contains("Rates needs a range"), "{err}"); + let err = s.execute("macro.run", json!({"name": "Two", "inputs": {"Rates": "Nowhere!A1"}})).unwrap_err().to_string(); + assert!(err.contains("Rates: There is no sheet named 'Nowhere'."), "{err}"); + // Whole columns shrink to the cells in use. + put( + &mut s, + json!({"name": "Col", "inputs": [{"name": "Input", "rows": [1, 10]}], "steps": [{"command": "cell.set", "params": {"cell": "Input.below", "input": "end"}}], "output": {"onConflict": "overwrite"}}), + ); + s.execute("macro.run", json!({"name": "Col", "inputs": "B:B"})).unwrap(); + assert_eq!(v(&s, "B6").display(), "end"); + assert!(s.execute("macro.run", json!({"name": "nope"})).unwrap_err().to_string().contains("no macro called 'nope'")); +} + +#[test] +fn an_error_rolls_everything_back_and_names_the_step() { + let mut s = s(); + data(&mut s); + let mut m = totals(); + m["output"]["onConflict"] = json!("overwrite"); + m["steps"][2] = json!({"type": "repeat", "over": "Input.data", "body": [ + {"command": "home.italic", "params": {"range": "Row"}}, + {"command": "cell.set", "params": {"cell": "Row.cell(1,9)", "input": "=1/"}} + ]}); + put(&mut s, m); + let (before, undo) = (s.doc().unwrap().wb.clone(), undo_labels(&s)); + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap_err().to_string(); + assert!(err.starts_with("Macro 'Totals' stopped. Step 3.2 (cell.set):"), "{err}"); + assert!(err.ends_with("Nothing was changed."), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &before)); + assert_eq!(undo_labels(&s), undo); + assert!(s.doc().unwrap().redo.is_empty()); +} + +#[test] +fn conflicts_ask_fail_or_overwrite() { + let mut s = s(); + data(&mut s); + set(&mut s, "E3", "keep me"); + // A formula that reads what the macro writes: its value changes, but it isn't written. + set(&mut s, "H1", "=E3"); + put(&mut s, totals()); + let start = s.doc().unwrap().wb.clone(); + let r = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5"})); + let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") }; + assert!(q.contains("would write over cells that hold data (E3 on Sheet1)"), "{q}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "programmatic runs default to failing"); + let err = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "fail"})).unwrap_err().to_string(); + assert!(err.contains("Nothing was changed"), "{err}"); + assert!(s.execute("macro.run", json!({"name": "Totals", "onConflict": "sideways"})).is_err()); + + // macro.check shows the footprint and the conflict, and changes nothing. + let journal = s.journal.len(); + let sel = s.doc().unwrap().selection.clone(); + let c = s.execute("macro.check", json!({"name": "Totals", "inputs": "B2:C5"})).unwrap(); + assert_eq!(c["ok"], true); + assert_eq!(c["conflicts"], 1); + assert_eq!(c["output"], "E2"); + assert_eq!(c["footprint"]["sheets"][0]["cells"], json!(["E2:E3"])); + assert_eq!(c["footprint"]["sheets"][0]["conflicts"], json!(["E3"])); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + assert_eq!((s.journal.len(), &s.doc().unwrap().selection), (journal, &sel)); + + // The answer to the question overwrites. + s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "overwrite": true})).unwrap(); + assert_eq!(v(&s, "E3").display(), "400"); + assert_eq!(v(&s, "H1").display(), "400"); +} + +#[test] +fn shifting_makes_room_and_is_checked_again() { + for (rule, moved) in [("shiftDown", ["E4", "E5"]), ("shiftRight", ["F2", "F3"])] { + let mut s = s(); + data(&mut s); + set(&mut s, "E2", "old 1"); + set(&mut s, "E3", "old 2"); + put(&mut s, totals()); + let r = s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": rule})).unwrap(); + assert_eq!(r["shifted"], rule); + assert_eq!((v(&s, "E2").display(), v(&s, "E3").display()), ("Total".into(), "400".into()), "{rule}"); + assert_eq!((v(&s, moved[0]).display(), v(&s, moved[1]).display()), ("old 1".into(), "old 2".into()), "{rule}"); + // The shift and the run are one undo step. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "E2").display(), "old 1", "{rule}"); + assert!(v(&s, moved[1]).is_empty(), "{rule}"); + } + // A cell written with what it already holds isn't a conflict, but it moves with the others, + // so rows stay together. + let mut s = super::s(); + for (cell, input) in [("F2", "x"), ("G2", "b"), ("F3", "f3"), ("G3", "g3")] { + set(&mut s, cell, input); + } + put( + &mut s, + json!({"name": "Pair", "output": {"onConflict": "shiftDown"}, "steps": [ + {"command": "cell.set", "params": {"cell": "F2", "input": "a"}}, + {"command": "cell.set", "params": {"cell": "G2", "input": "b"}} + ]}), + ); + let c = s.execute("macro.check", json!({"name": "Pair"})).unwrap(); + assert_eq!((c["conflicts"].as_u64(), &c["footprint"]["sheets"][0]["cells"]), (Some(1), &json!(["F2:G2"]))); + s.execute("macro.run", json!({"name": "Pair"})).unwrap(); + let row = |s: &Session, r: u32| (v(s, &format!("F{r}")).display(), v(s, &format!("G{r}")).display()); + assert_eq!(row(&s, 2), ("a".into(), "b".into())); + assert_eq!(row(&s, 3), ("x".into(), "b".into())); + assert_eq!(row(&s, 4), ("f3".into(), "g3".into())); + // An anchor that moves with the shifted data still lands on it: the second check refuses. + let mut s = super::s(); + data(&mut s); + set(&mut s, "E2", "old"); + s.execute("formulas.defineName", json!({"name": "Target", "refersTo": "=Sheet1!$E$2"})).unwrap(); + put( + &mut s, + json!({"name": "Named", "steps": [{"command": "cell.set", "params": {"cell": "Name:Target", "input": "new"}}], "output": {"onConflict": "shiftDown"}}), + ); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Named"})).unwrap_err().to_string(); + assert!(err.contains("shifting the data didn't make room (E3 on Sheet1)"), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); +} + +#[test] +fn the_footprint_covers_other_sheets_objects_and_edges() { + let mut s = s(); + data(&mut s); + s.execute("home.insertSheet", json!({"name": "Notes"})).unwrap(); + set(&mut s, "A1", "note"); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + s.execute("insert.chart", json!({"range": "B2:C5", "type": "column", "at": "H2"})).unwrap(); + let chart = s.doc().unwrap().wb.sheet(0).unwrap().charts[0].id; + put( + &mut s, + json!({"name": "Wide", "output": {"onConflict": "fail"}, "steps": [ + {"command": "cell.set", "params": {"cell": "Notes!A1", "input": "x"}}, + {"command": "cell.set", "params": {"cell": "Notes!B2", "input": "y"}}, + {"command": "home.insertSheet", "params": {"name": "Extra"}}, + {"command": "sheet.activate", "params": {"sheet": "Sheet1"}}, + {"command": "insert.shape", "params": {"kind": "ellipse", "at": "J2"}}, + {"command": "object.delete", "params": {"kind": "chart", "id": chart}} + ]}), + ); + let c = s.execute("macro.check", json!({"name": "Wide", "inputs": {}})).unwrap(); + let fp = &c["footprint"]; + assert_eq!( + fp["sheets"], + json!([{"sheet": "Notes", "cells": ["A1", "B2"], "changed": 2, "conflicts": ["A1"], "conflictCount": 1, "bounds": "A1:B2"}]) + ); + assert_eq!(fp["sheetsAdded"], json!(["Extra"])); + assert_eq!((fp["objectsAdded"].as_u64(), fp["objectsRemoved"].as_u64()), (Some(1), Some(1))); + assert_eq!(c["conflicts"], 2, "the deleted chart and the note"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2, "the dry run's new sheet went away"); + let err = s.execute("macro.run", json!({"name": "Wide"})).unwrap_err().to_string(); + assert!(err.contains("(A1 on Notes; 1 object)"), "{err}"); + + // Running past the edge of the sheet fails before anything changes. + put( + &mut s, + json!({"name": "Edge", "inputs": [{"name": "Input"}], "steps": [{"command": "cell.set", "params": {"cell": "Input.offset(-3,0)", "input": "x"}}]}), + ); + let err = s.execute("macro.run", json!({"name": "Edge", "inputs": "B2"})).unwrap_err().to_string(); + assert!(err.contains("Step 1 (cell.set): 'Input.offset(-3,0)' runs past the top of the sheet."), "{err}"); + let c = s.execute("macro.check", json!({"name": "Edge", "inputs": "B2"})).unwrap(); + assert_eq!((c["ok"].as_bool(), c["footprint"]["error"]["edge"].as_bool()), (Some(false), Some(true))); +} + +#[test] +fn repeat_if_and_stop() { + let mut s = s(); + data(&mut s); + put( + &mut s, + json!({"name": "Flag", "inputs": [{"name": "Input", "header": true}], "output": {"onConflict": "overwrite"}, "steps": [ + {"type": "repeat", "over": "Input.data", "body": [ + {"type": "if", "cell": "Row.cell(1,2)", "op": "gt", "value": 100, + "then": [{"command": "cell.set", "params": {"cell": "Row.right", "input": "big"}}], + "else": [{"command": "cell.set", "params": {"cell": "Row.right", "input": "small"}}]}, + {"type": "if", "cell": "Row.cell(1,1)", "op": "eq", "value": "bo", "then": [{"type": "stop", "message": "Found Bo."}]} + ]}, + {"command": "cell.set", "params": {"cell": "A1", "input": "not reached"}} + ]}), + ); + let r = s.execute("macro.run", json!({"name": "Flag", "inputs": "B2:C5"})).unwrap(); + assert_eq!(r["stopped"], "Found Bo."); + assert_eq!((v(&s, "D3").display(), v(&s, "D4").display()), ("big".into(), "small".into())); + assert!(v(&s, "D5").is_empty() && v(&s, "A1").is_empty()); + assert_eq!(undo_labels(&s).last().unwrap(), "Run Macro: Flag", "a stop keeps the changes"); + // A Stop that rolls back is an error that changes nothing. + put( + &mut s, + json!({"name": "Guard", "output": {"onConflict": "overwrite"}, "steps": [ + {"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, + {"type": "if", "cell": "C3", "op": "notEmpty", "then": [{"type": "stop", "message": "C3 must be empty.", "rollback": true}]} + ]}), + ); + let err = s.execute("macro.run", json!({"name": "Guard"})).unwrap_err().to_string(); + assert_eq!(err, "Macro 'Guard' stopped: C3 must be empty. Nothing was changed."); + assert!(v(&s, "A1").is_empty()); + // Comparisons. + for (op, value, want) in [ + ("eq", json!(120), true), + ("eq", json!("120"), true), + ("ne", json!(120), false), + ("ge", json!(120), true), + ("lt", json!(120.5), true), + ("le", json!(100), false), + ("contains", json!("12"), true), + ("empty", json!(null), false), + ] { + put( + &mut s, + json!({"name": "Cmp", "output": {"onConflict": "overwrite"}, "steps": [ + {"type": "if", "cell": "C3", "op": op, "value": value, "then": [{"command": "cell.set", "params": {"cell": "A9", "input": "yes"}}], "else": [{"command": "cell.set", "params": {"cell": "A9", "input": "no"}}]} + ]}), + ); + s.execute("macro.run", json!({"name": "Cmp"})).unwrap(); + assert_eq!(v(&s, "A9").display(), if want { "yes" } else { "no" }, "{op} {value}"); + } + // If needs one cell. + put(&mut s, json!({"name": "Many", "steps": [{"type": "if", "cell": "A1:A2", "op": "empty"}]})); + assert!(s.execute("macro.run", json!({"name": "Many"})).unwrap_err().to_string().contains("If looks at one cell")); + // Disabled steps are skipped. + put( + &mut s, + json!({"name": "Off", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "A8", "input": "x"}, "enabled": false}]}), + ); + assert_eq!(s.execute("macro.run", json!({"name": "Off"})).unwrap()["ran"], 0); +} + +#[test] +fn macros_are_bounded() { + let mut s = s(); + put(&mut s, json!({"name": "Long", "steps": [{"type": "repeat", "over": "A1:A20", "max": 10, "body": [{"type": "stop"}]}]})); + let err = s.execute("macro.run", json!({"name": "Long"})).unwrap_err().to_string(); + assert!(err.contains("Repeat over A1:A20 has 20 rows, more than its limit of 10."), "{err}"); + // 400 × 400 passes is over the step limit. + put( + &mut s, + json!({"name": "Spin", "output": {"onConflict": "overwrite"}, "steps": [{"type": "repeat", "over": "A1:A400", "body": [ + {"type": "repeat", "over": "A1:A400", "body": [{"type": "if", "cell": "A1", "op": "empty"}]} + ]}]}), + ); + let err = s.execute("macro.run", json!({"name": "Spin"})).unwrap_err().to_string(); + assert!(err.contains("ran more than 100000 steps"), "{err}"); + let err = s.execute("macro.run", json!({"name": "Spin", "timeLimit": 0.001})).unwrap_err().to_string(); + assert!(err.contains("ran longer than"), "{err}"); +} + +#[test] +fn the_policy_keeps_macros_away_from_files_and_the_app() { + let mut s = s(); + for (id, params) in [ + ("file.saveAs", json!({"path": "/tmp/x.xlsx"})), + ("file.close", json!({})), + ("app.language.set", json!({"language": "de"})), + ("automate.runScript", json!({"name": "Script 1"})), + ("edit.undo", json!({})), + ("macro.run", json!({"name": "Self"})), + ] { + put( + &mut s, + json!({"name": "Bad", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}, {"command": id, "params": params}]}), + ); + let err = s.execute("macro.run", json!({"name": "Bad"})).unwrap_err().to_string(); + assert!(err.contains(&format!("Step 2 ({id}) can't run in a macro")), "{err}"); + assert!(v(&s, "A1").is_empty(), "{id}: nothing ran"); + } + // A disabled step isn't checked. + put(&mut s, json!({"name": "Bad", "steps": [{"command": "file.close", "enabled": false}]})); + s.execute("macro.run", json!({"name": "Bad"})).unwrap(); + + // Reading a file needs the permission, then the person's approval (once per macro content). + let dir = std::env::temp_dir().join(format!("gc-macro-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("in.csv"); + std::fs::write(&csv, "a,b\n1,2\n").unwrap(); + let step = json!({"command": "data.fromTextCsv", "params": {"path": csv.to_string_lossy(), "sheet": "Imported"}}); + put(&mut s, json!({"name": "Import", "steps": [step]})); + let err = s.execute("macro.run", json!({"name": "Import"})).unwrap_err().to_string(); + assert!(err.contains("Step 1 (data.fromTextCsv) reads a file. Add data.fromTextCsv to the macro's permissions"), "{err}"); + put(&mut s, json!({"name": "Import", "steps": [step], "permissions": ["data.fromTextCsv"]})); + let r = s.execute("macro.run", json!({"name": "Import"})); + let Err(EngineError::Approve(q)) = &r else { panic!("{r:?}") }; + assert_eq!(q, "Run macro 'Import'? It runs data.fromTextCsv, which reads a file."); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 1); + let c = s.execute("macro.check", json!({"name": "Import"})).unwrap(); + assert_eq!(c["needsApproval"], json!(["runs data.fromTextCsv, which reads a file"])); + assert!(c.get("footprint").is_none(), "nothing unapproved runs, even dry"); + s.execute("macro.run", json!({"name": "Import", "approve": true})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("macro.run", json!({"name": "Import"})).unwrap(); + // The dry run skips the file step and says so. + let c = s.execute("macro.check", json!({"name": "Import"})).unwrap(); + assert_eq!(c["footprint"]["partial"]["step"], "1"); + // Changing the macro asks again. + put(&mut s, json!({"name": "Import", "description": "changed", "steps": [step], "permissions": ["data.fromTextCsv"]})); + assert!(matches!(s.execute("macro.run", json!({"name": "Import"})), Err(EngineError::Approve(_)))); + let _ = std::fs::remove_dir_all(&dir); + + // Steps that would open a dialog fail instead. + put(&mut s, json!({"name": "Pic", "output": {"onConflict": "overwrite"}, "steps": [{"command": "insert.picture"}]})); + let err = s.execute("macro.run", json!({"name": "Pic"})).unwrap_err().to_string(); + assert!(err.contains("would open the insertPicture dialog"), "{err}"); + assert!(s.ui_requests.is_empty()); +} + +#[test] +fn macros_from_a_file_ask_before_running() { + let mut s = s(); + let mut wb = gridcraft_model::Workbook::new(); + wb.macros.push(crate::macros::parse_macro(&json!({"name": "Theirs", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "ran"}}]})).unwrap()); + let bytes = serde_json::to_vec(&wb).unwrap(); + s.execute("file.open", json!({"name": "theirs.json", "base64": crate::io::base64_encode(&bytes)})).unwrap(); + assert_eq!(s.doc().unwrap().trust, crate::Trust::FromFile); + let r = s.execute("macro.run", json!({"name": "Theirs"})); + let Err(EngineError::Approve(q)) = &r else { panic!("{r:?}") }; + assert_eq!(q, "Run macro 'Theirs'? It came with a workbook you opened."); + assert!(v(&s, "A1").is_empty()); + s.execute("macro.run", json!({"name": "Theirs", "approve": true})).unwrap(); + assert_eq!(v(&s, "A1").display(), "ran"); + // Approved once per macro content: the second run doesn't ask. + s.execute("macro.run", json!({"name": "Theirs"})).unwrap(); +}