From 3bc1d91636c15e5dce4897c0ea663360e672007d Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:09:52 -0400 Subject: [PATCH] Engine: macro tokens, resolved to A1 references when a step runs Step parameters can name where they act through tokens that are worked out when the step runs, so a macro made on one block of data works on another: a declared input (Input, or any other input's name), Output (the output rule's anchor), Row or Column (the current item of the Repeat step around the step) and Name:Total (a defined name or a table's data). Methods narrow or move the range: col(n) or col("Header"), row(n), cols(a..b), rows(2..), cell(r,c) relative to the top-left cell, offset(r,c), resize(h,w), data (without the header row), below(n) and right(n). Methods chain. A token is written as {"$ref": "..."} anywhere, as the whole string of a reference parameter (cell, range, target, keys ending in Cell or Range and similar), or as {{...}} inside any string, which is how a formula refers to its input. Other text parameters are never read as tokens, so a cell can still hold the word Row and a chart can still be of type column. Every token becomes A1 text, sheet-qualified when it is on another sheet, before the command runs, so no existing command changes. Plain A1 references in a step stay absolute. A reference that runs past the edge of the sheet is reported apart from other mistakes, so the footprint check can tell the two apart. --- crates/engine/src/macros/mod.rs | 2 + crates/engine/src/macros/resolve.rs | 650 ++++++++++++++++++++++++++++ 2 files changed, 652 insertions(+) create mode 100644 crates/engine/src/macros/resolve.rs diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 38016049d..fee539201 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -14,6 +14,8 @@ use serde_json::{Value as Json, json}; use crate::cmd::stash; +pub mod resolve; + /// The hidden-data key prefix of a stored macro (`macro_`). const MACRO_KEY: &str = "macro_"; diff --git a/crates/engine/src/macros/resolve.rs b/crates/engine/src/macros/resolve.rs new file mode 100644 index 000000000..8abf68ad4 --- /dev/null +++ b/crates/engine/src/macros/resolve.rs @@ -0,0 +1,650 @@ +//! Macro tokens: references in step parameters that are worked out when the step runs, so a +//! macro made on one block of data works on another. +//! +//! A token is a base followed by methods: +//! +//! - bases: a declared input (`Input`, `Rates`), `Output` (the output rule's anchor), `Row` or +//! `Column` (the current item of the Repeat step around it), and `Name:Total` (a defined name or +//! a table's data); +//! - methods, 1-based and chainable: `.col(n)` or `.col("Amount")` (by header), `.row(n)`, +//! `.cols(a..b)`, `.rows(a..)`, `.cell(r,c)` (relative to the top-left cell; any whole numbers, +//! so `cell(0,1)` is the cell above), `.offset(r,c)`, `.resize(h,w)`, `.data` (without the +//! header row), `.below(n)` and `.right(n)` (the n rows below or columns right of the range). +//! +//! In parameters a token is written three ways: `{"$ref": "Input.col(2)"}` anywhere; the whole +//! string `"Input.col(2)"` in a reference parameter (`cell`, `range`, `target`, `to`, keys ending +//! in `Cell` or `Range`…); or `{{Input.col(2)}}` inside any string (`"=SUM({{Input.col(2)}})"`). +//! Each becomes A1 text before the command runs, so commands never see a token. Plain A1 +//! references in steps are absolute: they don't follow row inserts (as in VBA); a `Name:` anchor +//! does. + +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; +use gridcraft_model::{RepeatOf, Workbook}; +use serde_json::{Map, Value as Json}; + +/// A resolved reference: a block of cells on a sheet. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Place { + pub sheet: usize, + pub range: RangeRef, +} + +/// Why a reference couldn't be worked out. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ResolveError { + /// It runs past the edge of the sheet. + Edge(String), + /// Anything else (an unknown input, a malformed token). + Bad(String), +} + +impl ResolveError { + pub fn message(&self) -> &str { + match self { + ResolveError::Edge(m) | ResolveError::Bad(m) => m, + } + } +} + +impl std::fmt::Display for ResolveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.message()) + } +} + +type R = Result; + +fn bad(msg: impl Into) -> R { + Err(ResolveError::Bad(msg.into())) +} + +/// What tokens refer to while a step runs. +#[derive(Clone, Copy, Debug)] +pub struct Env<'a> { + pub wb: &'a Workbook, + /// The sheet a reference without a sheet name is on. + pub active_sheet: usize, + /// Declared inputs, with whether each has a header row. + pub inputs: &'a [(String, Place, bool)], + pub output: Option, + /// The innermost Repeat step's current row or column. + pub item: Option<(RepeatOf, Place)>, +} + +/// Parameter keys that hold references, where a whole-string token is resolved. Elsewhere a +/// string is text (a cell may hold "Row", a chart's type is "column"), and a token needs +/// `{"$ref": …}` or `{{…}}`. +const REF_KEYS: &[&str] = &[ + "active", + "anchor", + "area", + "areas", + "at", + "cell", + "cells", + "changing", + "copyTo", + "destination", + "from", + "fromCell", + "location", + "objective", + "over", + "range", + "ranges", + "reference", + "resultCells", + "source", + "sources", + "target", + "to", + "variables", +]; + +/// Whether a parameter called `key` holds a reference. +pub fn is_ref_key(key: &str) -> bool { + let k = key.to_ascii_lowercase(); + REF_KEYS.iter().any(|r| r.eq_ignore_ascii_case(key)) || ["cell", "cells", "range", "ranges", "ref"].iter().any(|end| k.ends_with(end)) +} + +/// The most nesting [`resolve_params`] walks into. +const MAX_JSON_DEPTH: usize = 64; + +/// Whether `text` starts like a token (its base is one this environment knows). +pub fn is_token(text: &str, env: &Env) -> bool { + let t = text.trim(); + if t.get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) { + return true; + } + let n: usize = t.chars().take_while(|c| c.is_alphanumeric() || *c == '_').map(char::len_utf8).sum(); + let base = t.get(..n).unwrap_or(""); + is_builtin(base) || env.inputs.iter().any(|(n, ..)| n.eq_ignore_ascii_case(base)) +} + +fn is_builtin(base: &str) -> bool { + ["Output", "Row", "Column"].iter().any(|b| b.eq_ignore_ascii_case(base)) +} + +/// Resolves a token, or a plain reference (`B2`, `Sheet2!A1:C3`, a defined name). +pub fn resolve_ref(text: &str, env: &Env) -> R { + let t = text.trim(); + if is_token(t, env) { + return resolve_token(t, env); + } + plain_ref(t, env) +} + +/// `A1`, `'My Sheet'!A1:B2` or a defined name or table. +fn plain_ref(t: &str, env: &Env) -> R { + let (sheet, body) = crate::cmd::split_sheet(t); + let si = match &sheet { + Some(name) => env.wb.sheet_index(name).ok_or_else(|| ResolveError::Bad(format!("There is no sheet named '{name}'.")))?, + None => env.active_sheet, + }; + if let Some(range) = RangeRef::parse(body) { + return Ok(Place { sheet: si, range }); + } + if sheet.is_none() { + return named(t, env); + } + bad(format!("'{t}' isn't a reference.")) +} + +/// A defined name (by its formula) or a table (its data rows). +fn named(name: &str, env: &Env) -> R { + if let Some(n) = env.wb.name(name, env.active_sheet) { + let f = n.formula.trim().trim_start_matches('=').replace('$', ""); + let (sheet, body) = crate::cmd::split_sheet(&f); + let si = match sheet { + Some(s) => env.wb.sheet_index(&s), + None => n.scope.or(Some(env.active_sheet)), + }; + if let (Some(sheet), Some(range)) = (si, RangeRef::parse(body)) { + return Ok(Place { sheet, range }); + } + return bad(format!("The name '{name}' doesn't refer to a range.")); + } + if let Some((si, ti)) = env.wb.table(name) + && let Some(t) = env.wb.sheet(si).and_then(|sh| sh.tables.get(ti)) + { + return Ok(Place { sheet: si, range: t.data_range().unwrap_or(t.range) }); + } + bad(format!("There is no name or table called '{name}'.")) +} + +/// Resolves a token (see the module docs). +pub fn resolve_token(text: &str, env: &Env) -> R { + let t = text.trim(); + let mut p = Parser { s: t, at: 0 }; + let (mut place, mut header) = p.base(env)?; + while p.eat('.') { + let name = p.ident(); + if name.is_empty() { + return bad(format!("'{t}': expected a method name after '.'.")); + } + let args = if p.eat('(') { p.args(t)? } else { vec![] }; + let r = place.range; + let range = match (name.to_ascii_lowercase().as_str(), args.as_slice()) { + ("col", [Arg::Int(n)]) => sub(r, None, Some((*n, *n)), t)?, + ("col", [Arg::Text(h)]) => { + let c = header_col(env, place, h).ok_or_else(|| ResolveError::Bad(format!("'{t}': no column is headed '{h}'.")))?; + sub(r, None, Some((c, c)), t)? + } + ("row", [Arg::Int(n)]) => sub(r, Some((*n, *n)), None, t)?, + ("cols", [Arg::Span(a, b)]) => sub(r, None, Some(span(*a, *b, i64::from(r.width()))), t)?, + ("rows", [Arg::Span(a, b)]) => sub(r, Some(span(*a, *b, i64::from(r.height()))), None, t)?, + ("cell", [Arg::Int(row), Arg::Int(col)]) => { + let c = shift(r.start, row - 1, col - 1, t)?; + RangeRef::cell(c) + } + ("offset", [Arg::Int(dr), Arg::Int(dc)]) => RangeRef::new(shift(r.start, *dr, *dc, t)?, shift(r.end, *dr, *dc, t)?), + ("resize", [Arg::Int(h), Arg::Int(w)]) => { + if *h < 1 || *w < 1 { + return bad(format!("'{t}': resize needs at least 1 row and 1 column.")); + } + RangeRef::new(r.start, shift(r.start, h - 1, w - 1, t)?) + } + ("data", []) => { + if header && r.height() > 1 { + RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end) + } else { + r + } + } + ("below", []) | ("below", [Arg::Int(_)]) => { + let n = arg_or_one(&args); + let top = shift(CellRef::new(r.end.row, r.start.col), 1, 0, t)?; + RangeRef::new(top, shift(CellRef::new(r.end.row, r.end.col), n, 0, t)?) + } + ("right", []) | ("right", [Arg::Int(_)]) => { + let n = arg_or_one(&args); + let left = shift(CellRef::new(r.start.row, r.end.col), 0, 1, t)?; + RangeRef::new(left, shift(r.end, 0, n, t)?) + } + _ => { + return bad(format!( + "'{t}': '{name}' with these arguments isn't a token method (col, row, cols, rows, cell, offset, resize, data, below, right)." + )); + } + }; + // After a method the result is plain cells: `data` and `col("…")` no longer apply. + header = false; + place = Place { sheet: place.sheet, range }; + } + p.skip_ws(); + if p.at < p.s.len() { + return bad(format!("'{t}' isn't a token: unexpected '{}'.", p.s.get(p.at..).unwrap_or(""))); + } + Ok(place) +} + +fn arg_or_one(args: &[Arg]) -> i64 { + match args.first() { + Some(Arg::Int(n)) => (*n).max(1), + _ => 1, + } +} + +/// `a..b` with open ends filled from 1 and `len`. +fn span(a: Option, b: Option, len: i64) -> (i64, i64) { + (a.unwrap_or(1), b.unwrap_or(len)) +} + +/// Rows `rows` and columns `cols` (1-based, inclusive) of `r`; `None` keeps them all. +fn sub(r: RangeRef, rows: Option<(i64, i64)>, cols: Option<(i64, i64)>, t: &str) -> R { + let (h, w) = (i64::from(r.height()), i64::from(r.width())); + let (r0, r1) = rows.unwrap_or((1, h)); + let (c0, c1) = cols.unwrap_or((1, w)); + if r0 < 1 || c0 < 1 || r1 < r0 || c1 < c0 || r1 > h || c1 > w { + return bad(format!("'{t}' is outside the range it starts from ({} rows by {} columns).", h, w)); + } + Ok(RangeRef::new(shift(r.start, r0 - 1, c0 - 1, t)?, shift(r.start, r1 - 1, c1 - 1, t)?)) +} + +fn shift(c: CellRef, dr: i64, dc: i64, t: &str) -> R { + let row = i64::from(c.row).checked_add(dr); + let col = i64::from(c.col).checked_add(dc); + match (row, col) { + (Some(row), Some(col)) if (0..i64::from(MAX_ROWS)).contains(&row) && (0..i64::from(MAX_COLS)).contains(&col) => { + Ok(CellRef::new(row as u32, col as u32)) + } + (Some(row), Some(_)) if row < 0 => Err(ResolveError::Edge(format!("'{t}' runs past the top of the sheet."))), + (Some(row), Some(_)) if row >= i64::from(MAX_ROWS) => Err(ResolveError::Edge(format!("'{t}' runs past the last row of the sheet."))), + _ => Err(ResolveError::Edge(format!("'{t}' runs past the edge of the sheet."))), + } +} + +/// The 1-based column of `place` whose header (first row) reads `h`. +fn header_col(env: &Env, place: Place, h: &str) -> Option { + let sh = env.wb.sheet(place.sheet)?; + let r = place.range; + (r.start.col..=r.end.col) + .position(|c| crate::display::cell_text(env.wb, sh, CellRef::new(r.start.row, c)).trim().eq_ignore_ascii_case(h.trim())) + .map(|i| i as i64 + 1) +} + +#[derive(Debug)] +enum Arg { + Int(i64), + Span(Option, Option), + Text(String), +} + +struct Parser<'a> { + s: &'a str, + at: usize, +} + +impl Parser<'_> { + fn rest(&self) -> &str { + self.s.get(self.at..).unwrap_or("") + } + fn skip_ws(&mut self) { + let n = self.rest().len() - self.rest().trim_start().len(); + self.at += n; + } + fn eat(&mut self, c: char) -> bool { + self.skip_ws(); + if self.rest().starts_with(c) { + self.at += c.len_utf8(); + true + } else { + false + } + } + fn ident(&mut self) -> String { + self.skip_ws(); + let n: usize = self.rest().chars().take_while(|c| c.is_alphanumeric() || *c == '_').map(char::len_utf8).sum(); + let out = self.rest().get(..n).unwrap_or("").to_string(); + self.at += n; + out + } + fn int(&mut self) -> Option { + self.skip_ws(); + let neg = self.rest().starts_with('-'); + let start = self.at + usize::from(neg); + let digits: usize = self.s.get(start..).unwrap_or("").chars().take_while(char::is_ascii_digit).count(); + if digits == 0 || digits > 9 { + return None; + } + let n: i64 = self.s.get(start..start + digits)?.parse().ok()?; + self.at = start + digits; + Some(if neg { -n } else { n }) + } + /// The base of a token and whether it has a header row. + fn base(&mut self, env: &Env) -> R<(Place, bool)> { + self.skip_ws(); + if self.rest().get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) { + self.at += 5; + // A name runs to the first `.` followed by a method, or the end. + let n: usize = self.rest().chars().take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '\\')).map(char::len_utf8).sum(); + let name = self.rest().get(..n).unwrap_or("").to_string(); + self.at += n; + if name.is_empty() { + return bad("'Name:' needs a defined name after it."); + } + return Ok((named(&name, env)?, false)); + } + let base = self.ident(); + if base.eq_ignore_ascii_case("Output") { + return env.output.map(|p| (p, false)).ok_or_else(|| ResolveError::Bad("'Output' is used, but the macro has no output anchor.".into())); + } + if base.eq_ignore_ascii_case("Row") || base.eq_ignore_ascii_case("Column") { + return match env.item { + Some((_, p)) => Ok((p, false)), + None => bad(format!("'{base}' only means something inside a Repeat step.")), + }; + } + match env.inputs.iter().find(|(n, ..)| n.eq_ignore_ascii_case(&base)) { + Some((_, p, header)) => Ok((*p, *header)), + None => bad(format!("'{base}' isn't an input of this macro.")), + } + } + fn args(&mut self, t: &str) -> R> { + let mut out = Vec::new(); + if self.eat(')') { + return Ok(out); + } + loop { + self.skip_ws(); + if self.rest().starts_with('"') { + self.at += 1; + let n = self.rest().find('"').ok_or_else(|| ResolveError::Bad(format!("'{t}': a quote isn't closed.")))?; + out.push(Arg::Text(self.rest().get(..n).unwrap_or("").to_string())); + self.at += n + 1; + } else { + let a = self.int(); + self.skip_ws(); + if self.rest().starts_with("..") { + self.at += 2; + let b = self.int(); + out.push(Arg::Span(a, b)); + } else { + out.push(Arg::Int(a.ok_or_else(|| ResolveError::Bad(format!("'{t}': expected a whole number.")))?)); + } + } + if out.len() > 4 { + return bad(format!("'{t}': too many arguments.")); + } + if self.eat(')') { + return Ok(out); + } + if !self.eat(',') { + return bad(format!("'{t}': expected ',' or ')'.")); + } + } + } +} + +/// `place` as A1 text, with the sheet name when it isn't the active sheet. +pub fn a1(place: Place, env: &Env) -> String { + if place.sheet == env.active_sheet { + return place.range.a1(); + } + let name = env.wb.sheet(place.sheet).map(|s| s.name.as_str()).unwrap_or(""); + format!("{}!{}", quote_sheet(name), place.range.a1()) +} + +/// `Sheet1` or `'My Sheet'` (quotes doubled). +pub fn quote_sheet(name: &str) -> String { + if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') && !name.starts_with(|c: char| c.is_ascii_digit()) { + name.to_string() + } else { + format!("'{}'", name.replace('\'', "''")) + } +} + +/// Step parameters with every token replaced by A1 text. +pub fn resolve_params(params: &Json, env: &Env) -> R { + walk(params, None, env, 0) +} + +fn walk(v: &Json, key: Option<&str>, env: &Env, depth: usize) -> R { + if depth > MAX_JSON_DEPTH { + return bad("The step's parameters nest too deeply."); + } + Ok(match v { + Json::Object(o) => { + if o.len() == 1 + && let Some(Json::String(t)) = o.get("$ref") + { + return Ok(Json::String(a1(resolve_ref(t, env)?, env))); + } + let mut out = Map::new(); + for (k, x) in o { + out.insert(k.clone(), walk(x, Some(k), env, depth + 1)?); + } + Json::Object(out) + } + // Items of a list keep their key's kind (`ranges: ["Input", …]`). + Json::Array(a) => Json::Array(a.iter().map(|x| walk(x, key, env, depth + 1)).collect::>>()?), + Json::String(s) => { + if key.is_some_and(is_ref_key) && is_token(s, env) { + Json::String(a1(resolve_token(s, env)?, env)) + } else { + Json::String(interpolate(s, env)?) + } + } + other => other.clone(), + }) +} + +/// Replaces each `{{token}}` in `s`. +pub fn interpolate(s: &str, env: &Env) -> R { + if !s.contains("{{") { + return Ok(s.to_string()); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find("{{") { + out.push_str(rest.get(..i).unwrap_or("")); + let after = rest.get(i + 2..).unwrap_or(""); + let Some(j) = after.find("}}") else { + out.push_str(rest.get(i..).unwrap_or("")); + return Ok(out); + }; + let token = after.get(..j).unwrap_or(""); + out.push_str(&a1(resolve_ref(token, env)?, env)); + rest = after.get(j + 2..).unwrap_or(""); + } + out.push_str(rest); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn wb() -> Workbook { + let mut wb = Workbook::new(); + wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("My Data"))); + let sh = wb.sheet_mut(0).unwrap(); + for (i, h) in ["Name", "Amount", "Date"].iter().enumerate() { + sh.set_value(CellRef::new(1, 1 + i as u32), gridcraft_core::Value::text(*h)); + } + wb.names.push(gridcraft_model::DefinedName { + name: "Total".into(), + scope: None, + formula: "Sheet1!$F$20".into(), + comment: String::new(), + hidden: false, + }); + wb.names.push(gridcraft_model::DefinedName { + name: "Rates".into(), + scope: None, + formula: "'My Data'!$A$1:$A$5".into(), + comment: String::new(), + hidden: false, + }); + wb + } + + fn place(a1: &str) -> Place { + Place { sheet: 0, range: RangeRef::parse(a1).unwrap() } + } + + fn check(env: &Env, token: &str, want: &str) { + match resolve_ref(token, env) { + Ok(p) => assert_eq!(a1(p, env), want, "{token}"), + Err(e) => panic!("{token}: {e}"), + } + } + + #[test] + fn every_token_resolves() { + let wb = wb(); + // Input B2:D11 with a header row; Other on another sheet. + let inputs = vec![ + ("Input".to_string(), place("B2:D11"), true), + ("Other".to_string(), Place { sheet: 1, range: RangeRef::parse("C3:C4").unwrap() }, false), + ]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("G2")), item: Some((RepeatOf::Rows, place("B5:D5"))) }; + for (token, want) in [ + ("Input", "B2:D11"), + ("input", "B2:D11"), + ("Input.col(2)", "C2:C11"), + ("Input.col(\"Amount\")", "C2:C11"), + ("Input.col(\"date\").data", "D2:D11"), + ("Input.data", "B3:D11"), + ("Input.data.col(2)", "C3:C11"), + ("Input.row(1)", "B2:D2"), + ("Input.rows(2..)", "B3:D11"), + ("Input.rows(2..4)", "B3:D5"), + ("Input.rows(..2)", "B2:D3"), + ("Input.cols(2..3)", "C2:D11"), + ("Input.cell(1,1)", "B2"), + ("Input.cell(3,2)", "C4"), + ("Input.cell(0,1)", "B1"), + ("Input.cell(12,3)", "D13"), + ("Input.offset(0,4)", "F2:H11"), + ("Input.offset(-1,-1)", "A1:C10"), + ("Input.cell(1,1).resize(2,3)", "B2:D3"), + ("Input.below()", "B12:D12"), + ("Input.below(2)", "B12:D13"), + ("Input.col(2).below", "C12"), + ("Input.right", "E2:E11"), + (" Input . col( 2 ) ", "C2:C11"), + ("Output", "G2"), + ("Output.offset(1,0)", "G3"), + ("Row", "B5:D5"), + ("Row.cell(1,2)", "C5"), + ("Column", "B5:D5"), + ("Name:Total", "F20"), + ("Name:Rates", "'My Data'!A1:A5"), + ("Name:Rates.cell(2,1)", "'My Data'!A2"), + ("Other", "'My Data'!C3:C4"), + ("B7", "B7"), + ("'My Data'!A1:B2", "'My Data'!A1:B2"), + ("Total", "F20"), + ] { + check(&env, token, want); + } + } + + #[test] + fn bad_tokens_say_why() { + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), false)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + for (token, needle, edge) in [ + ("Input.col(4)", "outside the range", false), + ("Input.col(0)", "outside the range", false), + ("Input.rows(5..2)", "outside the range", false), + ("Input.col(\"Nope\")", "no column is headed", false), + ("Input.colour(1)", "isn't a token method", false), + ("Input.col(1", "expected", false), + ("Input.col(x)", "whole number", false), + ("Input.col(99999999999)", "whole number", false), + ("Input.", "method name", false), + ("Input junk", "unexpected", false), + ("Input.resize(0,1)", "at least 1 row", false), + ("Output", "no output anchor", false), + ("Row", "inside a Repeat", false), + ("Name:", "needs a defined name", false), + ("Name:Nothing", "no name or table", false), + ("Nowhere!A1", "no sheet named", false), + ("Input.offset(-2,0)", "past the top", true), + ("Input.offset(0,-2)", "edge of the sheet", true), + ("Input.offset(1048570,0)", "past the last row", true), + ("Input.cell(1,16384)", "edge of the sheet", true), + ] { + let e = resolve_ref(token, &env).unwrap_err(); + assert!(e.message().contains(needle), "{token}: {e}"); + assert_eq!(matches!(e, ResolveError::Edge(_)), edge, "{token}"); + } + } + + #[test] + fn params_resolve_tokens_in_reference_keys_and_braces_anywhere() { + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("F2")), item: None }; + let p = json!({ + "cell": "Output", + "range": "Input.col(2)", + "ranges": ["Input.row(1)", "A1"], + "input": "Row", + "value": "Input", + "formula": "=SUM({{Input.data.col(2)}})/{{Name:Total}}", + "nested": {"target": {"$ref": "Input.cell(1,1)"}, "n": 3, "flag": true}, + "text": {"$ref": "Output"}, + "type": "column", + "sheet": "Input", + "rowInputCell": "Input.cell(1,1)", + "unclosed": "a {{b" + }); + let r = resolve_params(&p, &env).unwrap(); + assert_eq!( + r, + json!({ + "cell": "F2", + "range": "C2:C11", + "ranges": ["B2:D2", "A1"], + "input": "Row", + "value": "Input", + "formula": "=SUM(C3:C11)/F20", + "nested": {"target": "B2", "n": 3, "flag": true}, + "text": "F2", + "type": "column", + "sheet": "Input", + "rowInputCell": "B2", + "unclosed": "a {{b" + }) + ); + // Deeply nested parameters are refused, not followed. + let mut deep = json!(1); + for _ in 0..100 { + deep = json!([deep]); + } + assert!(resolve_params(&deep, &env).is_err()); + assert!(resolve_params(&json!({"cell": "Input.col(9)"}), &env).is_err()); + } + + #[test] + fn sheet_names_are_quoted_when_needed() { + for (name, want) in [("Sheet1", "Sheet1"), ("My Data", "'My Data'"), ("O'Hara", "'O''Hara'"), ("2024", "'2024'"), ("", "''")] { + assert_eq!(quote_sheet(name), want); + } + } +}