Skip to main content

formualizer_common/
address.rs

1//! Sheet-scoped reference helpers shared across the workspace.
2
3use std::borrow::Cow;
4use std::error::Error;
5use std::fmt;
6
7use crate::coord::{A1ParseError, CoordError, RelativeCoord};
8
9/// Stable sheet identifier used across the workspace.
10pub type SheetId = u16;
11
12/// Compact, stable packed address for an absolute grid cell: `(SheetId, row0, col0)`.
13///
14/// This is intended for high-volume, allocation-free data paths (e.g. evaluation deltas,
15/// dependency attribution, UI invalidation, FFI).
16///
17/// Bit layout (low → high):
18/// - `row0`: 20 bits (0..=1_048_575)
19/// - `col0`: 14 bits (0..=16_383)
20/// - `sheet_id`: 16 bits
21///
22/// This packing is a public contract. Do not change the bit layout without a major
23/// version bump.
24#[repr(transparent)]
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
26pub struct PackedSheetCell(u64);
27
28impl PackedSheetCell {
29    const ROW_BITS: u32 = 20;
30    const COL_BITS: u32 = 14;
31    const SHEET_BITS: u32 = 16;
32
33    const COL_SHIFT: u32 = Self::ROW_BITS;
34    const SHEET_SHIFT: u32 = Self::ROW_BITS + Self::COL_BITS;
35
36    const ROW_MASK: u64 = (1u64 << Self::ROW_BITS) - 1;
37    const COL_MASK: u64 = (1u64 << Self::COL_BITS) - 1;
38    const SHEET_MASK: u64 = (1u64 << Self::SHEET_BITS) - 1;
39
40    pub const MAX_ROW0: u32 = Self::ROW_MASK as u32;
41    pub const MAX_COL0: u32 = Self::COL_MASK as u32;
42    const USED_BITS: u32 = Self::ROW_BITS + Self::COL_BITS + Self::SHEET_BITS;
43    const USED_MASK: u64 = (1u64 << Self::USED_BITS) - 1;
44
45    /// Construct from a resolved sheet id and 0-based row/col indices.
46    ///
47    /// Returns `None` if indices exceed Excel's packed bounds.
48    pub const fn try_new(sheet_id: SheetId, row0: u32, col0: u32) -> Option<Self> {
49        if row0 > Self::MAX_ROW0 || col0 > Self::MAX_COL0 {
50            return None;
51        }
52        let packed = (row0 as u64)
53            | ((col0 as u64) << Self::COL_SHIFT)
54            | ((sheet_id as u64) << Self::SHEET_SHIFT);
55        Some(Self(packed))
56    }
57
58    /// Return the packed representation as a `u64` (stable ABI for FFI/serialization).
59    pub const fn as_u64(self) -> u64 {
60        self.0
61    }
62
63    /// Construct from a packed `u64` representation.
64    ///
65    /// Returns `None` if the upper unused bits are set, or if row/col exceed bounds.
66    pub const fn try_from_u64(raw: u64) -> Option<Self> {
67        if (raw & !Self::USED_MASK) != 0 {
68            return None;
69        }
70        let row0 = (raw & Self::ROW_MASK) as u32;
71        let col0 = ((raw >> Self::COL_SHIFT) & Self::COL_MASK) as u32;
72        if row0 > Self::MAX_ROW0 || col0 > Self::MAX_COL0 {
73            return None;
74        }
75        Some(Self(raw))
76    }
77
78    /// Construct from Excel-style 1-based row/col indices.
79    pub fn try_from_excel_1based(sheet_id: SheetId, row: u32, col: u32) -> Option<Self> {
80        let row0 = row.checked_sub(1)?;
81        let col0 = col.checked_sub(1)?;
82        Self::try_new(sheet_id, row0, col0)
83    }
84
85    pub const fn sheet_id(self) -> SheetId {
86        ((self.0 >> Self::SHEET_SHIFT) & Self::SHEET_MASK) as SheetId
87    }
88
89    pub const fn row0(self) -> u32 {
90        (self.0 & Self::ROW_MASK) as u32
91    }
92
93    pub const fn col0(self) -> u32 {
94        ((self.0 >> Self::COL_SHIFT) & Self::COL_MASK) as u32
95    }
96
97    pub const fn to_excel_1based(self) -> (SheetId, u32, u32) {
98        (self.sheet_id(), self.row0() + 1, self.col0() + 1)
99    }
100}
101
102/// Errors that can occur while constructing sheet-scoped references.
103#[derive(Clone, Debug, Eq, PartialEq)]
104pub enum SheetAddressError {
105    /// Encountered a 0 or underflowed 1-based index when converting to 0-based.
106    ZeroIndex,
107    /// Start/end coordinates were not ordered (start <= end).
108    RangeOrder,
109    /// Attempted to combine references with different sheet locators.
110    MismatchedSheets,
111    /// Requested operation requires a sheet name but only an id/current was supplied.
112    MissingSheetName,
113    /// Attempted to convert an unbounded range into a bounded representation.
114    UnboundedRange,
115    /// Wrapped [`CoordError`] that originated from `RelativeCoord`.
116    Coord(CoordError),
117    /// Wrapped [`A1ParseError`] originating from A1 parsing.
118    Parse(A1ParseError),
119}
120
121impl fmt::Display for SheetAddressError {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match self {
124            SheetAddressError::ZeroIndex => {
125                write!(f, "row and column indices must be 1-based (>= 1)")
126            }
127            SheetAddressError::RangeOrder => {
128                write!(
129                    f,
130                    "range must be ordered so the start is above/left of the end"
131                )
132            }
133            SheetAddressError::MismatchedSheets => {
134                write!(f, "range bounds refer to different sheets")
135            }
136            SheetAddressError::MissingSheetName => {
137                write!(f, "sheet name required to materialise textual address")
138            }
139            SheetAddressError::UnboundedRange => {
140                write!(f, "range requires explicit bounds")
141            }
142            SheetAddressError::Coord(err) => err.fmt(f),
143            SheetAddressError::Parse(err) => err.fmt(f),
144        }
145    }
146}
147
148impl Error for SheetAddressError {}
149
150impl From<CoordError> for SheetAddressError {
151    fn from(value: CoordError) -> Self {
152        SheetAddressError::Coord(value)
153    }
154}
155
156impl From<A1ParseError> for SheetAddressError {
157    fn from(value: A1ParseError) -> Self {
158        SheetAddressError::Parse(value)
159    }
160}
161
162/// Sheet locator that can carry either a resolved id, a name, or the current sheet.
163#[derive(Clone, Debug, Default, Eq, PartialEq, Hash)]
164pub enum SheetLocator<'a> {
165    /// Reference is scoped to the sheet containing the formula.
166    #[default]
167    Current,
168    /// Resolved sheet id.
169    Id(SheetId),
170    /// Unresolved sheet name (borrowed or owned).
171    Name(Cow<'a, str>),
172}
173
174impl<'a> SheetLocator<'a> {
175    /// Construct a locator for the current sheet.
176    pub const fn current() -> Self {
177        SheetLocator::Current
178    }
179
180    /// Construct from a resolved sheet id.
181    pub const fn from_id(id: SheetId) -> Self {
182        SheetLocator::Id(id)
183    }
184
185    /// Construct from a sheet name (borrowed or owned).
186    pub fn from_name(name: impl Into<Cow<'a, str>>) -> Self {
187        SheetLocator::Name(name.into())
188    }
189
190    /// Returns the sheet id if present.
191    pub const fn id(&self) -> Option<SheetId> {
192        match self {
193            SheetLocator::Id(id) => Some(*id),
194            SheetLocator::Current | SheetLocator::Name(_) => None,
195        }
196    }
197
198    /// Returns the sheet name if present.
199    pub fn name(&self) -> Option<&str> {
200        match self {
201            SheetLocator::Name(name) => Some(name.as_ref()),
202            SheetLocator::Current | SheetLocator::Id(_) => None,
203        }
204    }
205
206    /// Returns true if this locator refers to the current sheet.
207    pub const fn is_current(&self) -> bool {
208        matches!(self, SheetLocator::Current)
209    }
210
211    /// Borrow the locator, ensuring any owned name is exposed by reference.
212    pub fn as_ref(&self) -> SheetLocator<'_> {
213        match self {
214            SheetLocator::Current => SheetLocator::Current,
215            SheetLocator::Id(id) => SheetLocator::Id(*id),
216            SheetLocator::Name(name) => SheetLocator::Name(Cow::Borrowed(name.as_ref())),
217        }
218    }
219
220    /// Convert the locator into an owned `'static` form.
221    pub fn into_owned(self) -> SheetLocator<'static> {
222        match self {
223            SheetLocator::Current => SheetLocator::Current,
224            SheetLocator::Id(id) => SheetLocator::Id(id),
225            SheetLocator::Name(name) => SheetLocator::Name(Cow::Owned(name.into_owned())),
226        }
227    }
228}
229
230impl<'a> From<SheetId> for SheetLocator<'a> {
231    fn from(value: SheetId) -> Self {
232        SheetLocator::from_id(value)
233    }
234}
235
236impl<'a> From<&'a str> for SheetLocator<'a> {
237    fn from(value: &'a str) -> Self {
238        SheetLocator::from_name(value)
239    }
240}
241
242impl<'a> From<String> for SheetLocator<'a> {
243    fn from(value: String) -> Self {
244        SheetLocator::from_name(value)
245    }
246}
247
248/// Bound on a single axis (row or column).
249#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
250pub struct AxisBound {
251    /// 0-based index.
252    pub index: u32,
253    /// True if anchored with '$'.
254    pub abs: bool,
255}
256
257impl AxisBound {
258    pub const fn new(index: u32, abs: bool) -> Self {
259        AxisBound { index, abs }
260    }
261
262    /// Construct from an Excel 1-based index.
263    pub fn from_excel_1based(index: u32, abs: bool) -> Result<Self, SheetAddressError> {
264        let index0 = index.checked_sub(1).ok_or(SheetAddressError::ZeroIndex)?;
265        Ok(AxisBound::new(index0, abs))
266    }
267
268    /// Convert to Excel 1-based index.
269    pub const fn to_excel_1based(self) -> u32 {
270        self.index + 1
271    }
272}
273
274/// Sheet-scoped cell reference that retains relative/absolute anchors.
275#[derive(Clone, Debug, Eq, PartialEq, Hash)]
276pub struct SheetCellRef<'a> {
277    pub sheet: SheetLocator<'a>,
278    pub coord: RelativeCoord,
279}
280
281impl<'a> SheetCellRef<'a> {
282    pub const fn new(sheet: SheetLocator<'a>, coord: RelativeCoord) -> Self {
283        SheetCellRef { sheet, coord }
284    }
285
286    /// Construct from Excel 1-based coordinates with anchor flags.
287    pub fn from_excel(
288        sheet: SheetLocator<'a>,
289        row: u32,
290        col: u32,
291        row_abs: bool,
292        col_abs: bool,
293    ) -> Result<Self, SheetAddressError> {
294        let row0 = row.checked_sub(1).ok_or(SheetAddressError::ZeroIndex)?;
295        let col0 = col.checked_sub(1).ok_or(SheetAddressError::ZeroIndex)?;
296        let coord = RelativeCoord::try_new(row0, col0, row_abs, col_abs)?;
297        Ok(SheetCellRef::new(sheet, coord))
298    }
299
300    /// Parse an A1-style reference for this sheet.
301    pub fn try_from_a1(
302        sheet: SheetLocator<'a>,
303        reference: &str,
304    ) -> Result<Self, SheetAddressError> {
305        let coord = RelativeCoord::try_from_a1(reference)?;
306        Ok(SheetCellRef::new(sheet, coord))
307    }
308
309    /// Borrowing variant that preserves the lifetime of the sheet locator.
310    pub fn as_ref(&self) -> SheetCellRef<'_> {
311        SheetCellRef {
312            sheet: self.sheet.as_ref(),
313            coord: self.coord,
314        }
315    }
316
317    /// Convert into an owned `'static` reference.
318    pub fn into_owned(self) -> SheetCellRef<'static> {
319        SheetCellRef {
320            sheet: self.sheet.into_owned(),
321            coord: self.coord,
322        }
323    }
324}
325
326/// Sheet-scoped range reference. Bounds are inclusive; None indicates an unbounded side.
327#[derive(Clone, Debug, Eq, PartialEq, Hash)]
328pub struct SheetRangeRef<'a> {
329    pub sheet: SheetLocator<'a>,
330    pub start_row: Option<AxisBound>,
331    pub start_col: Option<AxisBound>,
332    pub end_row: Option<AxisBound>,
333    pub end_col: Option<AxisBound>,
334}
335
336impl<'a> SheetRangeRef<'a> {
337    pub const fn new(
338        sheet: SheetLocator<'a>,
339        start_row: Option<AxisBound>,
340        start_col: Option<AxisBound>,
341        end_row: Option<AxisBound>,
342        end_col: Option<AxisBound>,
343    ) -> Self {
344        SheetRangeRef {
345            sheet,
346            start_row,
347            start_col,
348            end_row,
349            end_col,
350        }
351    }
352
353    /// Construct a range from two cell references, ensuring sheet/order validity.
354    pub fn from_cells(
355        start: SheetCellRef<'a>,
356        end: SheetCellRef<'a>,
357    ) -> Result<Self, SheetAddressError> {
358        if start.sheet != end.sheet {
359            return Err(SheetAddressError::MismatchedSheets);
360        }
361        let sr = AxisBound::new(start.coord.row(), start.coord.row_abs());
362        let sc = AxisBound::new(start.coord.col(), start.coord.col_abs());
363        let er = AxisBound::new(end.coord.row(), end.coord.row_abs());
364        let ec = AxisBound::new(end.coord.col(), end.coord.col_abs());
365        SheetRangeRef::from_parts(start.sheet, Some(sr), Some(sc), Some(er), Some(ec))
366    }
367
368    /// Construct from Excel 1-based bounds and anchor flags.
369    #[allow(clippy::too_many_arguments)]
370    pub fn from_excel_rect(
371        sheet: SheetLocator<'a>,
372        start_row: u32,
373        start_col: u32,
374        end_row: u32,
375        end_col: u32,
376        start_row_abs: bool,
377        start_col_abs: bool,
378        end_row_abs: bool,
379        end_col_abs: bool,
380    ) -> Result<Self, SheetAddressError> {
381        let sr = AxisBound::from_excel_1based(start_row, start_row_abs)?;
382        let sc = AxisBound::from_excel_1based(start_col, start_col_abs)?;
383        let er = AxisBound::from_excel_1based(end_row, end_row_abs)?;
384        let ec = AxisBound::from_excel_1based(end_col, end_col_abs)?;
385        SheetRangeRef::from_parts(sheet, Some(sr), Some(sc), Some(er), Some(ec))
386    }
387
388    /// Helper to build a range from raw bounds, validating ordering when bounded.
389    pub fn from_parts(
390        sheet: SheetLocator<'a>,
391        start_row: Option<AxisBound>,
392        start_col: Option<AxisBound>,
393        end_row: Option<AxisBound>,
394        end_col: Option<AxisBound>,
395    ) -> Result<Self, SheetAddressError> {
396        if let (Some(sr), Some(er)) = (start_row, end_row)
397            && sr.index > er.index
398        {
399            return Err(SheetAddressError::RangeOrder);
400        }
401        if let (Some(sc), Some(ec)) = (start_col, end_col)
402            && sc.index > ec.index
403        {
404            return Err(SheetAddressError::RangeOrder);
405        }
406        Ok(SheetRangeRef::new(
407            sheet, start_row, start_col, end_row, end_col,
408        ))
409    }
410
411    /// Borrowing variant preserving the sheet locator lifetime.
412    pub fn as_ref(&self) -> SheetRangeRef<'_> {
413        SheetRangeRef {
414            sheet: self.sheet.as_ref(),
415            start_row: self.start_row,
416            start_col: self.start_col,
417            end_row: self.end_row,
418            end_col: self.end_col,
419        }
420    }
421
422    /// Convert into an owned `'static` range.
423    pub fn into_owned(self) -> SheetRangeRef<'static> {
424        SheetRangeRef {
425            sheet: self.sheet.into_owned(),
426            start_row: self.start_row,
427            start_col: self.start_col,
428            end_row: self.end_row,
429            end_col: self.end_col,
430        }
431    }
432}
433
434/// Sheet-scoped grid reference (cell or range).
435#[derive(Clone, Debug, Eq, PartialEq, Hash)]
436pub enum SheetRef<'a> {
437    Cell(SheetCellRef<'a>),
438    Range(SheetRangeRef<'a>),
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn sheet_locator_roundtrip() {
447        let loc = SheetLocator::from_id(7);
448        assert_eq!(loc.id(), Some(7));
449        assert_eq!(loc.name(), None);
450        assert_eq!(loc.as_ref(), SheetLocator::Id(7));
451
452        let name = SheetLocator::from_name("Data");
453        assert_eq!(name.id(), None);
454        assert_eq!(name.name(), Some("Data"));
455        let owned = name.clone().into_owned();
456        assert_eq!(owned.name(), Some("Data"));
457        assert_eq!(name, owned.as_ref());
458
459        let current = SheetLocator::current();
460        assert!(current.is_current());
461        assert_eq!(current.id(), None);
462    }
463
464    #[test]
465    fn cell_from_excel_preserves_flags() {
466        let a1 = SheetCellRef::from_excel(SheetLocator::from_name("Sheet1"), 1, 1, false, false)
467            .expect("valid cell");
468        assert_eq!(a1.coord.row(), 0);
469        assert_eq!(a1.coord.col(), 0);
470        assert!(!a1.coord.row_abs());
471        assert!(!a1.coord.col_abs());
472
473        let abs = SheetCellRef::from_excel(SheetLocator::from_name("Sheet1"), 3, 2, true, false)
474            .expect("valid absolute cell");
475        assert_eq!(abs.coord.row(), 2);
476        assert!(abs.coord.row_abs());
477        assert!(!abs.coord.col_abs());
478    }
479
480    #[test]
481    fn cell_from_excel_rejects_zero() {
482        let err = SheetCellRef::from_excel(SheetLocator::from_name("Sheet1"), 0, 1, false, false)
483            .unwrap_err();
484        assert_eq!(err, SheetAddressError::ZeroIndex);
485    }
486
487    #[test]
488    fn range_from_cells_validates_sheet_and_order() {
489        let sheet = SheetLocator::from_name("Sheet1");
490        let start = SheetCellRef::try_from_a1(sheet.as_ref(), "A1").unwrap();
491        let end = SheetCellRef::try_from_a1(sheet.as_ref(), "$B$3").unwrap();
492        let range = SheetRangeRef::from_cells(start.clone(), end.clone()).unwrap();
493        assert_eq!(range.start_row.unwrap().index, 0);
494        assert_eq!(range.end_row.unwrap().index, 2);
495
496        let other_sheet =
497            SheetCellRef::try_from_a1(SheetLocator::from_name("Other"), "C2").unwrap();
498        assert_eq!(
499            SheetRangeRef::from_cells(start, other_sheet).unwrap_err(),
500            SheetAddressError::MismatchedSheets
501        );
502
503        let inverted = SheetRangeRef::from_parts(
504            SheetLocator::from_name("Sheet1"),
505            Some(AxisBound::new(end.coord.row(), end.coord.row_abs())),
506            Some(AxisBound::new(end.coord.col(), end.coord.col_abs())),
507            Some(AxisBound::new(0, false)),
508            Some(AxisBound::new(0, false)),
509        );
510        assert_eq!(inverted.unwrap_err(), SheetAddressError::RangeOrder);
511    }
512
513    #[test]
514    fn packed_sheet_cell_roundtrip() {
515        let packed = PackedSheetCell::try_new(7, 10, 8).unwrap();
516        assert_eq!(packed.sheet_id(), 7);
517        assert_eq!(packed.row0(), 10);
518        assert_eq!(packed.col0(), 8);
519        assert_eq!(packed.to_excel_1based(), (7, 11, 9));
520        assert_eq!(
521            PackedSheetCell::try_from_excel_1based(7, 11, 9),
522            Some(packed)
523        );
524        assert_eq!(PackedSheetCell::try_from_excel_1based(7, 0, 1), None);
525        assert_eq!(PackedSheetCell::try_from_u64(packed.as_u64()), Some(packed));
526        assert_eq!(
527            PackedSheetCell::try_from_u64(packed.as_u64() | (1u64 << 63)),
528            None
529        );
530    }
531}