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