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

971 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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; a name can hold periods, and the longest one that exists is used, so
//! `Name:Sales.Q1.cell(1,1)` is a cell of `Sales.Q1`);
//! - methods, 1-based and chainable: `.col(n)` or `.col("Amount")` (by an input's header row,
//! also after `.data`), `.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. Text that would
//! read as a token is written `{"$text": "Output"}`: it is passed as it is, never resolved or
//! interpolated (a defined name called `Output`, or `Dear {{name}}`). 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<T> = Result<T, ResolveError>;
fn bad<T>(msg: impl Into<String>) -> R<T> {
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<Place>,
/// 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))
}
/// Reference keys that other commands use for plain words: `to` is a replacement word in
/// Review › Change Spelling, `area` is `row` or `column` in a pivot field move. Under these a
/// bare `Row` or `Column` stays text; `Row.col(2)`, `{{Row}}` or `{"$ref": "Row"}` is the token.
fn is_general_key(key: &str) -> bool {
["area", "areas", "at", "destination", "from", "location", "source", "sources", "target", "to"].iter().any(|k| k.eq_ignore_ascii_case(key))
}
/// `Row` or `Column` on its own, with no method or sheet after it.
fn is_bare_item_word(text: &str) -> bool {
let t = text.trim();
t.eq_ignore_ascii_case("row") || t.eq_ignore_ascii_case("column")
}
/// 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). A reference on a
/// sheet with such a name (`Output!A1`) is not a token.
pub fn is_token(text: &str, env: &Env) -> bool {
starts_like_token(text, |base| env.inputs.iter().any(|(n, ..)| n.eq_ignore_ascii_case(base)))
}
/// Whether `text` would read as a token in a macro whose inputs are `inputs` (see [`is_token`]).
pub fn reads_as_token(text: &str, inputs: &[&str]) -> bool {
starts_like_token(text, |base| inputs.iter().any(|n| n.eq_ignore_ascii_case(base)))
}
fn starts_like_token(text: &str, is_input: impl Fn(&str) -> bool) -> 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("");
if t.get(n..).unwrap_or("").trim_start().starts_with('!') {
return false;
}
is_builtin(base) || is_input(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<Place> {
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<Place> {
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<Place> {
if let Some(n) = env.wb.name(name, env.active_sheet) {
// The sheet part keeps its characters (a sheet may be called `US$`); only the reference
// loses its `$` anchors.
let f = n.formula.trim().trim_start_matches('=');
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.replace('$', ""))) {
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))
{
// A table stands for its data rows; one with none (a header, perhaps a totals row) has
// nothing to give.
let Some(range) = t.data_range() else {
return bad(format!("The table '{name}' has no data rows."));
};
return Ok(Place { sheet: si, range });
}
bad(format!("There is no name or table called '{name}'."))
}
/// Token method names.
const METHODS: &[&str] = &["col", "row", "cols", "rows", "cell", "offset", "resize", "data", "below", "right"];
/// Where the name ends in `run`, the text after `Name:` up to the first character a name can't
/// hold: at the end or at a period, the longest choice that is a defined name or table. When
/// none is, the name ends before the first method (`Nope.cell`), so the error names it.
fn name_end(run: &str, env: &Env) -> usize {
// One pass over the names and tables, as a single lookup makes.
let fits = |name: &str| {
!name.is_empty()
&& run.get(..name.len()).is_some_and(|head| head.eq_ignore_ascii_case(name))
&& run.get(name.len()..).is_some_and(|rest| rest.is_empty() || rest.starts_with('.'))
};
let names = env.wb.names.iter().filter(|n| n.scope.is_none() || n.scope == Some(env.active_sheet)).map(|n| n.name.as_str());
let tables = env.wb.sheets.iter().flat_map(|sh| sh.tables.iter().map(|t| t.name.as_str()));
if let Some(end) = names.chain(tables).filter(|n| fits(n)).map(str::len).max() {
return end;
}
run.match_indices('.')
.map(|(i, _)| i)
.find(|&i| {
let next = run.get(i + 1..).unwrap_or("").split('.').next().unwrap_or("");
METHODS.iter().any(|m| m.eq_ignore_ascii_case(next))
})
.unwrap_or(run.len())
}
/// Resolves a token (see the module docs).
pub fn resolve_token(text: &str, env: &Env) -> R<Place> {
let t = text.trim();
let mut p = Parser { s: t, at: 0 };
let (mut place, mut header) = p.base(env)?;
// The row of column names `col("…")` reads: the input's header row, kept through `data`.
let mut names_row = header.then_some(place.range.start.row);
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 method = name.to_ascii_lowercase();
let range = match (method.as_str(), args.as_slice()) {
("col", [Arg::Int(n)]) => sub(r, None, Some((*n, *n)), t)?,
("col", [Arg::Text(h)]) => {
let Some(row) = names_row else {
return bad(format!("'{t}': col(\"{h}\") needs an input with a header row; use col(n)."));
};
let c = header_col(env, place, row, 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 {
// A header row with nothing under it has no data rows to give.
if r.height() < 2 {
return bad(format!("'{t}': the range is only a header row, with no data under it."));
}
RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end)
} else {
r
}
}
("below", []) | ("below", [Arg::Int(_)]) => {
let n = count_arg(&args, t)?;
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 = count_arg(&args, t)?;
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 ({}).", METHODS.join(", ")));
}
};
// After a method the result is plain cells, except that `col` and `cols` keep the input's top
// row, so a later `data` still takes the header off; only `data` keeps the header row for
// `col("…")`.
if !matches!(method.as_str(), "col" | "cols") {
header = false;
}
if method != "data" {
names_row = None;
}
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)
}
/// The count `below(n)` and `right(n)` take: 1 when left out, and never less than 1.
fn count_arg(args: &[Arg], t: &str) -> R<i64> {
match args.first() {
Some(Arg::Int(n)) if *n < 1 => bad(format!("'{t}': the count must be at least 1.")),
Some(Arg::Int(n)) => Ok(*n),
_ => Ok(1),
}
}
/// `a..b` with open ends filled from 1 and `len`.
fn span(a: Option<i64>, b: Option<i64>, 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<RangeRef> {
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<CellRef> {
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 (in sheet row `row`) reads `h`.
fn header_col(env: &Env, place: Place, row: u32, h: &str) -> Option<i64> {
let sh = env.wb.sheet(place.sheet)?;
let r = place.range;
(r.start.col..=r.end.col)
// The header's value, however the sheet shows it: with Show Formulas on, the cell's
// display text is its formula.
.position(|c| crate::display::value_text(env.wb, sh, CellRef::new(row, c)).trim().eq_ignore_ascii_case(h.trim()))
.map(|i| i as i64 + 1)
}
#[derive(Debug)]
enum Arg {
Int(i64),
Span(Option<i64>, Option<i64>),
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<i64> {
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 can hold periods (`Sales.Q1`), so it is the longest part of the run, up to
// a period or the end, that names something; methods follow it.
let n: usize = self.rest().chars().take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '\\' | '.')).map(char::len_utf8).sum();
let run = self.rest().get(..n).unwrap_or("").to_string();
let end = name_end(&run, env);
let name = run.get(..end).unwrap_or("");
if name.is_empty() {
return bad("'Name:' needs a defined name after it.");
}
self.at += end;
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<Vec<Arg>> {
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 {
// The engine's one rule: a name that reads as a cell reference, an R1C1 token or a boolean
// is quoted too, or `Q1!A1` would be read as a reference to cell Q1.
gridcraft_core::quote_sheet(name)
}
/// Step parameters with every token replaced by A1 text.
pub fn resolve_params(params: &Json, env: &Env) -> R<Json> {
walk(params, None, env, 0)
}
fn walk(v: &Json, key: Option<&str>, env: &Env, depth: usize) -> R<Json> {
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)));
}
// Text passed as it is: never a token, and no `{{…}}` in it is read.
if o.len() == 1
&& let Some(Json::String(t)) = o.get("$text")
{
return Ok(Json::String(t.clone()));
}
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::<R<Vec<_>>>()?),
Json::String(s) => {
if key.is_some_and(|k| is_ref_key(k) && !(is_general_key(k) && is_bare_item_word(s))) && 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`. `{{{{` is a literal `{{`, for text that needs the braces
/// themselves (`"Dear {{{{name}}"` writes `Dear {{name}}`).
pub fn interpolate(s: &str, env: &Env) -> R<String> {
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(""));
if rest.get(i..i + 4) == Some("{{{{") {
out.push_str("{{");
rest = rest.get(i + 4..).unwrap_or("");
continue;
}
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 a_formula_header_matches_while_formulas_are_shown() {
// The header was read through the cell's display text, which is the formula while Show
// Formulas is on, so `col("Amount")` found no column headed by a formula.
let mut wb = wb();
let sh = wb.sheet_mut(0).unwrap();
let formula = Some(std::sync::Arc::new(gridcraft_model::Formula::new("\"Amo\"&\"unt\"")));
sh.set_cell(
CellRef::new(1, 2),
gridcraft_model::Cell { value: gridcraft_core::Value::text("Amount"), formula, style: Default::default(), py: None },
);
sh.show_formulas = true;
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
check(&env, "Input.col(\"Amount\")", "C2:C11");
}
#[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", "D3:D11"),
("Input.cols(2..3).data", "C3:D11"),
("Input.col(2).data.col(1)", "C3:C11"),
("Input.data", "B3:D11"),
("Input.data.col(2)", "C3:C11"),
("Input.data.col(\"Amount\")", "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();
// The same cells, read with and without a header row.
let inputs = vec![("Input".to_string(), place("B2:D11"), false), ("Headed".to_string(), place("B2:D11"), true)];
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),
("Headed.col(\"Nope\")", "no column is headed", false),
("Input.col(\"Name\")", "needs an input with a header row", false),
("Headed.col(2).col(\"Amount\")", "needs an input with a header row", false),
("Name:Rates.col(\"Name\")", "needs an input with a header row", 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 references_on_sheets_named_like_tokens_stay_references() {
// Sheets named after the built-in bases and after an input: `Output!A1` is a cell on the
// sheet Output, in a reference key, in `$ref` and in `{{…}}`.
let mut wb = Workbook::new();
for name in ["Output", "Row", "Column", "Input", "Rates"] {
wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new(name)));
}
let inputs = vec![("Input".to_string(), place("B2:D11"), true), ("Rates".to_string(), place("F2:F5"), false)];
for output in [None, Some(place("H2"))] {
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output, item: Some((RepeatOf::Rows, place("B5:D5"))) };
for (sheet, i) in [("Output", 1), ("Row", 2), ("Column", 3), ("Input", 4), ("Rates", 5)] {
for text in [format!("{sheet}!A1:B2"), format!("'{sheet}'!A1:B2")] {
let want = Place { sheet: i, range: RangeRef::parse("A1:B2").unwrap() };
assert_eq!(resolve_ref(&text, &env), Ok(want), "{text}");
let a = a1(want, &env);
let p = json!({"range": text, "target": {"$ref": text}, "formula": format!("=SUM({{{{{text}}}}})")});
let r = resolve_params(&p, &env).unwrap();
// A plain reference in a reference key is passed on as written.
assert_eq!(r, json!({"range": text, "target": a, "formula": format!("=SUM({a})")}), "{text}");
}
}
assert!(!is_token("Output !A1", &env));
// The bare names are still tokens.
check(&env, "Input.col(2)", "C2:C11");
check(&env, "Rates", "F2:F5");
check(&env, "Row", "B5:D5");
}
}
#[test]
fn defined_names_can_hold_periods() {
// `Sales.Q1` is one defined name, beside `Sales`; `Data.row` has a segment that reads like
// a method. The longest name that exists is used, and methods follow it.
let mut wb = wb();
for (name, formula) in [("Sales", "Sheet1!$A$1"), ("Sales.Q1", "Sheet1!$B$2:$C$5"), ("Data.row", "'My Data'!$E$1:$E$3")] {
wb.names.push(gridcraft_model::DefinedName {
name: name.into(),
scope: None,
formula: formula.into(),
comment: String::new(),
hidden: false,
});
}
let env = Env { wb: &wb, active_sheet: 0, inputs: &[], output: None, item: None };
for (token, want) in [
("Name:Sales.Q1", "B2:C5"),
("Name:sales.q1", "B2:C5"),
("Name:Sales.Q1.cell(1,1)", "B2"),
("Name:Sales.Q1.col(2).row(4)", "C5"),
("Name:Sales", "A1"),
("Name:Sales.offset(1,0)", "A2"),
("Name:Data.row", "'My Data'!E1:E3"),
("Name:Data.row.row(2)", "'My Data'!E2"),
("Name:Data.row.cell(1,1)", "'My Data'!E1"),
("Sales.Q1", "B2:C5"),
] {
check(&env, token, want);
}
// In a reference parameter, `$ref` and `{{…}}`, with and without a method.
let p = json!({
"range": "Name:Sales.Q1",
"cell": "Name:Sales.Q1.cell(1,1)",
"a": {"$ref": "Name:Sales.Q1"},
"b": {"$ref": "Name:Sales.Q1.cell(2,2)"},
"formula": "=SUM({{Name:Sales.Q1}})+{{Name:Sales.Q1.cell(1,2)}}"
});
let r = resolve_params(&p, &env).unwrap();
assert_eq!(r, json!({"range": "B2:C5", "cell": "B2", "a": "B2:C5", "b": "C3", "formula": "=SUM(B2:C5)+C2"}));
// Unknown names and bad methods still fail, naming what is wrong.
for (token, needle) in [
("Name:Nope.Q1", "no name or table called 'Nope.Q1'"),
("Name:Nope.cell(1,1)", "no name or table called 'Nope'"),
("Name:Sales.Q1.bogus(1)", "'bogus' with these arguments isn't a token method"),
("Name:Sales.Q1.cell(1", "expected"),
("Name:Sales.Q1.", "method name"),
("Name:.cell(1,1)", "needs a defined name"),
] {
let e = resolve_ref(token, &env).unwrap_err();
assert!(e.message().contains(needle), "{token}: {e}");
let e = resolve_params(&json!({"formula": format!("={{{{{token}}}}}")}), &env).unwrap_err();
assert!(e.message().contains(needle), "{token} in braces: {e}");
}
}
#[test]
fn sheet_names_are_quoted_when_needed() {
// The engine's rule: a name that reads as a cell reference, an R1C1 token or a boolean
// is quoted too, and a period on its own is not a reason to quote.
for (name, want) in [
("Sheet1", "Sheet1"),
("My Data", "'My Data'"),
("O'Hara", "'O''Hara'"),
("2024", "'2024'"),
("", "''"),
("Q1", "'Q1'"),
("R1C1", "'R1C1'"),
("TRUE", "'TRUE'"),
("Sales.Q1", "Sales.Q1"),
] {
assert_eq!(quote_sheet(name), want, "{name}");
}
}
#[test]
fn a_bare_row_or_column_under_a_general_key_is_text() {
// `to` is a replacement word in Change Spelling and `area` is `row` or `column` in a
// pivot field move, so a bare `Row` or `Column` there stays text, inside a Repeat or not;
// the token forms still resolve there, and the bare word resolves under `range`.
let wb = wb();
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
for item in [None, Some((RepeatOf::Rows, place("B5:D5")))] {
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("F2")), item };
let p = json!({"word": "Colum", "to": "Column", "area": "row", "from": "Input", "at": "Output"});
let r = resolve_params(&p, &env).unwrap();
assert_eq!(r, json!({"word": "Colum", "to": "Column", "area": "row", "from": "B2:D11", "at": "F2"}), "{item:?}");
}
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: Some((RepeatOf::Rows, place("B5:D5"))) };
let p = json!({"to": "Row.col(2)", "range": "Row", "area": {"$ref": "Row"}, "text": "{{Row}}"});
let r = resolve_params(&p, &env).unwrap();
assert_eq!(r, json!({"to": "C5", "range": "B5:D5", "area": "B5:D5", "text": "B5:D5"}));
}
#[test]
fn a_header_row_alone_and_a_table_without_data_rows_are_refused() {
let mut wb = wb();
let sh = wb.sheet_mut(0).unwrap();
sh.tables.push(gridcraft_model::Table {
id: 1,
name: "Empty".into(),
range: RangeRef::parse("H1:J1").unwrap(),
header_row: true,
totals_row: false,
columns: Vec::new(),
style: String::new(),
banded_rows: false,
banded_cols: false,
first_col: false,
last_col: false,
filter_button: false,
filter: Vec::new(),
table_type: None,
connection_id: None,
});
let inputs = vec![("Input".to_string(), place("B2:D2"), true), ("Plain".to_string(), place("B2:D2"), false)];
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
assert!(resolve_ref("Input.data", &env).unwrap_err().to_string().contains("only a header row"));
assert!(resolve_ref("Input.data.col(2)", &env).unwrap_err().to_string().contains("only a header row"));
check(&env, "Plain.data", "B2:D2");
check(&env, "Input.col(2)", "C2");
assert!(resolve_ref("Name:Empty", &env).unwrap_err().to_string().contains("no data rows"));
}
#[test]
fn doubled_braces_are_literal_braces() {
let wb = wb();
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
assert_eq!(interpolate("Dear {{{{name}},", &env).unwrap(), "Dear {{name}},");
assert_eq!(interpolate("{{{{ {{Input.cell(1,1)}} }}", &env).unwrap(), "{{ B2 }}");
assert!(interpolate("Dear {{name}},", &env).is_err(), "an unknown token is still an error");
}
#[test]
fn text_marked_as_text_is_never_a_token() {
// A defined name called Output or Input in a reference parameter, a Name: prefix and
// braces: `$text` passes each as it is, in any key; unmarked, each reads as a token.
let wb = wb();
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
for text in ["Output", "Input", "Name:Nowhere", "Row", "Dear {{name}}"] {
let p = json!({"range": {"$text": text}, "input": {"$text": text}});
assert_eq!(resolve_params(&p, &env).unwrap(), json!({"range": text, "input": text}), "{text}");
assert_ne!(resolve_params(&json!({"range": text}), &env).ok(), Some(json!({"range": text})), "{text}");
assert!(reads_as_token(text, &["Input"]) || text.contains("{{"), "{text}");
}
assert!(!reads_as_token("Output!A1", &["Input"]) && !reads_as_token("B2", &["Input"]));
}
#[test]
fn a_defined_name_on_a_sheet_with_a_dollar_sign_resolves() {
let mut wb = wb();
wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("US$")));
wb.names.push(gridcraft_model::DefinedName {
name: "Dollars".into(),
scope: None,
formula: "'US$'!$F$20".into(),
comment: String::new(),
hidden: false,
});
let env = Env { wb: &wb, active_sheet: 0, inputs: &[], output: None, item: None };
check(&env, "Name:Dollars", "'US$'!F20");
}
#[test]
fn below_and_right_refuse_a_count_under_one() {
let wb = wb();
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
check(&env, "Input.below", "B12:D12");
check(&env, "Input.below(2)", "B12:D13");
check(&env, "Input.right(1)", "E2:E11");
for bad in ["Input.below(0)", "Input.below(-3)", "Input.right(0)"] {
assert!(resolve_ref(bad, &env).unwrap_err().to_string().contains("at least 1"), "{bad}");
}
}
}