Skip to main content

formualizer_common/
grid_address.rs

1//! Owned, binding-neutral spreadsheet addresses.
2//!
3//! These absolute, 1-based addresses are intended for public reports and
4//! binding boundaries. Their public fields form a wire shape that is validated
5//! by the checked constructors; direct field construction and serde
6//! deserialization are intentionally unvalidated. [`CellAddress`]'s display is
7//! total even for such unchecked values.
8//!
9//! Sheet names carry engine-canonical casing verbatim. Equality and hashing are
10//! case-sensitive. Producers must use registry-canonical casing within a report;
11//! consumers comparing addresses across sources must normalize names themselves.
12//!
13//! These types deliberately differ from engine `CellRef`, which uses a numeric
14//! sheet id and packed 0-based coordinates, and from [`crate::SheetCellRef`] /
15//! [`crate::SheetRangeRef`], which retain locators, relative anchors, and
16//! borrowing lifetimes for parsing and evaluation.
17
18use std::fmt;
19
20use crate::{RangeAddress, SheetAddressError, format_a1_sheet_name};
21
22const MAX_ROW: u32 = 1_048_576;
23const MAX_COLUMN: u32 = 16_384;
24
25fn validate_row(row: u32) -> Result<(), SheetAddressError> {
26    match row {
27        0 => Err(SheetAddressError::ZeroIndex),
28        1..=MAX_ROW => Ok(()),
29        _ => Err(SheetAddressError::RowOutOfBounds),
30    }
31}
32
33fn validate_column(column: u32) -> Result<(), SheetAddressError> {
34    match column {
35        0 => Err(SheetAddressError::ZeroIndex),
36        1..=MAX_COLUMN => Ok(()),
37        _ => Err(SheetAddressError::ColumnOutOfBounds),
38    }
39}
40
41fn unbounded_column_letters(mut column: u32) -> String {
42    let mut reversed = Vec::new();
43    while column > 0 {
44        column -= 1;
45        reversed.push(char::from(b'A' + (column % 26) as u8));
46        column /= 26;
47    }
48    reversed.into_iter().rev().collect()
49}
50
51#[cfg(feature = "serde")]
52use serde::{Deserialize, Serialize};
53
54/// An owned, absolute cell address with 1-based coordinates.
55#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
56#[derive(Clone, Debug, Eq, PartialEq, Hash)]
57pub struct CellAddress {
58    pub sheet: String,
59    pub row: u32,
60    pub column: u32,
61}
62
63impl CellAddress {
64    /// Construct a validated in-grid 1-based address.
65    pub fn new(sheet: impl Into<String>, row: u32, column: u32) -> Result<Self, SheetAddressError> {
66        validate_row(row)?;
67        validate_column(column)?;
68        Ok(Self {
69            sheet: sheet.into(),
70            row,
71            column,
72        })
73    }
74
75    /// Convert this cell to a finite one-cell range.
76    pub fn to_finite(&self) -> RangeAddress {
77        RangeAddress {
78            sheet: self.sheet.clone(),
79            start_row: self.row,
80            start_col: self.column,
81            end_row: self.row,
82            end_col: self.column,
83        }
84    }
85
86    /// Convert a finite one-cell range to a cell address.
87    pub fn from_finite(range: &RangeAddress) -> Option<Self> {
88        (range.start_row == range.end_row && range.start_col == range.end_col).then(|| Self {
89            sheet: range.sheet.clone(),
90            row: range.start_row,
91            column: range.start_col,
92        })
93    }
94}
95
96impl fmt::Display for CellAddress {
97    /// Render a total, sheet-qualified, relative-form A1 label.
98    ///
99    /// This output omits `$` markers and is not intended for reinsertion into
100    /// formula text. Zero coordinates render as `Sheet!#REF!`; positive columns
101    /// beyond the spreadsheet grid use unbounded bijective base-26.
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        let sheet = format_a1_sheet_name(&self.sheet);
104        if self.row == 0 || self.column == 0 {
105            return write!(f, "{sheet}!#REF!");
106        }
107        write!(
108            f,
109            "{sheet}!{}{}",
110            unbounded_column_letters(self.column),
111            self.row
112        )
113    }
114}
115
116impl From<CellAddress> for RangeAddress {
117    fn from(value: CellAddress) -> Self {
118        Self {
119            sheet: value.sheet,
120            start_row: value.row,
121            start_col: value.column,
122            end_row: value.row,
123            end_col: value.column,
124        }
125    }
126}
127
128impl TryFrom<RangeAddress> for CellAddress {
129    type Error = SheetAddressError;
130
131    fn try_from(value: RangeAddress) -> Result<Self, Self::Error> {
132        if value.start_row != value.end_row || value.start_col != value.end_col {
133            return Err(SheetAddressError::NonSingleCellRange);
134        }
135        Self::new(value.sheet, value.start_row, value.start_col)
136    }
137}
138
139/// An owned, absolute range area with optional, inclusive 1-based bounds.
140///
141/// `None` represents an open side on that axis.
142#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
143#[derive(Clone, Debug, Eq, PartialEq, Hash)]
144pub struct RangeArea {
145    pub sheet: String,
146    pub start_row: Option<u32>,
147    pub start_column: Option<u32>,
148    pub end_row: Option<u32>,
149    pub end_column: Option<u32>,
150}
151
152impl RangeArea {
153    /// Construct an area, rejecting out-of-grid coordinates and inverted finite axes.
154    pub fn new(
155        sheet: impl Into<String>,
156        start_row: Option<u32>,
157        start_column: Option<u32>,
158        end_row: Option<u32>,
159        end_column: Option<u32>,
160    ) -> Result<Self, SheetAddressError> {
161        for row in [start_row, end_row].into_iter().flatten() {
162            validate_row(row)?;
163        }
164        for column in [start_column, end_column].into_iter().flatten() {
165            validate_column(column)?;
166        }
167        if start_row
168            .zip(end_row)
169            .is_some_and(|(start, end)| start > end)
170            || start_column
171                .zip(end_column)
172                .is_some_and(|(start, end)| start > end)
173        {
174            return Err(SheetAddressError::RangeOrder);
175        }
176        Ok(Self {
177            sheet: sheet.into(),
178            start_row,
179            start_column,
180            end_row,
181            end_column,
182        })
183    }
184
185    /// Copy a finite range into the open-area representation.
186    pub fn from_finite(range: &RangeAddress) -> Self {
187        Self {
188            sheet: range.sheet.clone(),
189            start_row: Some(range.start_row),
190            start_column: Some(range.start_col),
191            end_row: Some(range.end_row),
192            end_column: Some(range.end_col),
193        }
194    }
195
196    /// Convert to a finite range when all four bounds are present.
197    pub fn to_finite(&self) -> Option<RangeAddress> {
198        Some(RangeAddress {
199            sheet: self.sheet.clone(),
200            start_row: self.start_row?,
201            start_col: self.start_column?,
202            end_row: self.end_row?,
203            end_col: self.end_column?,
204        })
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn cell_display_uses_canonical_sheet_quoting() {
214        assert_eq!(
215            CellAddress::new("Data", 12, 28).unwrap().to_string(),
216            "Data!AB12"
217        );
218        assert_eq!(
219            CellAddress::new("My Sheet", 1, 1).unwrap().to_string(),
220            "'My Sheet'!A1"
221        );
222        assert_eq!(
223            CellAddress::new("O'Brien", 2, 3).unwrap().to_string(),
224            "'O''Brien'!C2"
225        );
226        assert_eq!(
227            CellAddress::new("TRUE", 3, 2).unwrap().to_string(),
228            "'TRUE'!B3"
229        );
230    }
231
232    #[test]
233    fn display_is_total_for_checked_and_unchecked_coordinates() {
234        let display = |row, column| {
235            CellAddress {
236                sheet: "Sheet".to_string(),
237                row,
238                column,
239            }
240            .to_string()
241        };
242        assert_eq!(display(1, 16_384), "Sheet!XFD1");
243        assert_eq!(display(1, 16_385), "Sheet!XFE1");
244        assert_eq!(display(1, u32::MAX), "Sheet!MWLQKWU1");
245        assert_eq!(display(1, 0), "Sheet!#REF!");
246        assert_eq!(display(0, 1), "Sheet!#REF!");
247        assert_eq!(display(0, 0), "Sheet!#REF!");
248    }
249
250    #[test]
251    fn constructors_validate_grid_bounds_and_order() {
252        assert_eq!(
253            CellAddress::new("S", 0, 1).unwrap_err(),
254            SheetAddressError::ZeroIndex
255        );
256        assert_eq!(
257            CellAddress::new("S", MAX_ROW + 1, 1).unwrap_err(),
258            SheetAddressError::RowOutOfBounds
259        );
260        assert_eq!(
261            CellAddress::new("S", 1, MAX_COLUMN + 1).unwrap_err(),
262            SheetAddressError::ColumnOutOfBounds
263        );
264        assert!(CellAddress::new("S", MAX_ROW, MAX_COLUMN).is_ok());
265        assert_eq!(
266            RangeArea::new("S", Some(4), Some(1), Some(3), Some(2)).unwrap_err(),
267            SheetAddressError::RangeOrder
268        );
269        assert_eq!(
270            RangeArea::new("S", None, Some(0), None, Some(2)).unwrap_err(),
271            SheetAddressError::ZeroIndex
272        );
273        assert_eq!(
274            RangeArea::new("S", Some(MAX_ROW + 1), None, None, None).unwrap_err(),
275            SheetAddressError::RowOutOfBounds
276        );
277        assert_eq!(
278            RangeArea::new("S", None, None, None, Some(MAX_COLUMN + 1)).unwrap_err(),
279            SheetAddressError::ColumnOutOfBounds
280        );
281        assert!(RangeArea::new("S", None, Some(2), Some(9), None).is_ok());
282    }
283
284    #[test]
285    fn finite_conversions_round_trip() {
286        let finite = RangeAddress::new("S", 2, 3, 5, 7).unwrap();
287        assert_eq!(RangeArea::from_finite(&finite).to_finite(), Some(finite));
288
289        let cell = CellAddress::new("S", 8, 9).unwrap();
290        assert_eq!(
291            CellAddress::from_finite(&cell.to_finite()),
292            Some(cell.clone())
293        );
294        assert_eq!(
295            CellAddress::try_from(RangeAddress::from(cell.clone())),
296            Ok(cell)
297        );
298        assert_eq!(
299            CellAddress::try_from(RangeAddress::new("S", 1, 1, 2, 1).unwrap()),
300            Err(SheetAddressError::NonSingleCellRange)
301        );
302    }
303}