Skip to main content

formualizer_eval/
reference.rs

1//! Cell, coordinate, and range reference utilities for a spreadsheet engine.
2//!
3//! ## Design goals
4//! * **Compact**: small, `Copy`‑able types (12–16 bytes) that can be placed in large
5//!   dependency graphs without GC/heap pressure.
6//! * **Excel‑compatible semantics**: four anchoring modes (`A1`, `$A1`, `A$1`, `$A$1`)
7//!   plus optional sheet scoping.
8//! * **Utility helpers**: rebasing, offsetting, (de)serialising, and pretty `Display`.
9//!
10//! ----
11//!
12//! ```text
13//! ┌──────────┐    1) Parser/loader creates         ┌─────────────┐
14//! │  Coord   │────┐                                 │   CellRef   │
15//! └──────────┘    └──────┐      2) Linker inserts ─▶└─────────────┘
16//!  row, col, flags        │      SheetId + range
17//!                         ▼
18//!                ┌────────────────┐   (RangeRef = 2×CellRef)
19//!                │ Evaluation IR  │  (row/col absolute, flags dropped)
20//!                └────────────────┘
21//! ```
22
23use core::fmt;
24
25use crate::engine::sheet_registry::SheetRegistry; // `no_std`‑friendly; swap for `std::fmt` if you prefer
26use formualizer_common::{
27    ExcelError, ExcelErrorKind, RelativeCoord, SheetCellRef as CommonSheetCellRef,
28    SheetId as CommonSheetId, SheetLocator as CommonSheetLocator,
29    SheetRangeRef as CommonSheetRangeRef, SheetRef as CommonSheetRef,
30};
31use formualizer_parse::parser::ReferenceType;
32
33//------------------------------------------------------------------------------
34// Shared ref aliases (Phase 3.2 staging)
35//------------------------------------------------------------------------------
36
37pub type SharedSheetId = CommonSheetId;
38pub type SharedSheetLocator<'a> = CommonSheetLocator<'a>;
39pub type SharedCellRef<'a> = CommonSheetCellRef<'a>;
40pub type SharedRangeRef<'a> = CommonSheetRangeRef<'a>;
41pub type SharedRef<'a> = CommonSheetRef<'a>;
42
43//------------------------------------------------------------------------------
44// Coord
45//------------------------------------------------------------------------------
46
47/// One 2‑D grid coordinate (row, column) **plus** absolute/relative flags.
48///
49/// Internally delegates to `RelativeCoord` from `formualizer-common`, adding the
50/// historical API surface used throughout the evaluator.
51#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
52pub struct Coord(RelativeCoord);
53
54impl Coord {
55    /// Construct a 0-based coordinate.
56    ///
57    /// # Panics
58    ///
59    /// Panics if `row` exceeds 1,048,575 or `col` exceeds 16,383. Use
60    /// [`Coord::try_new`] for values that come from a file, a formula or an
61    /// API caller.
62    #[inline]
63    pub fn new(row: u32, col: u32, row_abs: bool, col_abs: bool) -> Self {
64        Self(RelativeCoord::new(row, col, row_abs, col_abs))
65    }
66
67    /// Fallible [`Coord::new`]: an error instead of a panic for a row past
68    /// 1,048,575 or a column past 16,383.
69    #[inline]
70    pub fn try_new(
71        row: u32,
72        col: u32,
73        row_abs: bool,
74        col_abs: bool,
75    ) -> Result<Self, formualizer_common::CoordError> {
76        RelativeCoord::try_new(row, col, row_abs, col_abs).map(Self)
77    }
78
79    /// Construct from Excel 1-based coordinates (0 saturates to 1).
80    ///
81    /// # Panics
82    ///
83    /// Panics if `row` exceeds 1,048,576 or `col` exceeds 16,384.
84    #[inline]
85    pub fn from_excel(row: u32, col: u32, row_abs: bool, col_abs: bool) -> Self {
86        let row0 = row.saturating_sub(1);
87        let col0 = col.saturating_sub(1);
88        Self(RelativeCoord::new(row0, col0, row_abs, col_abs))
89    }
90
91    #[inline]
92    pub fn row(self) -> u32 {
93        self.0.row()
94    }
95
96    #[inline]
97    pub fn col(self) -> u32 {
98        self.0.col()
99    }
100
101    #[inline]
102    pub fn row_abs(self) -> bool {
103        self.0.row_abs()
104    }
105
106    #[inline]
107    pub fn col_abs(self) -> bool {
108        self.0.col_abs()
109    }
110
111    #[inline]
112    pub fn with_row_abs(self, abs: bool) -> Self {
113        Self(self.0.with_row_abs(abs))
114    }
115
116    #[inline]
117    pub fn with_col_abs(self, abs: bool) -> Self {
118        Self(self.0.with_col_abs(abs))
119    }
120
121    #[inline]
122    pub fn offset(self, drow: i32, dcol: i32) -> Self {
123        Self(self.0.offset(drow, dcol))
124    }
125
126    #[inline]
127    pub fn rebase(self, origin: Coord, target: Coord) -> Self {
128        Self(self.0.rebase(origin.0, target.0))
129    }
130
131    #[inline]
132    pub fn into_inner(self) -> RelativeCoord {
133        self.0
134    }
135
136    pub fn col_to_letters(col: u32) -> String {
137        RelativeCoord::col_to_letters(col)
138    }
139
140    pub fn letters_to_col(s: &str) -> Option<u32> {
141        RelativeCoord::letters_to_col(s)
142    }
143}
144
145type SheetBounds = (Option<String>, (u32, u32, u32, u32));
146
147/// Combine two references with the range operator ':'
148/// Supports combining Cell:Cell, Cell:Range (and Range:Cell), and Range:Range on the same sheet.
149/// Returns #REF! for cross-sheet combinations or incompatible shapes, and
150/// #N/IMPL! when either endpoint is a name (name endpoints are not resolved yet).
151pub fn combine_references(
152    a: &ReferenceType,
153    b: &ReferenceType,
154) -> Result<ReferenceType, ExcelError> {
155    // Extract sheet and bounds as (sheet, (sr, sc, er, ec))
156    fn to_bounds(r: &ReferenceType) -> Option<SheetBounds> {
157        match r {
158            ReferenceType::Cell {
159                sheet, row, col, ..
160            } => Some((sheet.clone(), (*row, *col, *row, *col))),
161            ReferenceType::Range {
162                sheet,
163                start_row,
164                start_col,
165                end_row,
166                end_col,
167                ..
168            } => {
169                let (sr, sc, er, ec) = match (start_row, start_col, end_row, end_col) {
170                    (Some(sr), Some(sc), Some(er), Some(ec)) => (*sr, *sc, *er, *ec),
171                    _ => return None,
172                };
173                Some((sheet.clone(), (sr, sc, er, ec)))
174            }
175            _ => None,
176        }
177    }
178
179    // A defined name (or LET local) as an endpoint needs its bounds resolved
180    // first, which is not supported yet. Excel computes the bounding range, so
181    // `#REF!` would be a wrong answer; report the gap instead.
182    for endpoint in [a, b] {
183        if let ReferenceType::NamedRange(name) = endpoint {
184            return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(format!(
185                "Defined name '{name}' used as a range endpoint (':') is not supported yet"
186            )));
187        }
188    }
189
190    let (sheet_a, (a_sr, a_sc, a_er, a_ec)) = to_bounds(a).ok_or_else(|| {
191        ExcelError::new(ExcelErrorKind::Ref).with_message("Unsupported reference for ':'")
192    })?;
193    let (sheet_b, (b_sr, b_sc, b_er, b_ec)) = to_bounds(b).ok_or_else(|| {
194        ExcelError::new(ExcelErrorKind::Ref).with_message("Unsupported reference for ':'")
195    })?;
196
197    // Sheets must match (both None or equal Some)
198    if sheet_a != sheet_b {
199        return Err(ExcelError::new(ExcelErrorKind::Ref)
200            .with_message("Cannot combine references across sheets"));
201    }
202
203    let sr = a_sr.min(b_sr);
204    let sc = a_sc.min(b_sc);
205    let er = a_er.max(b_er);
206    let ec = a_ec.max(b_ec);
207
208    Ok(ReferenceType::Range {
209        sheet: sheet_a,
210        start_row: Some(sr),
211        start_col: Some(sc),
212        end_row: Some(er),
213        end_col: Some(ec),
214        start_row_abs: false,
215        start_col_abs: false,
216        end_row_abs: false,
217        end_col_abs: false,
218    })
219}
220
221impl fmt::Display for Coord {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        if self.col_abs() {
224            write!(f, "$")?;
225        }
226        write!(f, "{}", Self::col_to_letters(self.col()))?;
227        if self.row_abs() {
228            write!(f, "$")?;
229        }
230        // rows are 1‑based in A1 notation
231        write!(f, "{}", self.row() + 1)
232    }
233}
234
235//------------------------------------------------------------------------------
236// CellRef
237//------------------------------------------------------------------------------
238
239/// Sheet identifier inside a workbook.
240///
241/// Sheet ids are assigned by the engine/registry and have no sentinel values.
242pub type SheetId = u16; // 65,535 sheets should be enough for anyone.
243
244#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
245pub struct CellRef {
246    pub sheet_id: SheetId,
247    pub coord: Coord,
248}
249
250impl CellRef {
251    #[inline]
252    pub const fn new(sheet_id: SheetId, coord: Coord) -> Self {
253        Self { sheet_id, coord }
254    }
255
256    #[inline]
257    pub fn new_absolute(sheet_id: SheetId, row: u32, col: u32) -> Self {
258        Self {
259            sheet_id,
260            coord: Coord::new(row, col, true, true),
261        }
262    }
263
264    /// Rebase using underlying `Coord` logic.
265    #[inline]
266    pub fn rebase(self, origin: Coord, target: Coord) -> Self {
267        Self {
268            sheet_id: self.sheet_id,
269            coord: self.coord.rebase(origin, target),
270        }
271    }
272
273    #[inline]
274    pub fn sheet_name<'a>(&self, sheet_reg: &'a SheetRegistry) -> &'a str {
275        sheet_reg.name(self.sheet_id)
276    }
277
278    #[inline]
279    pub fn to_shared(self) -> SharedCellRef<'static> {
280        SharedCellRef::new(
281            SharedSheetLocator::Id(self.sheet_id),
282            self.coord.into_inner(),
283        )
284    }
285
286    /// A `CellRef` is sheet-resolved by construction, so an unresolved locator
287    /// is `#REF!` rather than something to guess at: there is no context sheet
288    /// here to interpret `Current` against (issue #110).
289    pub fn try_from_shared(cell: SharedCellRef<'_>) -> Result<Self, ExcelError> {
290        let owned = cell.into_owned();
291        let sheet_id = match owned.sheet {
292            SharedSheetLocator::Id(id) => id,
293            SharedSheetLocator::Current | SharedSheetLocator::Name(_) => {
294                return Err(ExcelError::new(ExcelErrorKind::Ref));
295            }
296        };
297        Ok(Self::new(sheet_id, Coord(owned.coord)))
298    }
299}
300
301impl fmt::Display for CellRef {
302    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
303        // Always include the sheet id; there is no longer a "current sheet" sentinel.
304        write!(f, "Sheet{}!", self.sheet_id)?;
305        write!(f, "{}", self.coord)
306    }
307}
308
309//------------------------------------------------------------------------------
310// RangeRef (half‑open range helper)
311//------------------------------------------------------------------------------
312
313#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
314pub struct RangeRef {
315    pub start: CellRef,
316    pub end: CellRef, // inclusive like Excel: A1:B5 covers both corners
317}
318
319impl RangeRef {
320    #[inline]
321    pub const fn new(start: CellRef, end: CellRef) -> Self {
322        Self { start, end }
323    }
324
325    pub fn try_to_shared(self) -> Result<SharedRangeRef<'static>, ExcelError> {
326        if self.start.sheet_id != self.end.sheet_id {
327            return Err(ExcelError::new(ExcelErrorKind::Ref));
328        }
329        let sheet = SharedSheetLocator::Id(self.start.sheet_id);
330        let sr =
331            formualizer_common::AxisBound::new(self.start.coord.row(), self.start.coord.row_abs());
332        let sc =
333            formualizer_common::AxisBound::new(self.start.coord.col(), self.start.coord.col_abs());
334        let er = formualizer_common::AxisBound::new(self.end.coord.row(), self.end.coord.row_abs());
335        let ec = formualizer_common::AxisBound::new(self.end.coord.col(), self.end.coord.col_abs());
336        SharedRangeRef::from_parts(sheet, Some(sr), Some(sc), Some(er), Some(ec))
337            .map_err(|_| ExcelError::new(ExcelErrorKind::Ref))
338    }
339
340    /// See [`CellRef::try_from_shared`]: an unresolved locator is `#REF!`.
341    pub fn try_from_shared(range: SharedRangeRef<'_>) -> Result<Self, ExcelError> {
342        let owned = range.into_owned();
343        let sheet_id = match owned.sheet {
344            SharedSheetLocator::Id(id) => id,
345            SharedSheetLocator::Current | SharedSheetLocator::Name(_) => {
346                return Err(ExcelError::new(ExcelErrorKind::Ref));
347            }
348        };
349        let (sr, sc, er, ec) = match (
350            owned.start_row,
351            owned.start_col,
352            owned.end_row,
353            owned.end_col,
354        ) {
355            (Some(sr), Some(sc), Some(er), Some(ec)) => (sr, sc, er, ec),
356            _ => return Err(ExcelError::new(ExcelErrorKind::Ref)),
357        };
358        let start = CellRef::new(sheet_id, Coord::new(sr.index, sc.index, sr.abs, sc.abs));
359        let end = CellRef::new(sheet_id, Coord::new(er.index, ec.index, er.abs, ec.abs));
360        Ok(Self::new(start, end))
361    }
362}
363
364impl fmt::Display for RangeRef {
365    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
366        if self.start.sheet_id == self.end.sheet_id {
367            // Single sheet: prefix once
368            write!(f, "{}:{}", self.start, self.end.coord)
369        } else {
370            // Different sheets: print fully.
371            write!(f, "{}:{}", self.start, self.end)
372        }
373    }
374}
375
376//------------------------------------------------------------------------------
377// Tests
378//------------------------------------------------------------------------------
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn test_display_coord() {
386        let c = Coord::new(0, 0, false, false);
387        assert_eq!(c.to_string(), "A1");
388        let c = Coord::new(7, 27, true, true); // row 8, col 28 == AB
389        assert_eq!(c.to_string(), "$AB$8");
390    }
391
392    #[test]
393    fn test_rebase() {
394        let origin = Coord::new(0, 0, false, false);
395        let target = Coord::new(1, 1, false, false);
396        let formula_coord = Coord::new(2, 0, false, true); // A3 with absolute col
397        let rebased = formula_coord.rebase(origin, target);
398        // Should move down 1 row, col stays because absolute
399        assert_eq!(rebased, Coord::new(3, 0, false, true));
400    }
401
402    #[test]
403    fn test_range_display() {
404        let a1 = CellRef::new(0, Coord::new(0, 0, false, false));
405        let b2 = CellRef::new(0, Coord::new(1, 1, false, false));
406        let r = RangeRef::new(a1, b2);
407        assert_eq!(r.to_string(), "Sheet0!A1:B2");
408    }
409}