Files
gridcraft/crates/engine/src/macros/run.rs
T

1357 lines
58 KiB
Rust

//! 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). A dry run
//! that stops early (a file step, Solver) proves nothing about later steps, so the run then
//! checks each step as it goes.
//! 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<String>,
pub message: String,
/// The step ran past the edge of the sheet.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub edge: bool,
/// The step would write over data and the conflict rule is ask: overwriting answers it.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub confirm: bool,
}
impl StepError {
fn new(step: &str, command: Option<&str>, message: impl Into<String>) -> StepError {
StepError { step: step.to_string(), command: command.map(str::to_string), message: message.into(), edge: false, confirm: 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 {
let text = format!("Macro '{name}' stopped. {} Nothing was changed.", self.describe());
if self.confirm { EngineError::Confirm(text) } else { EngineError::Other(text) }
}
}
// ---------------------------------------------------------------- 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<Vec<String>> {
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::fingerprint(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<Bound>,
pub output: Option<Place>,
}
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<Prepared> {
let d = s.doc()?;
let wb = &d.wb;
let mut inputs: Vec<Bound> = 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<String> {
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<String> {
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<String> = 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)]
pub struct Stopwatch {
#[cfg(not(target_arch = "wasm32"))]
start: std::time::Instant,
}
impl Stopwatch {
pub 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<f64> {
#[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<Vec<Step>>,
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<Loop>,
}
#[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<Bound>,
pub output: Option<Place>,
permissions: Vec<String>,
trust: Trust,
frames: Vec<Frame>,
/// 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<Json>,
/// Set when the footprint was partial: each step is checked for writes over data as it runs.
guard: Option<Guard>,
}
/// Checks each step of a real run for writes over data the footprint couldn't see.
#[derive(Clone, Debug)]
struct Guard {
/// The workbook when the run started, and for each sheet now, its index there (`None` for a
/// sheet the run added).
start: Arc<Workbook>,
origin: Vec<Option<usize>>,
/// Where the footprint stopped: "step 2 (file.exportCsv writes a file)".
partial: String,
/// The conflict rule is ask.
ask: bool,
}
impl Guard {
fn new(start: Arc<Workbook>, partial: &Json, ask: bool) -> Guard {
let text = |k: &str| partial.get(k).and_then(Json::as_str).unwrap_or_default().to_string();
let partial = format!("step {} ({})", text("step"), text("reason"));
Guard { origin: (0..start.sheets.len()).map(Some).collect(), start, partial, ask }
}
/// What one step wrote over that held data when the run started: cells outside the inputs,
/// sheets and objects it deleted ("B1 on Sheet1; 1 object"), or `""`.
fn check(&mut self, prev: &Workbook, after: &Workbook, inputs: &[Bound]) -> String {
let (pairs, _) = pair_sheets(prev, after);
let mut origin = vec![None; after.sheets.len()];
let mut parts = Vec::new();
let mut objects = 0usize;
for (pi, ai) in pairs {
// A sheet the run added holds nothing of the person's.
let Some(si) = self.origin.get(pi).copied().flatten() else { continue };
let Some(ss) = self.start.sheets.get(si) else { continue };
let Some(ai) = ai else {
parts.push(format!("the sheet {}", ss.name));
continue;
};
if let Some(o) = origin.get_mut(ai) {
*o = Some(si);
}
let (Some(ps), Some(sa)) = (prev.sheets.get(pi), after.sheets.get(ai)) else { continue };
if Arc::ptr_eq(ps, sa) {
continue;
}
let in_input = |c: CellRef| inputs.iter().any(|(_, p, _)| p.sheet == si && p.range.contains(c));
let mut hit: Vec<CellRef> = ps
.cells
.diff(&sa.cells)
.into_iter()
.filter(|&c| !in_input(c) && !recalculated_only(ps.cell(c), sa.cell(c)))
.filter(|&c| ss.cell(c).is_some_and(has_content) && content_differs(ss.cell(c), sa.cell(c)))
.collect();
if !hit.is_empty() {
let mut listed = ranges(&mut hit);
if listed.len() > 3 {
listed.truncate(3);
listed.push("…".into());
}
parts.push(format!("{} on {}", listed.join(", "), sa.name));
}
objects += deleted(&ss.charts, &ps.charts, &sa.charts, |o| o.id)
+ deleted(&ss.images, &ps.images, &sa.images, |o| o.id)
+ deleted(&ss.shapes, &ps.shapes, &sa.shapes, |o| o.id);
}
if objects > 0 {
parts.push(format!("{objects} object{}", if objects == 1 { "" } else { "s" }));
}
self.origin = origin;
parts.join("; ")
}
}
/// Objects of `prev` that `after` no longer has and that were there at the start.
fn deleted<T>(start: &[T], prev: &[T], after: &[T], id: impl Fn(&T) -> u32) -> usize {
let kept: BTreeSet<u32> = after.iter().map(&id).collect();
let had: BTreeSet<u32> = start.iter().map(&id).collect();
prev.iter().map(&id).filter(|i| !kept.contains(i) && had.contains(i)).count()
}
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,
guard: 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()
}
/// Starts the time limit again (the debugger gives each action its own).
pub fn restart_clock(&mut self) {
self.clock = Stopwatch::start();
}
/// The current step for the debugger: its path, kind, command, parameters as they would run
/// now, and the Repeat pass.
pub fn current_json(&self, wb: &Workbook) -> Json {
let Some((path, st)) = self.current() else { return Json::Null };
let env = self.env(wb);
let mut out = json!({"step": path, "breakpoint": st.breakpoint});
if !st.note.is_empty() {
out["note"] = json!(st.note);
}
match &st.kind {
StepKind::Command { id, params } => {
out["kind"] = json!("command");
out["command"] = json!(id);
match resolve::resolve_params(params, &env) {
Ok(p) => out["params"] = p,
Err(e) => out["paramsError"] = json!(e.message()),
}
}
StepKind::Repeat { over, of, .. } => {
out["kind"] = json!("repeat");
out["over"] = json!(resolve::resolve_ref(over, &env).map(|p| resolve::a1(p, &env)).unwrap_or_else(|e| e.message().to_string()));
out["of"] = json!(if *of == RepeatOf::Rows { "rows" } else { "columns" });
}
StepKind::If { cell, .. } => {
out["kind"] = json!("if");
out["cell"] = json!(resolve::resolve_ref(cell, &env).map(|p| resolve::a1(p, &env)).unwrap_or_else(|e| e.message().to_string()));
}
StepKind::Stop { message, .. } => {
out["kind"] = json!("stop");
out["message"] = json!(message);
}
}
if let Some((at, count)) = self.iteration() {
out["iteration"] = json!({"at": at, "of": count});
}
out
}
/// 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);
// The policy sees the parameters the command will get, after tokens became A1 text.
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))?
};
match (macro_policy(id, params, self.trust), macro_policy(id, &resolved, self.trust)) {
(_, Policy::Deny(why)) | (Policy::Deny(why), _) => return Err(fail(format!("{id} can't run in a macro: {why}."))),
(written, 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.")));
}
// The gate asked the person about this step only if it saw the file access.
if !matches!(written, Policy::NeedsPermission(_)) {
return Err(fail(format!("{id} {what} only once its parameters are filled in, so it wasn't approved.")));
}
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(());
}
self.last_params = Some(resolved.clone());
let prev = self.guard.as_ref().and_then(|_| s.docs.iter().find(|d| d.uid == self.doc)).map(|d| d.wb.clone());
let queued = s.ui_requests.len();
let r = s.execute(id, resolved);
// Macros never open dialogs or web pages, and never start editing a cell; messages for
// the person stay queued.
let mut blocked = None;
let asked: Vec<UiRequest> = 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(_) | UiRequest::InsertName(_) => {}
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));
}
if let (Some(g), Some(prev)) = (self.guard.as_mut(), prev) {
let after = s.docs.iter().find(|d| d.uid == self.doc).map(|d| d.wb.clone()).ok_or_else(|| fail("The workbook was closed.".into()))?;
let what = g.check(&prev, &after, &self.inputs);
if !what.is_empty() {
let msg = format!("It would write over data ({what}); the check before the run stopped at {} and couldn't see this.", g.partial);
return Err(StepError { confirm: g.ask, ..fail(msg) });
}
}
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<std::cmp::Ordering> {
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::<f64>()) {
(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 {
/// Steps executed by the end of the dry run that made this footprint (the run's one budget).
#[serde(skip)]
pub executed: u64,
/// The dry run wrote more cells than the touch log keeps, so writes that kept a cell's value
/// may be missing from `cells`: a shift cannot trust the block.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub log_full: bool,
pub sheets: Vec<SheetFootprint>,
pub sheets_added: Vec<String>,
pub sheets_removed: Vec<String>,
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<Json>,
/// The step the run would fail at.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<StepError>,
/// A Stop step's message.
#[serde(skip_serializing_if = "Option::is_none")]
pub stopped: Option<String>,
}
/// 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<String>,
pub changed: usize,
/// Written cells that hold data now.
pub conflicts: Vec<String>,
pub conflict_count: usize,
/// The block that holds every written cell.
#[serde(skip_serializing_if = "Option::is_none")]
pub bounds: Option<String>,
#[serde(skip)]
pub bounds_range: Option<RangeRef>,
}
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::<usize>() + 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<String> = self
.sheets
.iter()
.filter(|s| s.conflict_count > 0)
.map(|s| {
let mut listed = s.conflicts.iter().take(3).cloned().collect::<Vec<_>>().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<u64>,
/// The workbook as last saved: `sheet.activate` and the view commands move it to the
/// current workbook on a clean document, and the rollback puts the workbook back but not this.
saved: Option<Arc<Workbook>>,
sheet_selections: Vec<Selection>,
unlocked_ranges: Vec<(String, String)>,
trust: Trust,
clipboard: Option<crate::Clipboard>,
prefs: crate::Prefs,
mode: crate::Mode,
/// The journal's length and how many entries it had dropped from its front; a dry run that
/// pushes past the journal's limits drops more, which a length alone cannot see.
journal: usize,
journal_dropped: u64,
recording: Option<usize>,
ui_requests: usize,
format_painter: Option<(Arc<Workbook>, usize, RangeRef, bool)>,
last_find: Option<Json>,
hide_ruler: bool,
hidden_windows: Vec<usize>,
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(),
saved: d.map(|d| d.saved.clone()),
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(),
journal_dropped: s.journal_dropped(),
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) {
if let Some(saved) = self.saved {
d.saved = saved;
}
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;
// Entries the dry run pushed go; entries the journal dropped meanwhile are gone for good,
// and the recording's start moves back with them.
let dropped = usize::try_from(s.journal_dropped().saturating_sub(self.journal_dropped)).unwrap_or(usize::MAX);
s.truncate_journal(self.journal.saturating_sub(dropped));
s.recording = self.recording.map(|r| r.saturating_sub(dropped));
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. The dry run stops once `time_limit_ms` has passed on `clock`.
pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: f64, executed: u64) -> Result<Footprint> {
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);
(m.clock, m.time_limit_ms, m.executed) = (clock, time_limit_ms, executed);
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.log_full = touched.len() >= crate::cmd::TOUCH_LOG_LIMIT;
fp.executed = m.executed;
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();
let (pairs, added) = pair_sheets(before, after);
fp.sheets_added = added.into_iter().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<CellRef> = 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<CellRef> = 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
}
/// Each sheet of `before` with its index in `after` (`None` when deleted), and the sheets of
/// `after` that are new. Sheets are matched by name; what's left is matched in order (a renamed
/// sheet).
fn pair_sheets(before: &Workbook, after: &Workbook) -> (Vec<(usize, Option<usize>)>, Vec<usize>) {
let mut pairs: Vec<(usize, Option<usize>)> = before.sheets.iter().enumerate().map(|(bi, sh)| (bi, after.sheet_index(&sh.name))).collect();
let matched: BTreeSet<usize> = 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();
}
(pairs, spare.collect())
}
/// 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<T: PartialEq>(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<String> {
cells.sort_unstable_by_key(|c| (c.row, c.col));
let mut runs: Vec<RangeRef> = 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<RangeRef> = 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. The block is every written cell's, so a row the macro writes
/// stays together. A shift that would move one of the macro's inputs is refused: the inputs were
/// checked and bound where they are, and only the block's columns (or rows) would move.
fn make_room(s: &mut Session, fp: &Footprint, p: &Prepared, 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()));
}
if fp.log_full {
return Err(EngineError::Other("Shifting can't make room: the macro writes more cells than the footprint can follow.".into()));
}
let wb = s.doc()?.wb.clone();
let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None };
let mut moves = Vec::new();
for sh in fp.sheets.iter().filter(|s| s.conflict_count > 0) {
let Some(block) = sh.bounds_range else { continue };
// What the insertion moves: the block's columns from its top row down, or its rows from
// its left column rightwards.
let moved = |r: &RangeRef| {
if down {
r.start.col <= block.end.col && r.end.col >= block.start.col && r.end.row >= block.start.row
} else {
r.start.row <= block.end.row && r.end.row >= block.start.row && r.end.col >= block.start.col
}
};
// The output is where the macro writes, so moving what is there is the point; the second
// footprint catches data the shift moved into it.
if let Some((name, pl)) = p.inputs.iter().map(|(n, pl, _)| (n.as_str(), *pl)).find(|(_, pl)| pl.sheet == sh.index && moved(&pl.range)) {
return Err(EngineError::Other(format!(
"Shifting the data would move {name} ({}). Run with onConflict overwrite or fail, or make room first.",
resolve::a1(pl, &env)
)));
}
moves.push(resolve::a1(Place { sheet: sh.index, range: block }, &env));
}
for range in moves {
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<Begun> {
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 = time_limit(p);
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
/// the machine for the run and whether it shifted. The dry runs count towards the run's time
/// limit.
///
/// A partial footprint proves nothing about the steps it didn't reach, so then the machine
/// checks each step as it runs and stops the run at one that writes over data.
pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> {
let (m, rule) = (&b.mac, b.rule);
let mut machine = Machine::new(&b.prepared, false);
machine.time_limit_ms = b.time_limit_ms;
if rule == OnConflict::Overwrite {
return Ok((machine, false));
}
// The dry runs and the run share one step budget as they share one clock.
let mut fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?;
machine.executed = fp.executed;
if let Some(e) = fp.error.take() {
return Err(e.into_engine(&m.name));
}
let mut shifted = false;
if fp.conflict_count() > 0 {
let what = fp.describe_conflicts();
match rule {
OnConflict::Ask => {
return 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 => {
return 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, &b.prepared, rule == OnConflict::ShiftDown)?;
fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?;
machine.executed = fp.executed;
if let Some(e) = fp.error.take() {
return Err(e.into_engine(&m.name));
}
if fp.conflict_count() > 0 {
return Err(EngineError::Other(format!(
"Macro '{}': shifting the data didn't make room ({}). Nothing was changed.",
m.name,
fp.describe_conflicts()
)));
}
shifted = true;
}
}
}
if let Some(p) = &fp.partial {
let start = s.docs.iter().find(|d| d.uid == b.prepared.doc).ok_or(EngineError::NoDocument)?.wb.clone();
machine.guard = Some(Guard::new(start, p, rule == OnConflict::Ask));
}
Ok((machine, shifted))
}
/// 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<Json> {
let b = begin(s, name, p)?;
let label = format!("Run Macro: {}", b.mac.name);
s.grouped(&label, |s| {
let (mut machine, shifted) = make_way(s, &b)?;
let name = &b.mac.name;
// `ran` counts the run's own steps; the budget also holds the dry runs'.
let before_run = machine.executed;
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.saturating_sub(before_run), "inputs": b.inputs});
if let Some((msg, _)) = stopped {
out["stopped"] = json!(msg);
}
if shifted {
out["shifted"] = json!(b.rule.name());
}
Ok(out)
}
}
})
}
/// The run's `timeLimit` (seconds) in milliseconds, or the default.
fn time_limit(p: &Json) -> f64 {
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)
}
/// The conflict rule for this run: `onConflict`, else the macro's own. `overwrite: true` is the
/// answer to the question, so it turns `ask` into `overwrite` whichever way `ask` was given.
fn conflict_rule(p: &Json, m: &Macro) -> Result<OnConflict> {
let rule = match p.get("onConflict").and_then(Json::as_str) {
Some(t) => OnConflict::parse(t)
.ok_or_else(|| EngineError::Other(format!("onConflict must be ask, fail, overwrite, shiftDown or shiftRight, not '{t}'.")))?,
None => m.output.on_conflict,
};
if rule == OnConflict::Ask && p.get("overwrite").and_then(Json::as_bool) == Some(true) {
return Ok(OnConflict::Overwrite);
}
Ok(rule)
}
/// `macro.check`: what running the macro would do, without changing anything.
pub fn check(s: &mut Session, name: &str, p: &Json) -> Result<Json> {
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::fingerprint(&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, Stopwatch::start(), time_limit(p), 0)?;
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)
}