Skip to main content

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.
11///
12/// `A1` → `CellAddr { col: 1, row: 1 }`, `BC42` → `CellAddr { col: 55, row: 42 }`.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct CellAddr {
15    /// 1-based column index (`A` = 1, `Z` = 26, `AA` = 27).
16    pub col: u32,
17    /// 1-based row index.
18    pub row: u32,
19}
20
21impl CellAddr {
22    /// Parse an A1-style address (case-insensitive): letters followed by digits.
23    ///
24    /// Returns `None` for anything else (empty column/row part, row `0`,
25    /// trailing characters, or out-of-range values).
26    pub fn parse(text: &str) -> Option<Self> {
27        let bytes = text.as_bytes();
28        let col_end = bytes.iter().take_while(|b| b.is_ascii_alphabetic()).count();
29        if col_end == 0 || col_end == bytes.len() {
30            return None;
31        }
32        let row_str = &text[col_end..];
33        if !row_str.bytes().all(|b| b.is_ascii_digit()) {
34            return None;
35        }
36        let row: u32 = row_str.parse().ok()?;
37        if row == 0 {
38            return None;
39        }
40        let mut col: u32 = 0;
41        for b in &bytes[..col_end] {
42            let v = (b.to_ascii_uppercase() - b'A' + 1) as u32;
43            col = col.checked_mul(26)?.checked_add(v)?;
44        }
45        Some(Self { col, row })
46    }
47}
48
49impl fmt::Display for CellAddr {
50    /// Canonical A1 form: uppercase column letters followed by the row number.
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        let mut col = self.col;
53        let mut letters = [0u8; 8];
54        let mut n = 0;
55        while col > 0 {
56            let rem = ((col - 1) % 26) as u8;
57            letters[n] = b'A' + rem;
58            n += 1;
59            col = (col - 1) / 26;
60        }
61        for b in letters[..n].iter().rev() {
62            write!(f, "{}", *b as char)?;
63        }
64        write!(f, "{}", self.row)
65    }
66}
67
68/// A parsed reference. Resolution (what the reference evaluates to) is
69/// delegated to the embedding application; see the workbook v1 scope ADR.
70#[derive(Debug, Clone, PartialEq)]
71pub enum Ref {
72    /// A single cell, optionally sheet-qualified: `A1`, `Sheet1!A1`, `'Q2 Data'!A1`.
73    Cell {
74        /// Sheet name without quoting/escaping; `None` for a bare `A1` reference.
75        sheet: Option<String>,
76        addr: CellAddr,
77    },
78    /// A rectangular range, optionally sheet-qualified: `A1:D4`, `Sheet1!A1:B2`.
79    Range {
80        /// Sheet name without quoting/escaping; `None` for a bare range.
81        sheet: Option<String>,
82        start: CellAddr,
83        end: CellAddr,
84    },
85    /// A named reference (named range, defined name): `TAX_RATE`.
86    Name(String),
87}
88
89impl Ref {
90    /// Classify a bare identifier or range string into a [`Ref`].
91    ///
92    /// `"A1"` → [`Ref::Cell`] (no sheet), `"A1:D4"` → [`Ref::Range`] (no sheet),
93    /// anything else → [`Ref::Name`]. This is the mapping used for bare
94    /// identifiers in formulas, which stay [`crate::Expr::Variable`] nodes in
95    /// the AST for compatibility.
96    pub fn classify(text: &str) -> Ref {
97        if let Some(addr) = CellAddr::parse(text) {
98            return Ref::Cell { sheet: None, addr };
99        }
100        if let Some((s, e)) = text.split_once(':') {
101            if let (Some(start), Some(end)) = (CellAddr::parse(s), CellAddr::parse(e)) {
102                return Ref::Range { sheet: None, start, end };
103            }
104        }
105        Ref::Name(text.to_string())
106    }
107}
108
109/// True if `name` looks like an in-grid A1 cell address (1-3 column
110/// letters followed by digits), so it must be quoted before `!` to
111/// disambiguate: a sheet named `A1` is written `'A1'!B2`, while `Sheet1`
112/// (five column letters - beyond any real grid) stays unquoted.
113fn looks_like_cell_addr(name: &str) -> bool {
114    let letters = name.bytes().take_while(|b| b.is_ascii_alphabetic()).count();
115    (1..=3).contains(&letters) && CellAddr::parse(name).is_some()
116}
117
118/// True if `name` cannot appear unquoted before `!` in canonical form.
119/// Unquoted sheet names must match `^[A-Za-z_][A-Za-z0-9_]*$`, must not
120/// look like an A1 cell address (see [`looks_like_cell_addr`]), and must
121/// not be the boolean literals `TRUE`/`FALSE` (which the parser recognizes
122/// before identifiers, so an unquoted `TRUE!A1` would not re-parse).
123fn sheet_needs_quoting(name: &str) -> bool {
124    let mut chars = name.chars();
125    let bare = match chars.next() {
126        Some(c) if c.is_ascii_alphabetic() || c == '_' => {
127            chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
128        }
129        _ => false,
130    };
131    !bare
132        || looks_like_cell_addr(name)
133        || name.eq_ignore_ascii_case("TRUE")
134        || name.eq_ignore_ascii_case("FALSE")
135}
136
137fn write_sheet(f: &mut fmt::Formatter<'_>, sheet: &Option<String>) -> fmt::Result {
138    match sheet {
139        None => Ok(()),
140        Some(name) => {
141            if sheet_needs_quoting(name) {
142                write!(f, "'{}'!", name.replace('\'', "''"))
143            } else {
144                write!(f, "{}!", name)
145            }
146        }
147    }
148}
149
150impl fmt::Display for Ref {
151    /// Canonical text form per the workbook JSON schema v1 spec: sheet name
152    /// single-quoted iff it is not a bare identifier or would parse as an A1
153    /// address; embedded single quotes doubled (`''`).
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        match self {
156            Ref::Cell { sheet, addr } => {
157                write_sheet(f, sheet)?;
158                write!(f, "{}", addr)
159            }
160            Ref::Range { sheet, start, end } => {
161                write_sheet(f, sheet)?;
162                write!(f, "{}:{}", start, end)
163            }
164            Ref::Name(name) => write!(f, "{}", name),
165        }
166    }
167}