truecalc_core/parser/refs.rs
1//! Reference model for the workbook grammar (P1.2).
2//!
3//! A [`Ref`] is the parser-level description of *what a formula points at*:
4//! a single cell, a rectangular range, or a named reference. Resolution is
5//! delegated to the caller (core = language); the engine itself never knows
6//! what a sheet or a named range contains.
7
8use std::fmt;
9
10/// A1-style cell address with 1-based column and row, plus per-axis `$`
11/// (absolute-reference) markers.
12///
13/// `A1` → `CellAddr::new(1, 1)`, `BC42` → `CellAddr::new(55, 42)`,
14/// `$A$1` → `CellAddr::new(1, 1).with_col_abs(true).with_row_abs(true)`.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct CellAddr {
17 /// 1-based column index (`A` = 1, `Z` = 26, `AA` = 27).
18 pub col: u32,
19 /// 1-based row index.
20 pub row: u32,
21 /// `true` if the column was written with a `$` anchor (`$A1`, `$A$1`).
22 pub col_abs: bool,
23 /// `true` if the row was written with a `$` anchor (`A$1`, `$A$1`).
24 pub row_abs: bool,
25}
26
27impl CellAddr {
28 /// A relative address (no `$` on either axis) — the common case.
29 pub const fn new(col: u32, row: u32) -> Self {
30 Self { col, row, col_abs: false, row_abs: false }
31 }
32
33 /// Set whether the column is `$`-anchored.
34 pub const fn with_col_abs(mut self, abs: bool) -> Self {
35 self.col_abs = abs;
36 self
37 }
38
39 /// Set whether the row is `$`-anchored.
40 pub const fn with_row_abs(mut self, abs: bool) -> Self {
41 self.row_abs = abs;
42 self
43 }
44
45 /// Parse an A1-style address (case-insensitive): an optional `$`, column
46 /// letters, an optional `$`, then row digits. A `$` before the letters
47 /// marks the column absolute; a `$` before the digits marks the row
48 /// absolute.
49 ///
50 /// Returns `None` for anything else (empty column/row part, row `0`,
51 /// trailing characters, malformed `$` placement, or out-of-range values).
52 pub fn parse(text: &str) -> Option<Self> {
53 let bytes = text.as_bytes();
54 let mut idx = 0;
55 let col_abs = bytes.first() == Some(&b'$');
56 if col_abs {
57 idx += 1;
58 }
59 let col_start = idx;
60 let col_end =
61 col_start + bytes[col_start..].iter().take_while(|b| b.is_ascii_alphabetic()).count();
62 if col_end == col_start {
63 return None;
64 }
65 idx = col_end;
66 let row_abs = bytes.get(idx) == Some(&b'$');
67 if row_abs {
68 idx += 1;
69 }
70 let row_str = &text[idx..];
71 if row_str.is_empty() || !row_str.bytes().all(|b| b.is_ascii_digit()) {
72 return None;
73 }
74 let row: u32 = row_str.parse().ok()?;
75 if row == 0 {
76 return None;
77 }
78 let mut col: u32 = 0;
79 for b in &bytes[col_start..col_end] {
80 let v = (b.to_ascii_uppercase() - b'A' + 1) as u32;
81 col = col.checked_mul(26)?.checked_add(v)?;
82 }
83 Some(Self { col, row, col_abs, row_abs })
84 }
85}
86
87impl fmt::Display for CellAddr {
88 /// Canonical A1 form: uppercase column letters followed by the row
89 /// number, each axis preceded by `$` when marked absolute.
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 if self.col_abs {
92 write!(f, "$")?;
93 }
94 let mut col = self.col;
95 let mut letters = [0u8; 8];
96 let mut n = 0;
97 while col > 0 {
98 let rem = ((col - 1) % 26) as u8;
99 letters[n] = b'A' + rem;
100 n += 1;
101 col = (col - 1) / 26;
102 }
103 for b in letters[..n].iter().rev() {
104 write!(f, "{}", *b as char)?;
105 }
106 if self.row_abs {
107 write!(f, "$")?;
108 }
109 write!(f, "{}", self.row)
110 }
111}
112
113/// A parsed reference. Resolution (what the reference evaluates to) is
114/// delegated to the embedding application; see the workbook v1 scope ADR.
115#[derive(Debug, Clone, PartialEq)]
116pub enum Ref {
117 /// A single cell, optionally sheet-qualified: `A1`, `Sheet1!A1`, `'Q2 Data'!A1`.
118 Cell {
119 /// Sheet name without quoting/escaping; `None` for a bare `A1` reference.
120 sheet: Option<String>,
121 addr: CellAddr,
122 },
123 /// A rectangular range, optionally sheet-qualified: `A1:D4`, `Sheet1!A1:B2`.
124 Range {
125 /// Sheet name without quoting/escaping; `None` for a bare range.
126 sheet: Option<String>,
127 start: CellAddr,
128 end: CellAddr,
129 },
130 /// A named reference (named range, defined name): `TAX_RATE`.
131 Name(String),
132 /// A structured table reference: `Table[Column]` (whole-column, array) or
133 /// `Table[@Column]` / unqualified `[@Column]` (current-row, scalar).
134 /// `table` is `None` only for the unqualified `@` form. Resolution
135 /// (which table, which row) is delegated to the caller, same as `Name`.
136 Table {
137 table: Option<String>,
138 column: String,
139 this_row: bool,
140 },
141}
142
143impl Ref {
144 /// Classify a bare identifier or range string into a [`Ref`].
145 ///
146 /// `"A1"` → [`Ref::Cell`] (no sheet), `"A1:D4"` → [`Ref::Range`] (no sheet),
147 /// anything else → [`Ref::Name`]. This is the mapping used for bare
148 /// identifiers in formulas, which stay [`crate::Expr::Variable`] nodes in
149 /// the AST for compatibility.
150 pub fn classify(text: &str) -> Ref {
151 if let Some(addr) = CellAddr::parse(text) {
152 return Ref::Cell { sheet: None, addr };
153 }
154 if let Some((s, e)) = text.split_once(':') {
155 if let (Some(start), Some(end)) = (CellAddr::parse(s), CellAddr::parse(e)) {
156 return Ref::Range { sheet: None, start, end };
157 }
158 }
159 Ref::Name(text.to_string())
160 }
161
162 /// Canonical text with all `$` anchors stripped — an identity/lookup key
163 /// where `$` must not affect equality (e.g. resolver-override lookups,
164 /// dependency-graph dedup). Built structurally (zeroing `col_abs`/
165 /// `row_abs` before formatting), not by string-replacing `$` out of the
166 /// rendered text, so a quoted sheet name that legitimately contains a
167 /// literal `$` is not corrupted.
168 pub fn relative_display(&self) -> String {
169 match self {
170 Ref::Cell { sheet, addr } => Ref::Cell {
171 sheet: sheet.clone(),
172 addr: CellAddr::new(addr.col, addr.row),
173 }
174 .to_string(),
175 Ref::Range { sheet, start, end } => Ref::Range {
176 sheet: sheet.clone(),
177 start: CellAddr::new(start.col, start.row),
178 end: CellAddr::new(end.col, end.row),
179 }
180 .to_string(),
181 Ref::Name(_) => self.to_string(),
182 Ref::Table { .. } => self.to_string(),
183 }
184 }
185}
186
187/// True if `name` looks like an in-grid A1 cell address (1-3 column
188/// letters followed by digits), so it must be quoted before `!` to
189/// disambiguate: a sheet named `A1` is written `'A1'!B2`, while `Sheet1`
190/// (five column letters - beyond any real grid) stays unquoted.
191fn looks_like_cell_addr(name: &str) -> bool {
192 let letters = name.bytes().take_while(|b| b.is_ascii_alphabetic()).count();
193 (1..=3).contains(&letters) && CellAddr::parse(name).is_some()
194}
195
196/// True if `name` cannot appear unquoted before `!` in canonical form.
197/// Unquoted sheet names must match `^[A-Za-z_][A-Za-z0-9_]*$`, must not
198/// look like an A1 cell address (see [`looks_like_cell_addr`]), and must
199/// not be the boolean literals `TRUE`/`FALSE` (which the parser recognizes
200/// before identifiers, so an unquoted `TRUE!A1` would not re-parse).
201fn sheet_needs_quoting(name: &str) -> bool {
202 let mut chars = name.chars();
203 let bare = match chars.next() {
204 Some(c) if c.is_ascii_alphabetic() || c == '_' => {
205 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
206 }
207 _ => false,
208 };
209 !bare
210 || looks_like_cell_addr(name)
211 || name.eq_ignore_ascii_case("TRUE")
212 || name.eq_ignore_ascii_case("FALSE")
213}
214
215pub(crate) fn write_sheet(f: &mut dyn fmt::Write, sheet: &Option<String>) -> fmt::Result {
216 match sheet {
217 None => Ok(()),
218 Some(name) => {
219 if sheet_needs_quoting(name) {
220 write!(f, "'{}'!", name.replace('\'', "''"))
221 } else {
222 write!(f, "{}!", name)
223 }
224 }
225 }
226}
227
228impl fmt::Display for Ref {
229 /// Canonical text form per the workbook JSON schema v1 spec: sheet name
230 /// single-quoted iff it is not a bare identifier or would parse as an A1
231 /// address; embedded single quotes doubled (`''`).
232 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233 match self {
234 Ref::Cell { sheet, addr } => {
235 write_sheet(f, sheet)?;
236 write!(f, "{}", addr)
237 }
238 Ref::Range { sheet, start, end } => {
239 write_sheet(f, sheet)?;
240 write!(f, "{}:{}", start, end)
241 }
242 Ref::Name(name) => write!(f, "{}", name),
243 Ref::Table { table, column, this_row } => {
244 if let Some(t) = table {
245 write!(f, "{}", t)?;
246 }
247 write!(f, "[")?;
248 if *this_row {
249 write!(f, "@")?;
250 }
251 write!(f, "{}]", column)
252 }
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests;