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/// Errors that can occur while constructing sheet-scoped references.
13#[derive(Clone, Debug, Eq, PartialEq)]
14pub enum SheetAddressError {
15    /// Encountered a 0 or underflowed 1-based index when converting to 0-based.
16    ZeroIndex,
17    /// Start/end coordinates were not ordered (start <= end).
18    RangeOrder,
19    /// Attempted to combine references with different sheet locators.
20    MismatchedSheets,
21    /// Requested operation requires a sheet name but only an id/current was supplied.
22    MissingSheetName,
23    /// Attempted to convert an unbounded range into a bounded representation.
24    UnboundedRange,
25    /// Wrapped [`CoordError`] that originated from `RelativeCoord`.
26    Coord(CoordError),
27    /// Wrapped [`A1ParseError`] originating from A1 parsing.
28    Parse(A1ParseError),
29}
30
31impl fmt::Display for SheetAddressError {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            SheetAddressError::ZeroIndex => {
35                write!(f, "row and column indices must be 1-based (>= 1)")
36            }
37            SheetAddressError::RangeOrder => {
38                write!(
39                    f,
40                    "range must be ordered so the start is above/left of the end"
41                )
42            }
43            SheetAddressError::MismatchedSheets => {
44                write!(f, "range bounds refer to different sheets")
45            }
46            SheetAddressError::MissingSheetName => {
47                write!(f, "sheet name required to materialise textual address")
48            }
49            SheetAddressError::UnboundedRange => {
50                write!(f, "range requires explicit bounds")
51            }
52            SheetAddressError::Coord(err) => err.fmt(f),
53            SheetAddressError::Parse(err) => err.fmt(f),
54        }
55    }
56}
57
58impl Error for SheetAddressError {}
59
60impl From<CoordError> for SheetAddressError {
61    fn from(value: CoordError) -> Self {
62        SheetAddressError::Coord(value)
63    }
64}
65
66impl From<A1ParseError> for SheetAddressError {
67    fn from(value: A1ParseError) -> Self {
68        SheetAddressError::Parse(value)
69    }
70}
71
72/// Sheet locator that can carry either a resolved id, a name, or the current sheet.
73#[derive(Clone, Debug, Default, Eq, PartialEq, Hash)]
74pub enum SheetLocator<'a> {
75    /// Reference is scoped to the sheet containing the formula.
76    #[default]
77    Current,
78    /// Resolved sheet id.
79    Id(SheetId),
80    /// Unresolved sheet name (borrowed or owned).
81    Name(Cow<'a, str>),
82}
83
84impl<'a> SheetLocator<'a> {
85    /// Construct a locator for the current sheet.
86    pub const fn current() -> Self {
87        SheetLocator::Current
88    }
89
90    /// Construct from a resolved sheet id.
91    pub const fn from_id(id: SheetId) -> Self {
92        SheetLocator::Id(id)
93    }
94
95    /// Construct from a sheet name (borrowed or owned).
96    pub fn from_name(name: impl Into<Cow<'a, str>>) -> Self {
97        SheetLocator::Name(name.into())
98    }
99
100    /// Returns the sheet id if present.
101    pub const fn id(&self) -> Option<SheetId> {
102        match self {
103            SheetLocator::Id(id) => Some(*id),
104            SheetLocator::Current | SheetLocator::Name(_) => None,
105        }
106    }
107
108    /// Returns the sheet name if present.
109    pub fn name(&self) -> Option<&str> {
110        match self {
111            SheetLocator::Name(name) => Some(name.as_ref()),
112            SheetLocator::Current | SheetLocator::Id(_) => None,
113        }
114    }
115
116    /// Returns true if this locator refers to the current sheet.
117    pub const fn is_current(&self) -> bool {
118        matches!(self, SheetLocator::Current)
119    }
120
121    /// Borrow the locator, ensuring any owned name is exposed by reference.
122    pub fn as_ref(&self) -> SheetLocator<'_> {
123        match self {
124            SheetLocator::Current => SheetLocator::Current,
125            SheetLocator::Id(id) => SheetLocator::Id(*id),
126            SheetLocator::Name(name) => SheetLocator::Name(Cow::Borrowed(name.as_ref())),
127        }
128    }
129
130    /// Convert the locator into an owned `'static` form.
131    pub fn into_owned(self) -> SheetLocator<'static> {
132        match self {
133            SheetLocator::Current => SheetLocator::Current,
134            SheetLocator::Id(id) => SheetLocator::Id(id),
135            SheetLocator::Name(name) => SheetLocator::Name(Cow::Owned(name.into_owned())),
136        }
137    }
138}
139
140impl<'a> From<SheetId> for SheetLocator<'a> {
141    fn from(value: SheetId) -> Self {
142        SheetLocator::from_id(value)
143    }
144}
145
146impl<'a> From<&'a str> for SheetLocator<'a> {
147    fn from(value: &'a str) -> Self {
148        SheetLocator::from_name(value)
149    }
150}
151
152impl<'a> From<String> for SheetLocator<'a> {
153    fn from(value: String) -> Self {
154        SheetLocator::from_name(value)
155    }
156}
157
158/// Bound on a single axis (row or column).
159#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
160pub struct AxisBound {
161    /// 0-based index.
162    pub index: u32,
163    /// True if anchored with '$'.
164    pub abs: bool,
165}
166
167impl AxisBound {
168    pub const fn new(index: u32, abs: bool) -> Self {
169        AxisBound { index, abs }
170    }
171
172    /// Construct from an Excel 1-based index.
173    pub fn from_excel_1based(index: u32, abs: bool) -> Result<Self, SheetAddressError> {
174        let index0 = index.checked_sub(1).ok_or(SheetAddressError::ZeroIndex)?;
175        Ok(AxisBound::new(index0, abs))
176    }
177
178    /// Convert to Excel 1-based index.
179    pub const fn to_excel_1based(self) -> u32 {
180        self.index + 1
181    }
182}
183
184/// Sheet-scoped cell reference that retains relative/absolute anchors.
185#[derive(Clone, Debug, Eq, PartialEq, Hash)]
186pub struct SheetCellRef<'a> {
187    pub sheet: SheetLocator<'a>,
188    pub coord: RelativeCoord,
189}
190
191impl<'a> SheetCellRef<'a> {
192    pub const fn new(sheet: SheetLocator<'a>, coord: RelativeCoord) -> Self {
193        SheetCellRef { sheet, coord }
194    }
195
196    /// Construct from Excel 1-based coordinates with anchor flags.
197    pub fn from_excel(
198        sheet: SheetLocator<'a>,
199        row: u32,
200        col: u32,
201        row_abs: bool,
202        col_abs: bool,
203    ) -> Result<Self, SheetAddressError> {
204        let row0 = row.checked_sub(1).ok_or(SheetAddressError::ZeroIndex)?;
205        let col0 = col.checked_sub(1).ok_or(SheetAddressError::ZeroIndex)?;
206        let coord = RelativeCoord::try_new(row0, col0, row_abs, col_abs)?;
207        Ok(SheetCellRef::new(sheet, coord))
208    }
209
210    /// Parse an A1-style reference for this sheet.
211    pub fn try_from_a1(
212        sheet: SheetLocator<'a>,
213        reference: &str,
214    ) -> Result<Self, SheetAddressError> {
215        let coord = RelativeCoord::try_from_a1(reference)?;
216        Ok(SheetCellRef::new(sheet, coord))
217    }
218
219    /// Borrowing variant that preserves the lifetime of the sheet locator.
220    pub fn as_ref(&self) -> SheetCellRef<'_> {
221        SheetCellRef {
222            sheet: self.sheet.as_ref(),
223            coord: self.coord,
224        }
225    }
226
227    /// Convert into an owned `'static` reference.
228    pub fn into_owned(self) -> SheetCellRef<'static> {
229        SheetCellRef {
230            sheet: self.sheet.into_owned(),
231            coord: self.coord,
232        }
233    }
234}
235
236/// Sheet-scoped range reference. Bounds are inclusive; None indicates an unbounded side.
237#[derive(Clone, Debug, Eq, PartialEq, Hash)]
238pub struct SheetRangeRef<'a> {
239    pub sheet: SheetLocator<'a>,
240    pub start_row: Option<AxisBound>,
241    pub start_col: Option<AxisBound>,
242    pub end_row: Option<AxisBound>,
243    pub end_col: Option<AxisBound>,
244}
245
246impl<'a> SheetRangeRef<'a> {
247    pub const fn new(
248        sheet: SheetLocator<'a>,
249        start_row: Option<AxisBound>,
250        start_col: Option<AxisBound>,
251        end_row: Option<AxisBound>,
252        end_col: Option<AxisBound>,
253    ) -> Self {
254        SheetRangeRef {
255            sheet,
256            start_row,
257            start_col,
258            end_row,
259            end_col,
260        }
261    }
262
263    /// Construct a range from two cell references, ensuring sheet/order validity.
264    pub fn from_cells(
265        start: SheetCellRef<'a>,
266        end: SheetCellRef<'a>,
267    ) -> Result<Self, SheetAddressError> {
268        if start.sheet != end.sheet {
269            return Err(SheetAddressError::MismatchedSheets);
270        }
271        let sr = AxisBound::new(start.coord.row(), start.coord.row_abs());
272        let sc = AxisBound::new(start.coord.col(), start.coord.col_abs());
273        let er = AxisBound::new(end.coord.row(), end.coord.row_abs());
274        let ec = AxisBound::new(end.coord.col(), end.coord.col_abs());
275        SheetRangeRef::from_parts(start.sheet, Some(sr), Some(sc), Some(er), Some(ec))
276    }
277
278    /// Construct from Excel 1-based bounds and anchor flags.
279    #[allow(clippy::too_many_arguments)]
280    pub fn from_excel_rect(
281        sheet: SheetLocator<'a>,
282        start_row: u32,
283        start_col: u32,
284        end_row: u32,
285        end_col: u32,
286        start_row_abs: bool,
287        start_col_abs: bool,
288        end_row_abs: bool,
289        end_col_abs: bool,
290    ) -> Result<Self, SheetAddressError> {
291        let sr = AxisBound::from_excel_1based(start_row, start_row_abs)?;
292        let sc = AxisBound::from_excel_1based(start_col, start_col_abs)?;
293        let er = AxisBound::from_excel_1based(end_row, end_row_abs)?;
294        let ec = AxisBound::from_excel_1based(end_col, end_col_abs)?;
295        SheetRangeRef::from_parts(sheet, Some(sr), Some(sc), Some(er), Some(ec))
296    }
297
298    /// Helper to build a range from raw bounds, validating ordering when bounded.
299    pub fn from_parts(
300        sheet: SheetLocator<'a>,
301        start_row: Option<AxisBound>,
302        start_col: Option<AxisBound>,
303        end_row: Option<AxisBound>,
304        end_col: Option<AxisBound>,
305    ) -> Result<Self, SheetAddressError> {
306        if let (Some(sr), Some(er)) = (start_row, end_row)
307            && sr.index > er.index
308        {
309            return Err(SheetAddressError::RangeOrder);
310        }
311        if let (Some(sc), Some(ec)) = (start_col, end_col)
312            && sc.index > ec.index
313        {
314            return Err(SheetAddressError::RangeOrder);
315        }
316        Ok(SheetRangeRef::new(
317            sheet, start_row, start_col, end_row, end_col,
318        ))
319    }
320
321    /// Borrowing variant preserving the sheet locator lifetime.
322    pub fn as_ref(&self) -> SheetRangeRef<'_> {
323        SheetRangeRef {
324            sheet: self.sheet.as_ref(),
325            start_row: self.start_row,
326            start_col: self.start_col,
327            end_row: self.end_row,
328            end_col: self.end_col,
329        }
330    }
331
332    /// Convert into an owned `'static` range.
333    pub fn into_owned(self) -> SheetRangeRef<'static> {
334        SheetRangeRef {
335            sheet: self.sheet.into_owned(),
336            start_row: self.start_row,
337            start_col: self.start_col,
338            end_row: self.end_row,
339            end_col: self.end_col,
340        }
341    }
342}
343
344/// Sheet-scoped grid reference (cell or range).
345#[derive(Clone, Debug, Eq, PartialEq, Hash)]
346pub enum SheetRef<'a> {
347    Cell(SheetCellRef<'a>),
348    Range(SheetRangeRef<'a>),
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn sheet_locator_roundtrip() {
357        let loc = SheetLocator::from_id(7);
358        assert_eq!(loc.id(), Some(7));
359        assert_eq!(loc.name(), None);
360        assert_eq!(loc.as_ref(), SheetLocator::Id(7));
361
362        let name = SheetLocator::from_name("Data");
363        assert_eq!(name.id(), None);
364        assert_eq!(name.name(), Some("Data"));
365        let owned = name.clone().into_owned();
366        assert_eq!(owned.name(), Some("Data"));
367        assert_eq!(name, owned.as_ref());
368
369        let current = SheetLocator::current();
370        assert!(current.is_current());
371        assert_eq!(current.id(), None);
372    }
373
374    #[test]
375    fn cell_from_excel_preserves_flags() {
376        let a1 = SheetCellRef::from_excel(SheetLocator::from_name("Sheet1"), 1, 1, false, false)
377            .expect("valid cell");
378        assert_eq!(a1.coord.row(), 0);
379        assert_eq!(a1.coord.col(), 0);
380        assert!(!a1.coord.row_abs());
381        assert!(!a1.coord.col_abs());
382
383        let abs = SheetCellRef::from_excel(SheetLocator::from_name("Sheet1"), 3, 2, true, false)
384            .expect("valid absolute cell");
385        assert_eq!(abs.coord.row(), 2);
386        assert!(abs.coord.row_abs());
387        assert!(!abs.coord.col_abs());
388    }
389
390    #[test]
391    fn cell_from_excel_rejects_zero() {
392        let err = SheetCellRef::from_excel(SheetLocator::from_name("Sheet1"), 0, 1, false, false)
393            .unwrap_err();
394        assert_eq!(err, SheetAddressError::ZeroIndex);
395    }
396
397    #[test]
398    fn range_from_cells_validates_sheet_and_order() {
399        let sheet = SheetLocator::from_name("Sheet1");
400        let start = SheetCellRef::try_from_a1(sheet.as_ref(), "A1").unwrap();
401        let end = SheetCellRef::try_from_a1(sheet.as_ref(), "$B$3").unwrap();
402        let range = SheetRangeRef::from_cells(start.clone(), end.clone()).unwrap();
403        assert_eq!(range.start_row.unwrap().index, 0);
404        assert_eq!(range.end_row.unwrap().index, 2);
405
406        let other_sheet =
407            SheetCellRef::try_from_a1(SheetLocator::from_name("Other"), "C2").unwrap();
408        assert_eq!(
409            SheetRangeRef::from_cells(start, other_sheet).unwrap_err(),
410            SheetAddressError::MismatchedSheets
411        );
412
413        let inverted = SheetRangeRef::from_parts(
414            SheetLocator::from_name("Sheet1"),
415            Some(AxisBound::new(end.coord.row(), end.coord.row_abs())),
416            Some(AxisBound::new(end.coord.col(), end.coord.col_abs())),
417            Some(AxisBound::new(0, false)),
418            Some(AxisBound::new(0, false)),
419        );
420        assert_eq!(inverted.unwrap_err(), SheetAddressError::RangeOrder);
421    }
422}