From 54c08645b9335d3cc8d3cb9afbae455987b1beaf Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:15:12 -0400 Subject: [PATCH] Engine: macro.run and macro.check: input checks, footprint dry run, policy macro.run runs a macro as one undo step ("Run Macro: "); an error at any step rolls everything back and names the step and its path (3, 2.1 inside a Repeat, 4.then.1 inside an If). It works in four parts: - Policy (macro_policy). File, app, window, automate and macro commands and Undo and Redo never run in a macro. Reading or writing files (data.getData, data.fromTextCsv, insert.picture, Page Layout Background, or an export with a path) needs the command in the macro's permissions and the person's approval. A macro in a workbook opened from a file asks before it runs. Approval holds for the session until the macro's content changes; it is asked for with the new EngineError::Approve and given with approve: true. A step that would open a dialog or a web page fails instead. - Inputs. Each declared input is bound (given, its default, or the selection for the first one), whole columns shrink to the used range, and the shape and column types are checked with messages that name the cells: "Input column 2 (Amount) must hold numbers; C4 holds text". - Footprint. Unless the macro may overwrite, it first runs in dry-run mode on the real workbook inside an undo group that is rolled back, with the session's clipboard, journal, windows and the document's trust put back. CellStore::diff on every sheet, plus the cells commands wrote (DocState::touched), sheets and objects, gives what it would write outside its inputs; cells that only recalculated do not count. Writing over data, deleting a sheet or deleting an object is a conflict: ask (the new EngineError::Confirm, so programmatic runs fail unless they pass onConflict or overwrite), fail, overwrite, or shift the data down or right with Insert Cells and check again. A step that would fail, for example past the edge of the sheet, fails the run before anything changes. File steps and Solver stop the dry run early and are reported. - Steps run on a machine with an explicit frame stack: Repeat over the rows or columns of a range with Row or Column bound, If on one cell with Excel's comparisons, and Stop, which keeps the changes or rolls them back. A run executes at most 100,000 steps within a time limit (60 s by default, timeLimit to change; the web build has no clock, so only the step limit applies there). macro.check reports the bound inputs, the output anchor, the approval needed, the footprint and the conflicts without changing anything; nothing from an unapproved macro runs, not even as a dry run. --- crates/engine/src/cmd/macros.rs | 43 ++ crates/engine/src/cmd/mod.rs | 12 + crates/engine/src/lib.rs | 12 + crates/engine/src/macros/mod.rs | 10 + crates/engine/src/macros/policy.rs | 122 +++ crates/engine/src/macros/run.rs | 1134 ++++++++++++++++++++++++++++ crates/engine/src/macros/tests.rs | 5 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/macros.rs | 436 +++++++++++ 9 files changed, 1775 insertions(+) create mode 100644 crates/engine/src/cmd/macros.rs create mode 100644 crates/engine/src/macros/policy.rs create mode 100644 crates/engine/src/macros/run.rs create mode 100644 crates/engine/src/tests/macros.rs 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(); +}