mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-29 13:16:50 -06:00
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.
This commit is contained in:
@@ -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_<n>`).
|
||||
const MACRO_KEY: &str = "macro_";
|
||||
|
||||
|
||||
@@ -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<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))
|
||||
}
|
||||
|
||||
/// 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<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) {
|
||||
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<Place> {
|
||||
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<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 (first row) reads `h`.
|
||||
fn header_col(env: &Env, place: Place, h: &str) -> Option<i64> {
|
||||
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<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 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<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 {
|
||||
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<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)));
|
||||
}
|
||||
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(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<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(""));
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user