Skip to main content

ravenrs_sheet/engine/macros/
resolve.rs

1//! Macro tokens: references in step parameters that are worked out when the step runs, so a
2//! macro made on one block of data works on another.
3//!
4//! A token is a base followed by methods:
5//!
6//! - bases: a declared input (`Input`, `Rates`), `Output` (the output rule's anchor), `Row` or
7//!   `Column` (the current item of the Repeat step around it), and `Name:Total` (a defined name or
8//!   a table's data; a name can hold periods, and the longest one that exists is used, so
9//!   `Name:Sales.Q1.cell(1,1)` is a cell of `Sales.Q1`);
10//! - methods, 1-based and chainable: `.col(n)` or `.col("Amount")` (by an input's header row,
11//!   also after `.data`), `.row(n)`, `.cols(a..b)`, `.rows(a..)`, `.cell(r,c)` (relative to the
12//!   top-left cell; any whole numbers, so `cell(0,1)` is the cell above), `.offset(r,c)`,
13//!   `.resize(h,w)`, `.data` (without the header row), `.below(n)` and `.right(n)` (the n rows
14//!   below or columns right of the range).
15//!
16//! In parameters a token is written three ways: `{"$ref": "Input.col(2)"}` anywhere; the whole
17//! string `"Input.col(2)"` in a reference parameter (`cell`, `range`, `target`, `to`, keys ending
18//! in `Cell` or `Range`…); or `{{Input.col(2)}}` inside any string (`"=SUM({{Input.col(2)}})"`).
19//! Each becomes A1 text before the command runs, so commands never see a token. Text that would
20//! read as a token is written `{"$text": "Output"}`: it is passed as it is, never resolved or
21//! interpolated (a defined name called `Output`, or `Dear {{name}}`). Plain A1
22//! references in steps are absolute: they don't follow row inserts (as in VBA); a `Name:` anchor
23//! does.
24
25use crate::base::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
26use crate::model::{RepeatOf, Workbook};
27use serde_json::{Map, Value as Json};
28
29/// A resolved reference: a block of cells on a sheet.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub struct Place {
32    pub sheet: usize,
33    pub range: RangeRef,
34}
35
36/// Why a reference couldn't be worked out.
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub enum ResolveError {
39    /// It runs past the edge of the sheet.
40    Edge(String),
41    /// Anything else (an unknown input, a malformed token).
42    Bad(String),
43}
44
45impl ResolveError {
46    pub fn message(&self) -> &str {
47        match self {
48            ResolveError::Edge(m) | ResolveError::Bad(m) => m,
49        }
50    }
51}
52
53impl std::fmt::Display for ResolveError {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.write_str(self.message())
56    }
57}
58
59type R<T> = Result<T, ResolveError>;
60
61fn bad<T>(msg: impl Into<String>) -> R<T> {
62    Err(ResolveError::Bad(msg.into()))
63}
64
65/// What tokens refer to while a step runs.
66#[derive(Clone, Copy, Debug)]
67pub struct Env<'a> {
68    pub wb: &'a Workbook,
69    /// The sheet a reference without a sheet name is on.
70    pub active_sheet: usize,
71    /// Declared inputs, with whether each has a header row.
72    pub inputs: &'a [(String, Place, bool)],
73    pub output: Option<Place>,
74    /// The innermost Repeat step's current row or column.
75    pub item: Option<(RepeatOf, Place)>,
76}
77
78/// Parameter keys that hold references, where a whole-string token is resolved. Elsewhere a
79/// string is text (a cell may hold "Row", a chart's type is "column"), and a token needs
80/// `{"$ref": …}` or `{{…}}`.
81const REF_KEYS: &[&str] = &[
82    "active",
83    "anchor",
84    "area",
85    "areas",
86    "at",
87    "cell",
88    "cells",
89    "changing",
90    "copyTo",
91    "destination",
92    "from",
93    "fromCell",
94    "location",
95    "objective",
96    "over",
97    "range",
98    "ranges",
99    "reference",
100    "resultCells",
101    "source",
102    "sources",
103    "target",
104    "to",
105    "variables",
106];
107
108/// Whether a parameter called `key` holds a reference.
109pub fn is_ref_key(key: &str) -> bool {
110    let k = key.to_ascii_lowercase();
111    REF_KEYS.iter().any(|r| r.eq_ignore_ascii_case(key)) || ["cell", "cells", "range", "ranges", "ref"].iter().any(|end| k.ends_with(end))
112}
113
114/// Reference keys that other commands use for plain words: `to` is a replacement word in
115/// Review › Change Spelling, `area` is `row` or `column` in a pivot field move. Under these a
116/// bare `Row` or `Column` stays text; `Row.col(2)`, `{{Row}}` or `{"$ref": "Row"}` is the token.
117fn is_general_key(key: &str) -> bool {
118    ["area", "areas", "at", "destination", "from", "location", "source", "sources", "target", "to"].iter().any(|k| k.eq_ignore_ascii_case(key))
119}
120
121/// `Row` or `Column` on its own, with no method or sheet after it.
122fn is_bare_item_word(text: &str) -> bool {
123    let t = text.trim();
124    t.eq_ignore_ascii_case("row") || t.eq_ignore_ascii_case("column")
125}
126
127/// The most nesting [`resolve_params`] walks into.
128const MAX_JSON_DEPTH: usize = 64;
129
130/// Whether `text` starts like a token (its base is one this environment knows). A reference on a
131/// sheet with such a name (`Output!A1`) is not a token.
132pub fn is_token(text: &str, env: &Env) -> bool {
133    starts_like_token(text, |base| env.inputs.iter().any(|(n, ..)| n.eq_ignore_ascii_case(base)))
134}
135
136/// Whether `text` would read as a token in a macro whose inputs are `inputs` (see [`is_token`]).
137pub fn reads_as_token(text: &str, inputs: &[&str]) -> bool {
138    starts_like_token(text, |base| inputs.iter().any(|n| n.eq_ignore_ascii_case(base)))
139}
140
141fn starts_like_token(text: &str, is_input: impl Fn(&str) -> bool) -> bool {
142    let t = text.trim();
143    if t.get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) {
144        return true;
145    }
146    let n: usize = t.chars().take_while(|c| c.is_alphanumeric() || *c == '_').map(char::len_utf8).sum();
147    let base = t.get(..n).unwrap_or("");
148    if t.get(n..).unwrap_or("").trim_start().starts_with('!') {
149        return false;
150    }
151    is_builtin(base) || is_input(base)
152}
153
154fn is_builtin(base: &str) -> bool {
155    ["Output", "Row", "Column"].iter().any(|b| b.eq_ignore_ascii_case(base))
156}
157
158/// Resolves a token, or a plain reference (`B2`, `Sheet2!A1:C3`, a defined name).
159pub fn resolve_ref(text: &str, env: &Env) -> R<Place> {
160    let t = text.trim();
161    if is_token(t, env) {
162        return resolve_token(t, env);
163    }
164    plain_ref(t, env)
165}
166
167/// `A1`, `'My Sheet'!A1:B2` or a defined name or table.
168fn plain_ref(t: &str, env: &Env) -> R<Place> {
169    let (sheet, body) = crate::engine::cmd::split_sheet(t);
170    let si = match &sheet {
171        Some(name) => env.wb.sheet_index(name).ok_or_else(|| ResolveError::Bad(format!("There is no sheet named '{name}'.")))?,
172        None => env.active_sheet,
173    };
174    if let Some(range) = RangeRef::parse(body) {
175        return Ok(Place { sheet: si, range });
176    }
177    if sheet.is_none() {
178        return named(t, env);
179    }
180    bad(format!("'{t}' isn't a reference."))
181}
182
183/// A defined name (by its formula) or a table (its data rows).
184fn named(name: &str, env: &Env) -> R<Place> {
185    if let Some(n) = env.wb.name(name, env.active_sheet) {
186        // The sheet part keeps its characters (a sheet may be called `US$`); only the reference
187        // loses its `$` anchors.
188        let f = n.formula.trim().trim_start_matches('=');
189        let (sheet, body) = crate::engine::cmd::split_sheet(f);
190        let si = match sheet {
191            Some(s) => env.wb.sheet_index(&s),
192            None => n.scope.or(Some(env.active_sheet)),
193        };
194        if let (Some(sheet), Some(range)) = (si, RangeRef::parse(&body.replace('$', ""))) {
195            return Ok(Place { sheet, range });
196        }
197        return bad(format!("The name '{name}' doesn't refer to a range."));
198    }
199    if let Some((si, ti)) = env.wb.table(name)
200        && let Some(t) = env.wb.sheet(si).and_then(|sh| sh.tables.get(ti))
201    {
202        // A table stands for its data rows; one with none (a header, perhaps a totals row) has
203        // nothing to give.
204        let Some(range) = t.data_range() else {
205            return bad(format!("The table '{name}' has no data rows."));
206        };
207        return Ok(Place { sheet: si, range });
208    }
209    bad(format!("There is no name or table called '{name}'."))
210}
211
212/// Token method names.
213const METHODS: &[&str] = &["col", "row", "cols", "rows", "cell", "offset", "resize", "data", "below", "right"];
214
215/// Where the name ends in `run`, the text after `Name:` up to the first character a name can't
216/// hold: at the end or at a period, the longest choice that is a defined name or table. When
217/// none is, the name ends before the first method (`Nope.cell`), so the error names it.
218fn name_end(run: &str, env: &Env) -> usize {
219    // One pass over the names and tables, as a single lookup makes.
220    let fits = |name: &str| {
221        !name.is_empty()
222            && run.get(..name.len()).is_some_and(|head| head.eq_ignore_ascii_case(name))
223            && run.get(name.len()..).is_some_and(|rest| rest.is_empty() || rest.starts_with('.'))
224    };
225    let names = env.wb.names.iter().filter(|n| n.scope.is_none() || n.scope == Some(env.active_sheet)).map(|n| n.name.as_str());
226    let tables = env.wb.sheets.iter().flat_map(|sh| sh.tables.iter().map(|t| t.name.as_str()));
227    if let Some(end) = names.chain(tables).filter(|n| fits(n)).map(str::len).max() {
228        return end;
229    }
230    run.match_indices('.')
231        .map(|(i, _)| i)
232        .find(|&i| {
233            let next = run.get(i + 1..).unwrap_or("").split('.').next().unwrap_or("");
234            METHODS.iter().any(|m| m.eq_ignore_ascii_case(next))
235        })
236        .unwrap_or(run.len())
237}
238
239/// Resolves a token (see the module docs).
240pub fn resolve_token(text: &str, env: &Env) -> R<Place> {
241    let t = text.trim();
242    let mut p = Parser { s: t, at: 0 };
243    let (mut place, mut header) = p.base(env)?;
244    // The row of column names `col("…")` reads: the input's header row, kept through `data`.
245    let mut names_row = header.then_some(place.range.start.row);
246    while p.eat('.') {
247        let name = p.ident();
248        if name.is_empty() {
249            return bad(format!("'{t}': expected a method name after '.'."));
250        }
251        let args = if p.eat('(') { p.args(t)? } else { vec![] };
252        let r = place.range;
253        let method = name.to_ascii_lowercase();
254        let range = match (method.as_str(), args.as_slice()) {
255            ("col", [Arg::Int(n)]) => sub(r, None, Some((*n, *n)), t)?,
256            ("col", [Arg::Text(h)]) => {
257                let Some(row) = names_row else {
258                    return bad(format!("'{t}': col(\"{h}\") needs an input with a header row; use col(n)."));
259                };
260                let c = header_col(env, place, row, h).ok_or_else(|| ResolveError::Bad(format!("'{t}': no column is headed '{h}'.")))?;
261                sub(r, None, Some((c, c)), t)?
262            }
263            ("row", [Arg::Int(n)]) => sub(r, Some((*n, *n)), None, t)?,
264            ("cols", [Arg::Span(a, b)]) => sub(r, None, Some(span(*a, *b, i64::from(r.width()))), t)?,
265            ("rows", [Arg::Span(a, b)]) => sub(r, Some(span(*a, *b, i64::from(r.height()))), None, t)?,
266            ("cell", [Arg::Int(row), Arg::Int(col)]) => {
267                let c = shift(r.start, row - 1, col - 1, t)?;
268                RangeRef::cell(c)
269            }
270            ("offset", [Arg::Int(dr), Arg::Int(dc)]) => RangeRef::new(shift(r.start, *dr, *dc, t)?, shift(r.end, *dr, *dc, t)?),
271            ("resize", [Arg::Int(h), Arg::Int(w)]) => {
272                if *h < 1 || *w < 1 {
273                    return bad(format!("'{t}': resize needs at least 1 row and 1 column."));
274                }
275                RangeRef::new(r.start, shift(r.start, h - 1, w - 1, t)?)
276            }
277            ("data", []) => {
278                if header {
279                    // A header row with nothing under it has no data rows to give.
280                    if r.height() < 2 {
281                        return bad(format!("'{t}': the range is only a header row, with no data under it."));
282                    }
283                    RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end)
284                } else {
285                    r
286                }
287            }
288            ("below", []) | ("below", [Arg::Int(_)]) => {
289                let n = count_arg(&args, t)?;
290                let top = shift(CellRef::new(r.end.row, r.start.col), 1, 0, t)?;
291                RangeRef::new(top, shift(CellRef::new(r.end.row, r.end.col), n, 0, t)?)
292            }
293            ("right", []) | ("right", [Arg::Int(_)]) => {
294                let n = count_arg(&args, t)?;
295                let left = shift(CellRef::new(r.start.row, r.end.col), 0, 1, t)?;
296                RangeRef::new(left, shift(r.end, 0, n, t)?)
297            }
298            _ => {
299                return bad(format!("'{t}': '{name}' with these arguments isn't a token method ({}).", METHODS.join(", ")));
300            }
301        };
302        // After a method the result is plain cells, except that `col` and `cols` keep the input's top
303        // row, so a later `data` still takes the header off; only `data` keeps the header row for
304        // `col("…")`.
305        if !matches!(method.as_str(), "col" | "cols") {
306            header = false;
307        }
308        if method != "data" {
309            names_row = None;
310        }
311        place = Place { sheet: place.sheet, range };
312    }
313    p.skip_ws();
314    if p.at < p.s.len() {
315        return bad(format!("'{t}' isn't a token: unexpected '{}'.", p.s.get(p.at..).unwrap_or("")));
316    }
317    Ok(place)
318}
319
320/// The count `below(n)` and `right(n)` take: 1 when left out, and never less than 1.
321fn count_arg(args: &[Arg], t: &str) -> R<i64> {
322    match args.first() {
323        Some(Arg::Int(n)) if *n < 1 => bad(format!("'{t}': the count must be at least 1.")),
324        Some(Arg::Int(n)) => Ok(*n),
325        _ => Ok(1),
326    }
327}
328
329/// `a..b` with open ends filled from 1 and `len`.
330fn span(a: Option<i64>, b: Option<i64>, len: i64) -> (i64, i64) {
331    (a.unwrap_or(1), b.unwrap_or(len))
332}
333
334/// Rows `rows` and columns `cols` (1-based, inclusive) of `r`; `None` keeps them all.
335fn sub(r: RangeRef, rows: Option<(i64, i64)>, cols: Option<(i64, i64)>, t: &str) -> R<RangeRef> {
336    let (h, w) = (i64::from(r.height()), i64::from(r.width()));
337    let (r0, r1) = rows.unwrap_or((1, h));
338    let (c0, c1) = cols.unwrap_or((1, w));
339    if r0 < 1 || c0 < 1 || r1 < r0 || c1 < c0 || r1 > h || c1 > w {
340        return bad(format!("'{t}' is outside the range it starts from ({} rows by {} columns).", h, w));
341    }
342    Ok(RangeRef::new(shift(r.start, r0 - 1, c0 - 1, t)?, shift(r.start, r1 - 1, c1 - 1, t)?))
343}
344
345fn shift(c: CellRef, dr: i64, dc: i64, t: &str) -> R<CellRef> {
346    let row = i64::from(c.row).checked_add(dr);
347    let col = i64::from(c.col).checked_add(dc);
348    match (row, col) {
349        (Some(row), Some(col)) if (0..i64::from(MAX_ROWS)).contains(&row) && (0..i64::from(MAX_COLS)).contains(&col) => {
350            Ok(CellRef::new(row as u32, col as u32))
351        }
352        (Some(row), Some(_)) if row < 0 => Err(ResolveError::Edge(format!("'{t}' runs past the top of the sheet."))),
353        (Some(row), Some(_)) if row >= i64::from(MAX_ROWS) => Err(ResolveError::Edge(format!("'{t}' runs past the last row of the sheet."))),
354        _ => Err(ResolveError::Edge(format!("'{t}' runs past the edge of the sheet."))),
355    }
356}
357
358/// The 1-based column of `place` whose header (in sheet row `row`) reads `h`.
359fn header_col(env: &Env, place: Place, row: u32, h: &str) -> Option<i64> {
360    let sh = env.wb.sheet(place.sheet)?;
361    let r = place.range;
362    (r.start.col..=r.end.col)
363        // The header's value, however the sheet shows it: with Show Formulas on, the cell's
364        // display text is its formula.
365        .position(|c| crate::engine::display::value_text(env.wb, sh, CellRef::new(row, c)).trim().eq_ignore_ascii_case(h.trim()))
366        .map(|i| i as i64 + 1)
367}
368
369#[derive(Debug)]
370enum Arg {
371    Int(i64),
372    Span(Option<i64>, Option<i64>),
373    Text(String),
374}
375
376struct Parser<'a> {
377    s: &'a str,
378    at: usize,
379}
380
381impl Parser<'_> {
382    fn rest(&self) -> &str {
383        self.s.get(self.at..).unwrap_or("")
384    }
385    fn skip_ws(&mut self) {
386        let n = self.rest().len() - self.rest().trim_start().len();
387        self.at += n;
388    }
389    fn eat(&mut self, c: char) -> bool {
390        self.skip_ws();
391        if self.rest().starts_with(c) {
392            self.at += c.len_utf8();
393            true
394        } else {
395            false
396        }
397    }
398    fn ident(&mut self) -> String {
399        self.skip_ws();
400        let n: usize = self.rest().chars().take_while(|c| c.is_alphanumeric() || *c == '_').map(char::len_utf8).sum();
401        let out = self.rest().get(..n).unwrap_or("").to_string();
402        self.at += n;
403        out
404    }
405    fn int(&mut self) -> Option<i64> {
406        self.skip_ws();
407        let neg = self.rest().starts_with('-');
408        let start = self.at + usize::from(neg);
409        let digits: usize = self.s.get(start..).unwrap_or("").chars().take_while(char::is_ascii_digit).count();
410        if digits == 0 || digits > 9 {
411            return None;
412        }
413        let n: i64 = self.s.get(start..start + digits)?.parse().ok()?;
414        self.at = start + digits;
415        Some(if neg { -n } else { n })
416    }
417    /// The base of a token and whether it has a header row.
418    fn base(&mut self, env: &Env) -> R<(Place, bool)> {
419        self.skip_ws();
420        if self.rest().get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) {
421            self.at += 5;
422            // A name can hold periods (`Sales.Q1`), so it is the longest part of the run, up to
423            // a period or the end, that names something; methods follow it.
424            let n: usize = self.rest().chars().take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '\\' | '.')).map(char::len_utf8).sum();
425            let run = self.rest().get(..n).unwrap_or("").to_string();
426            let end = name_end(&run, env);
427            let name = run.get(..end).unwrap_or("");
428            if name.is_empty() {
429                return bad("'Name:' needs a defined name after it.");
430            }
431            self.at += end;
432            return Ok((named(name, env)?, false));
433        }
434        let base = self.ident();
435        if base.eq_ignore_ascii_case("Output") {
436            return env.output.map(|p| (p, false)).ok_or_else(|| ResolveError::Bad("'Output' is used, but the macro has no output anchor.".into()));
437        }
438        if base.eq_ignore_ascii_case("Row") || base.eq_ignore_ascii_case("Column") {
439            return match env.item {
440                Some((_, p)) => Ok((p, false)),
441                None => bad(format!("'{base}' only means something inside a Repeat step.")),
442            };
443        }
444        match env.inputs.iter().find(|(n, ..)| n.eq_ignore_ascii_case(&base)) {
445            Some((_, p, header)) => Ok((*p, *header)),
446            None => bad(format!("'{base}' isn't an input of this macro.")),
447        }
448    }
449    fn args(&mut self, t: &str) -> R<Vec<Arg>> {
450        let mut out = Vec::new();
451        if self.eat(')') {
452            return Ok(out);
453        }
454        loop {
455            self.skip_ws();
456            if self.rest().starts_with('"') {
457                self.at += 1;
458                let n = self.rest().find('"').ok_or_else(|| ResolveError::Bad(format!("'{t}': a quote isn't closed.")))?;
459                out.push(Arg::Text(self.rest().get(..n).unwrap_or("").to_string()));
460                self.at += n + 1;
461            } else {
462                let a = self.int();
463                self.skip_ws();
464                if self.rest().starts_with("..") {
465                    self.at += 2;
466                    let b = self.int();
467                    out.push(Arg::Span(a, b));
468                } else {
469                    out.push(Arg::Int(a.ok_or_else(|| ResolveError::Bad(format!("'{t}': expected a whole number.")))?));
470                }
471            }
472            if out.len() > 4 {
473                return bad(format!("'{t}': too many arguments."));
474            }
475            if self.eat(')') {
476                return Ok(out);
477            }
478            if !self.eat(',') {
479                return bad(format!("'{t}': expected ',' or ')'."));
480            }
481        }
482    }
483}
484
485/// `place` as A1 text, with the sheet name when it isn't the active sheet.
486pub fn a1(place: Place, env: &Env) -> String {
487    if place.sheet == env.active_sheet {
488        return place.range.a1();
489    }
490    let name = env.wb.sheet(place.sheet).map(|s| s.name.as_str()).unwrap_or("");
491    format!("{}!{}", quote_sheet(name), place.range.a1())
492}
493
494/// `Sheet1` or `'My Sheet'` (quotes doubled).
495pub fn quote_sheet(name: &str) -> String {
496    // The engine's one rule: a name that reads as a cell reference, an R1C1 token or a boolean
497    // is quoted too, or `Q1!A1` would be read as a reference to cell Q1.
498    crate::base::quote_sheet(name)
499}
500
501/// Step parameters with every token replaced by A1 text.
502pub fn resolve_params(params: &Json, env: &Env) -> R<Json> {
503    walk(params, None, env, 0)
504}
505
506fn walk(v: &Json, key: Option<&str>, env: &Env, depth: usize) -> R<Json> {
507    if depth > MAX_JSON_DEPTH {
508        return bad("The step's parameters nest too deeply.");
509    }
510    Ok(match v {
511        Json::Object(o) => {
512            if o.len() == 1
513                && let Some(Json::String(t)) = o.get("$ref")
514            {
515                return Ok(Json::String(a1(resolve_ref(t, env)?, env)));
516            }
517            // Text passed as it is: never a token, and no `{{…}}` in it is read.
518            if o.len() == 1
519                && let Some(Json::String(t)) = o.get("$text")
520            {
521                return Ok(Json::String(t.clone()));
522            }
523            let mut out = Map::new();
524            for (k, x) in o {
525                out.insert(k.clone(), walk(x, Some(k), env, depth + 1)?);
526            }
527            Json::Object(out)
528        }
529        // Items of a list keep their key's kind (`ranges: ["Input", …]`).
530        Json::Array(a) => Json::Array(a.iter().map(|x| walk(x, key, env, depth + 1)).collect::<R<Vec<_>>>()?),
531        Json::String(s) => {
532            if key.is_some_and(|k| is_ref_key(k) && !(is_general_key(k) && is_bare_item_word(s))) && is_token(s, env) {
533                Json::String(a1(resolve_token(s, env)?, env))
534            } else {
535                Json::String(interpolate(s, env)?)
536            }
537        }
538        other => other.clone(),
539    })
540}
541
542/// Replaces each `{{token}}` in `s`. `{{{{` is a literal `{{`, for text that needs the braces
543/// themselves (`"Dear {{{{name}}"` writes `Dear {{name}}`).
544pub fn interpolate(s: &str, env: &Env) -> R<String> {
545    if !s.contains("{{") {
546        return Ok(s.to_string());
547    }
548    let mut out = String::with_capacity(s.len());
549    let mut rest = s;
550    while let Some(i) = rest.find("{{") {
551        out.push_str(rest.get(..i).unwrap_or(""));
552        if rest.get(i..i + 4) == Some("{{{{") {
553            out.push_str("{{");
554            rest = rest.get(i + 4..).unwrap_or("");
555            continue;
556        }
557        let after = rest.get(i + 2..).unwrap_or("");
558        let Some(j) = after.find("}}") else {
559            out.push_str(rest.get(i..).unwrap_or(""));
560            return Ok(out);
561        };
562        let token = after.get(..j).unwrap_or("");
563        out.push_str(&a1(resolve_ref(token, env)?, env));
564        rest = after.get(j + 2..).unwrap_or("");
565    }
566    out.push_str(rest);
567    Ok(out)
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use serde_json::json;
574
575    fn wb() -> Workbook {
576        let mut wb = Workbook::new();
577        wb.sheets.push(std::sync::Arc::new(crate::model::Sheet::new("My Data")));
578        let sh = wb.sheet_mut(0).unwrap();
579        for (i, h) in ["Name", "Amount", "Date"].iter().enumerate() {
580            sh.set_value(CellRef::new(1, 1 + i as u32), crate::base::Value::text(*h));
581        }
582        wb.names.push(crate::model::DefinedName {
583            name: "Total".into(),
584            scope: None,
585            formula: "Sheet1!$F$20".into(),
586            comment: String::new(),
587            hidden: false,
588        });
589        wb.names.push(crate::model::DefinedName {
590            name: "Rates".into(),
591            scope: None,
592            formula: "'My Data'!$A$1:$A$5".into(),
593            comment: String::new(),
594            hidden: false,
595        });
596        wb
597    }
598
599    fn place(a1: &str) -> Place {
600        Place { sheet: 0, range: RangeRef::parse(a1).unwrap() }
601    }
602
603    fn check(env: &Env, token: &str, want: &str) {
604        match resolve_ref(token, env) {
605            Ok(p) => assert_eq!(a1(p, env), want, "{token}"),
606            Err(e) => panic!("{token}: {e}"),
607        }
608    }
609
610    #[test]
611    fn a_formula_header_matches_while_formulas_are_shown() {
612        // The header was read through the cell's display text, which is the formula while Show
613        // Formulas is on, so `col("Amount")` found no column headed by a formula.
614        let mut wb = wb();
615        let sh = wb.sheet_mut(0).unwrap();
616        let formula = Some(std::sync::Arc::new(crate::model::Formula::new("\"Amo\"&\"unt\"")));
617        sh.set_cell(
618            CellRef::new(1, 2),
619            crate::model::Cell { value: crate::base::Value::text("Amount"), formula, style: Default::default(), py: None },
620        );
621        sh.show_formulas = true;
622        let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
623        let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
624        check(&env, "Input.col(\"Amount\")", "C2:C11");
625    }
626
627    #[test]
628    fn every_token_resolves() {
629        let wb = wb();
630        // Input B2:D11 with a header row; Other on another sheet.
631        let inputs = vec![
632            ("Input".to_string(), place("B2:D11"), true),
633            ("Other".to_string(), Place { sheet: 1, range: RangeRef::parse("C3:C4").unwrap() }, false),
634        ];
635        let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("G2")), item: Some((RepeatOf::Rows, place("B5:D5"))) };
636        for (token, want) in [
637            ("Input", "B2:D11"),
638            ("input", "B2:D11"),
639            ("Input.col(2)", "C2:C11"),
640            ("Input.col(\"Amount\")", "C2:C11"),
641            ("Input.col(\"date\").data", "D3:D11"),
642            ("Input.cols(2..3).data", "C3:D11"),
643            ("Input.col(2).data.col(1)", "C3:C11"),
644            ("Input.data", "B3:D11"),
645            ("Input.data.col(2)", "C3:C11"),
646            ("Input.data.col(\"Amount\")", "C3:C11"),
647            ("Input.row(1)", "B2:D2"),
648            ("Input.rows(2..)", "B3:D11"),
649            ("Input.rows(2..4)", "B3:D5"),
650            ("Input.rows(..2)", "B2:D3"),
651            ("Input.cols(2..3)", "C2:D11"),
652            ("Input.cell(1,1)", "B2"),
653            ("Input.cell(3,2)", "C4"),
654            ("Input.cell(0,1)", "B1"),
655            ("Input.cell(12,3)", "D13"),
656            ("Input.offset(0,4)", "F2:H11"),
657            ("Input.offset(-1,-1)", "A1:C10"),
658            ("Input.cell(1,1).resize(2,3)", "B2:D3"),
659            ("Input.below()", "B12:D12"),
660            ("Input.below(2)", "B12:D13"),
661            ("Input.col(2).below", "C12"),
662            ("Input.right", "E2:E11"),
663            (" Input . col( 2 ) ", "C2:C11"),
664            ("Output", "G2"),
665            ("Output.offset(1,0)", "G3"),
666            ("Row", "B5:D5"),
667            ("Row.cell(1,2)", "C5"),
668            ("Column", "B5:D5"),
669            ("Name:Total", "F20"),
670            ("Name:Rates", "'My Data'!A1:A5"),
671            ("Name:Rates.cell(2,1)", "'My Data'!A2"),
672            ("Other", "'My Data'!C3:C4"),
673            ("B7", "B7"),
674            ("'My Data'!A1:B2", "'My Data'!A1:B2"),
675            ("Total", "F20"),
676        ] {
677            check(&env, token, want);
678        }
679    }
680
681    #[test]
682    fn bad_tokens_say_why() {
683        let wb = wb();
684        // The same cells, read with and without a header row.
685        let inputs = vec![("Input".to_string(), place("B2:D11"), false), ("Headed".to_string(), place("B2:D11"), true)];
686        let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
687        for (token, needle, edge) in [
688            ("Input.col(4)", "outside the range", false),
689            ("Input.col(0)", "outside the range", false),
690            ("Input.rows(5..2)", "outside the range", false),
691            ("Headed.col(\"Nope\")", "no column is headed", false),
692            ("Input.col(\"Name\")", "needs an input with a header row", false),
693            ("Headed.col(2).col(\"Amount\")", "needs an input with a header row", false),
694            ("Name:Rates.col(\"Name\")", "needs an input with a header row", false),
695            ("Input.colour(1)", "isn't a token method", false),
696            ("Input.col(1", "expected", false),
697            ("Input.col(x)", "whole number", false),
698            ("Input.col(99999999999)", "whole number", false),
699            ("Input.", "method name", false),
700            ("Input junk", "unexpected", false),
701            ("Input.resize(0,1)", "at least 1 row", false),
702            ("Output", "no output anchor", false),
703            ("Row", "inside a Repeat", false),
704            ("Name:", "needs a defined name", false),
705            ("Name:Nothing", "no name or table", false),
706            ("Nowhere!A1", "no sheet named", false),
707            ("Input.offset(-2,0)", "past the top", true),
708            ("Input.offset(0,-2)", "edge of the sheet", true),
709            ("Input.offset(1048570,0)", "past the last row", true),
710            ("Input.cell(1,16384)", "edge of the sheet", true),
711        ] {
712            let e = resolve_ref(token, &env).unwrap_err();
713            assert!(e.message().contains(needle), "{token}: {e}");
714            assert_eq!(matches!(e, ResolveError::Edge(_)), edge, "{token}");
715        }
716    }
717
718    #[test]
719    fn params_resolve_tokens_in_reference_keys_and_braces_anywhere() {
720        let wb = wb();
721        let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
722        let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("F2")), item: None };
723        let p = json!({
724            "cell": "Output",
725            "range": "Input.col(2)",
726            "ranges": ["Input.row(1)", "A1"],
727            "input": "Row",
728            "value": "Input",
729            "formula": "=SUM({{Input.data.col(2)}})/{{Name:Total}}",
730            "nested": {"target": {"$ref": "Input.cell(1,1)"}, "n": 3, "flag": true},
731            "text": {"$ref": "Output"},
732            "type": "column",
733            "sheet": "Input",
734            "rowInputCell": "Input.cell(1,1)",
735            "unclosed": "a {{b"
736        });
737        let r = resolve_params(&p, &env).unwrap();
738        assert_eq!(
739            r,
740            json!({
741                "cell": "F2",
742                "range": "C2:C11",
743                "ranges": ["B2:D2", "A1"],
744                "input": "Row",
745                "value": "Input",
746                "formula": "=SUM(C3:C11)/F20",
747                "nested": {"target": "B2", "n": 3, "flag": true},
748                "text": "F2",
749                "type": "column",
750                "sheet": "Input",
751                "rowInputCell": "B2",
752                "unclosed": "a {{b"
753            })
754        );
755        // Deeply nested parameters are refused, not followed.
756        let mut deep = json!(1);
757        for _ in 0..100 {
758            deep = json!([deep]);
759        }
760        assert!(resolve_params(&deep, &env).is_err());
761        assert!(resolve_params(&json!({"cell": "Input.col(9)"}), &env).is_err());
762    }
763
764    #[test]
765    fn references_on_sheets_named_like_tokens_stay_references() {
766        // Sheets named after the built-in bases and after an input: `Output!A1` is a cell on the
767        // sheet Output, in a reference key, in `$ref` and in `{{…}}`.
768        let mut wb = Workbook::new();
769        for name in ["Output", "Row", "Column", "Input", "Rates"] {
770            wb.sheets.push(std::sync::Arc::new(crate::model::Sheet::new(name)));
771        }
772        let inputs = vec![("Input".to_string(), place("B2:D11"), true), ("Rates".to_string(), place("F2:F5"), false)];
773        for output in [None, Some(place("H2"))] {
774            let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output, item: Some((RepeatOf::Rows, place("B5:D5"))) };
775            for (sheet, i) in [("Output", 1), ("Row", 2), ("Column", 3), ("Input", 4), ("Rates", 5)] {
776                for text in [format!("{sheet}!A1:B2"), format!("'{sheet}'!A1:B2")] {
777                    let want = Place { sheet: i, range: RangeRef::parse("A1:B2").unwrap() };
778                    assert_eq!(resolve_ref(&text, &env), Ok(want), "{text}");
779                    let a = a1(want, &env);
780                    let p = json!({"range": text, "target": {"$ref": text}, "formula": format!("=SUM({{{{{text}}}}})")});
781                    let r = resolve_params(&p, &env).unwrap();
782                    // A plain reference in a reference key is passed on as written.
783                    assert_eq!(r, json!({"range": text, "target": a, "formula": format!("=SUM({a})")}), "{text}");
784                }
785            }
786            assert!(!is_token("Output !A1", &env));
787            // The bare names are still tokens.
788            check(&env, "Input.col(2)", "C2:C11");
789            check(&env, "Rates", "F2:F5");
790            check(&env, "Row", "B5:D5");
791        }
792    }
793
794    #[test]
795    fn defined_names_can_hold_periods() {
796        // `Sales.Q1` is one defined name, beside `Sales`; `Data.row` has a segment that reads like
797        // a method. The longest name that exists is used, and methods follow it.
798        let mut wb = wb();
799        for (name, formula) in [("Sales", "Sheet1!$A$1"), ("Sales.Q1", "Sheet1!$B$2:$C$5"), ("Data.row", "'My Data'!$E$1:$E$3")] {
800            wb.names.push(crate::model::DefinedName {
801                name: name.into(),
802                scope: None,
803                formula: formula.into(),
804                comment: String::new(),
805                hidden: false,
806            });
807        }
808        let env = Env { wb: &wb, active_sheet: 0, inputs: &[], output: None, item: None };
809        for (token, want) in [
810            ("Name:Sales.Q1", "B2:C5"),
811            ("Name:sales.q1", "B2:C5"),
812            ("Name:Sales.Q1.cell(1,1)", "B2"),
813            ("Name:Sales.Q1.col(2).row(4)", "C5"),
814            ("Name:Sales", "A1"),
815            ("Name:Sales.offset(1,0)", "A2"),
816            ("Name:Data.row", "'My Data'!E1:E3"),
817            ("Name:Data.row.row(2)", "'My Data'!E2"),
818            ("Name:Data.row.cell(1,1)", "'My Data'!E1"),
819            ("Sales.Q1", "B2:C5"),
820        ] {
821            check(&env, token, want);
822        }
823        // In a reference parameter, `$ref` and `{{…}}`, with and without a method.
824        let p = json!({
825            "range": "Name:Sales.Q1",
826            "cell": "Name:Sales.Q1.cell(1,1)",
827            "a": {"$ref": "Name:Sales.Q1"},
828            "b": {"$ref": "Name:Sales.Q1.cell(2,2)"},
829            "formula": "=SUM({{Name:Sales.Q1}})+{{Name:Sales.Q1.cell(1,2)}}"
830        });
831        let r = resolve_params(&p, &env).unwrap();
832        assert_eq!(r, json!({"range": "B2:C5", "cell": "B2", "a": "B2:C5", "b": "C3", "formula": "=SUM(B2:C5)+C2"}));
833        // Unknown names and bad methods still fail, naming what is wrong.
834        for (token, needle) in [
835            ("Name:Nope.Q1", "no name or table called 'Nope.Q1'"),
836            ("Name:Nope.cell(1,1)", "no name or table called 'Nope'"),
837            ("Name:Sales.Q1.bogus(1)", "'bogus' with these arguments isn't a token method"),
838            ("Name:Sales.Q1.cell(1", "expected"),
839            ("Name:Sales.Q1.", "method name"),
840            ("Name:.cell(1,1)", "needs a defined name"),
841        ] {
842            let e = resolve_ref(token, &env).unwrap_err();
843            assert!(e.message().contains(needle), "{token}: {e}");
844            let e = resolve_params(&json!({"formula": format!("={{{{{token}}}}}")}), &env).unwrap_err();
845            assert!(e.message().contains(needle), "{token} in braces: {e}");
846        }
847    }
848
849    #[test]
850    fn sheet_names_are_quoted_when_needed() {
851        // The engine's rule: a name that reads as a cell reference, an R1C1 token or a boolean
852        // is quoted too, and a period on its own is not a reason to quote.
853        for (name, want) in [
854            ("Sheet1", "Sheet1"),
855            ("My Data", "'My Data'"),
856            ("O'Hara", "'O''Hara'"),
857            ("2024", "'2024'"),
858            ("", "''"),
859            ("Q1", "'Q1'"),
860            ("R1C1", "'R1C1'"),
861            ("TRUE", "'TRUE'"),
862            ("Sales.Q1", "Sales.Q1"),
863        ] {
864            assert_eq!(quote_sheet(name), want, "{name}");
865        }
866    }
867
868    #[test]
869    fn a_bare_row_or_column_under_a_general_key_is_text() {
870        // `to` is a replacement word in Change Spelling and `area` is `row` or `column` in a
871        // pivot field move, so a bare `Row` or `Column` there stays text, inside a Repeat or not;
872        // the token forms still resolve there, and the bare word resolves under `range`.
873        let wb = wb();
874        let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
875        for item in [None, Some((RepeatOf::Rows, place("B5:D5")))] {
876            let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("F2")), item };
877            let p = json!({"word": "Colum", "to": "Column", "area": "row", "from": "Input", "at": "Output"});
878            let r = resolve_params(&p, &env).unwrap();
879            assert_eq!(r, json!({"word": "Colum", "to": "Column", "area": "row", "from": "B2:D11", "at": "F2"}), "{item:?}");
880        }
881        let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: Some((RepeatOf::Rows, place("B5:D5"))) };
882        let p = json!({"to": "Row.col(2)", "range": "Row", "area": {"$ref": "Row"}, "text": "{{Row}}"});
883        let r = resolve_params(&p, &env).unwrap();
884        assert_eq!(r, json!({"to": "C5", "range": "B5:D5", "area": "B5:D5", "text": "B5:D5"}));
885    }
886
887    #[test]
888    fn a_header_row_alone_and_a_table_without_data_rows_are_refused() {
889        let mut wb = wb();
890        let sh = wb.sheet_mut(0).unwrap();
891        sh.tables.push(crate::model::Table {
892            id: 1,
893            name: "Empty".into(),
894            range: RangeRef::parse("H1:J1").unwrap(),
895            header_row: true,
896            totals_row: false,
897            columns: Vec::new(),
898            style: String::new(),
899            banded_rows: false,
900            banded_cols: false,
901            first_col: false,
902            last_col: false,
903            filter_button: false,
904            filter: Vec::new(),
905            table_type: None,
906            connection_id: None,
907        });
908        let inputs = vec![("Input".to_string(), place("B2:D2"), true), ("Plain".to_string(), place("B2:D2"), false)];
909        let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
910        assert!(resolve_ref("Input.data", &env).unwrap_err().to_string().contains("only a header row"));
911        assert!(resolve_ref("Input.data.col(2)", &env).unwrap_err().to_string().contains("only a header row"));
912        check(&env, "Plain.data", "B2:D2");
913        check(&env, "Input.col(2)", "C2");
914        assert!(resolve_ref("Name:Empty", &env).unwrap_err().to_string().contains("no data rows"));
915    }
916
917    #[test]
918    fn doubled_braces_are_literal_braces() {
919        let wb = wb();
920        let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
921        let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
922        assert_eq!(interpolate("Dear {{{{name}},", &env).unwrap(), "Dear {{name}},");
923        assert_eq!(interpolate("{{{{ {{Input.cell(1,1)}} }}", &env).unwrap(), "{{ B2 }}");
924        assert!(interpolate("Dear {{name}},", &env).is_err(), "an unknown token is still an error");
925    }
926
927    #[test]
928    fn text_marked_as_text_is_never_a_token() {
929        // A defined name called Output or Input in a reference parameter, a Name: prefix and
930        // braces: `$text` passes each as it is, in any key; unmarked, each reads as a token.
931        let wb = wb();
932        let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
933        let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
934        for text in ["Output", "Input", "Name:Nowhere", "Row", "Dear {{name}}"] {
935            let p = json!({"range": {"$text": text}, "input": {"$text": text}});
936            assert_eq!(resolve_params(&p, &env).unwrap(), json!({"range": text, "input": text}), "{text}");
937            assert_ne!(resolve_params(&json!({"range": text}), &env).ok(), Some(json!({"range": text})), "{text}");
938            assert!(reads_as_token(text, &["Input"]) || text.contains("{{"), "{text}");
939        }
940        assert!(!reads_as_token("Output!A1", &["Input"]) && !reads_as_token("B2", &["Input"]));
941    }
942
943    #[test]
944    fn a_defined_name_on_a_sheet_with_a_dollar_sign_resolves() {
945        let mut wb = wb();
946        wb.sheets.push(std::sync::Arc::new(crate::model::Sheet::new("US$")));
947        wb.names.push(crate::model::DefinedName {
948            name: "Dollars".into(),
949            scope: None,
950            formula: "'US$'!$F$20".into(),
951            comment: String::new(),
952            hidden: false,
953        });
954        let env = Env { wb: &wb, active_sheet: 0, inputs: &[], output: None, item: None };
955        check(&env, "Name:Dollars", "'US$'!F20");
956    }
957
958    #[test]
959    fn below_and_right_refuse_a_count_under_one() {
960        let wb = wb();
961        let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
962        let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
963        check(&env, "Input.below", "B12:D12");
964        check(&env, "Input.below(2)", "B12:D13");
965        check(&env, "Input.right(1)", "E2:E11");
966        for bad in ["Input.below(0)", "Input.below(-3)", "Input.right(0)"] {
967            assert!(resolve_ref(bad, &env).unwrap_err().to_string().contains("at least 1"), "{bad}");
968        }
969    }
970}