Skip to main content

formualizer_common/
coord.rs

1//! Compact coordinate representations shared across the engine and bindings.
2//!
3//! `Coord` encodes an absolute cell position (row, column) in 64 bits with the same
4//! limits as Excel: 1,048,576 rows × 16,384 columns. `RelativeCoord` extends that
5//! layout with anchor flags that preserve the `$A$1` semantics needed while parsing
6//! and adjusting formulas.
7
8use core::{fmt, str::FromStr};
9
10const ROW_BITS: u32 = 20;
11const COL_BITS: u32 = 14;
12const ROW_MAX: u32 = (1 << ROW_BITS) - 1;
13const COL_MAX: u32 = (1 << COL_BITS) - 1;
14const ROW_MAX_1BASED: u32 = ROW_MAX + 1;
15const COL_MAX_1BASED: u32 = COL_MAX + 1;
16
17const ROW_SHIFT: u32 = 24;
18const COL_SHIFT: u32 = 10;
19
20const ROW_MASK: u64 = (ROW_MAX as u64) << ROW_SHIFT;
21const COL_MASK: u64 = (COL_MAX as u64) << COL_SHIFT;
22const RESERVED_HIGH_MASK: u64 = 0xFFFFF00000000000;
23const RESERVED_LOW_MASK: u64 = 0x3FF;
24
25const ROW_ABS_BIT: u64 = 1;
26const COL_ABS_BIT: u64 = 1 << 1;
27const RELATIVE_RESERVED_LOW_MASK: u64 = RESERVED_LOW_MASK & !(ROW_ABS_BIT | COL_ABS_BIT);
28
29/// Errors returned when constructing coordinates from unchecked inputs.
30#[non_exhaustive]
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum CoordError {
33    RowOverflow(i64),
34    ColOverflow(i64),
35    NegativeRow(i64),
36    NegativeCol(i64),
37    ReservedBitsSet(u64),
38}
39
40impl fmt::Display for CoordError {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            CoordError::RowOverflow(row) => write!(f, "row {row} exceeds {MAX}", MAX = ROW_MAX),
44            CoordError::ColOverflow(col) => write!(f, "col {col} exceeds {MAX}", MAX = COL_MAX),
45            CoordError::NegativeRow(row) => write!(f, "row {row} is negative"),
46            CoordError::NegativeCol(col) => write!(f, "col {col} is negative"),
47            CoordError::ReservedBitsSet(bits) => {
48                write!(f, "coordinate contains reserved bits: {bits:#x}")
49            }
50        }
51    }
52}
53
54/// Errors that can occur while parsing A1-style references.
55#[non_exhaustive]
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub enum A1ParseError {
58    Empty,
59    MissingColumn,
60    MissingRow,
61    InvalidColumnChar(char),
62    InvalidRowChar(char),
63    TrailingCharacters(String),
64    ColumnOutOfRange(u32),
65    RowOutOfRange(u32),
66}
67
68impl fmt::Display for A1ParseError {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            A1ParseError::Empty => write!(f, "reference is empty"),
72            A1ParseError::MissingColumn => write!(f, "reference must start with a column"),
73            A1ParseError::MissingRow => write!(f, "reference must include a row number"),
74            A1ParseError::InvalidColumnChar(ch) => {
75                write!(f, "invalid column character `{ch}`; expected A-Z")
76            }
77            A1ParseError::InvalidRowChar(ch) => {
78                write!(f, "invalid row character `{ch}`; expected 0-9")
79            }
80            A1ParseError::TrailingCharacters(rest) => {
81                write!(f, "unexpected trailing characters `{rest}`")
82            }
83            A1ParseError::ColumnOutOfRange(col) => {
84                write!(
85                    f,
86                    "column {col} is outside Excel's supported range (1..={})",
87                    COL_MAX_1BASED
88                )
89            }
90            A1ParseError::RowOutOfRange(row) => {
91                write!(
92                    f,
93                    "row {row} is outside Excel's supported range (1..={})",
94                    ROW_MAX_1BASED
95                )
96            }
97        }
98    }
99}
100
101impl From<CoordError> for A1ParseError {
102    fn from(value: CoordError) -> Self {
103        match value {
104            CoordError::RowOverflow(row) => A1ParseError::RowOutOfRange(row as u32 + 1),
105            CoordError::ColOverflow(col) => A1ParseError::ColumnOutOfRange(col as u32 + 1),
106            CoordError::NegativeRow(_) => A1ParseError::RowOutOfRange(0),
107            CoordError::NegativeCol(_) => A1ParseError::ColumnOutOfRange(0),
108            CoordError::ReservedBitsSet(bits) => {
109                A1ParseError::TrailingCharacters(format!("reserved bits {bits:#x}"))
110            }
111        }
112    }
113}
114
115/// Absolute grid coordinate (row, column) with Excel-compatible bounds.
116#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
117pub struct Coord(u64);
118
119impl Coord {
120    pub const INVALID: Self = Self(u64::MAX);
121
122    const RESERVED_MASK: u64 = RESERVED_HIGH_MASK | RESERVED_LOW_MASK;
123
124    /// Construct a 0-based coordinate.
125    ///
126    /// # Panics
127    ///
128    /// Panics if `row` exceeds 1,048,575 (20 bits) or `col` exceeds 16,383
129    /// (14 bits). Use [`Coord::try_new`] for values that come from a file, a
130    /// formula or an API caller.
131    pub fn new(row: u32, col: u32) -> Self {
132        assert!(row <= ROW_MAX, "Row {row} exceeds 20 bits");
133        assert!(col <= COL_MAX, "Col {col} exceeds 14 bits");
134        Self(((row as u64) << ROW_SHIFT) | ((col as u64) << COL_SHIFT))
135    }
136
137    /// Construct from Excel 1-based coordinates (0 saturates to 1).
138    ///
139    /// # Panics
140    ///
141    /// Panics if `row` exceeds 1,048,576 or `col` exceeds 16,384, as
142    /// [`Coord::new`] does.
143    #[inline(always)]
144    pub fn from_excel(row: u32, col: u32) -> Self {
145        let row0 = row.saturating_sub(1);
146        let col0 = col.saturating_sub(1);
147        Self::new(row0, col0)
148    }
149
150    /// Fallible constructor that reports overflow rather than panicking.
151    pub fn try_new(row: u32, col: u32) -> Result<Self, CoordError> {
152        if row > ROW_MAX {
153            return Err(CoordError::RowOverflow(row as i64));
154        }
155        if col > COL_MAX {
156            return Err(CoordError::ColOverflow(col as i64));
157        }
158        Ok(Self::new(row, col))
159    }
160
161    /// Reconstruct from a raw packed value, ensuring reserved bits stay zero.
162    pub fn from_raw(raw: u64) -> Result<Self, CoordError> {
163        if raw == u64::MAX {
164            return Ok(Self::INVALID);
165        }
166        if raw & Self::RESERVED_MASK != 0 {
167            return Err(CoordError::ReservedBitsSet(raw & Self::RESERVED_MASK));
168        }
169        Ok(Self(raw))
170    }
171
172    #[inline(always)]
173    pub fn row(self) -> u32 {
174        ((self.0 & ROW_MASK) >> ROW_SHIFT) as u32
175    }
176
177    #[inline(always)]
178    pub fn col(self) -> u32 {
179        ((self.0 & COL_MASK) >> COL_SHIFT) as u32
180    }
181
182    #[inline(always)]
183    pub fn as_u64(self) -> u64 {
184        self.0
185    }
186
187    #[inline(always)]
188    pub fn is_valid(self) -> bool {
189        self.0 != u64::MAX
190    }
191
192    /// Clear any reserved bits in-place. Useful before serialisation.
193    #[inline(always)]
194    pub fn normalize(self) -> Self {
195        Self(self.0 & !Self::RESERVED_MASK)
196    }
197
198    /// Convert to a relative coordinate with absolute anchors on both axes.
199    #[inline(always)]
200    pub fn into_relative(self) -> RelativeCoord {
201        RelativeCoord::new(self.row(), self.col(), true, true)
202    }
203
204    /// Parse an A1-style reference (e.g. `"A1"`, `"$B$12"`) into a [`Coord`].
205    pub fn try_from_a1(input: &str) -> Result<Self, A1ParseError> {
206        let (row, col, _, _) = parse_a1_components(input)?;
207        let row0 = row.checked_sub(1).ok_or(A1ParseError::RowOutOfRange(0))?;
208        let col0 = col
209            .checked_sub(1)
210            .ok_or(A1ParseError::ColumnOutOfRange(0))?;
211        Coord::try_new(row0, col0).map_err(A1ParseError::from)
212    }
213}
214
215impl From<Coord> for (u32, u32) {
216    fn from(coord: Coord) -> Self {
217        (coord.row(), coord.col())
218    }
219}
220
221impl TryFrom<(u32, u32)> for Coord {
222    type Error = CoordError;
223
224    fn try_from(value: (u32, u32)) -> Result<Self, Self::Error> {
225        Self::try_new(value.0, value.1)
226    }
227}
228
229impl TryFrom<(i64, i64)> for Coord {
230    type Error = CoordError;
231
232    fn try_from(value: (i64, i64)) -> Result<Self, Self::Error> {
233        let (row, col) = value;
234        if row < 0 {
235            return Err(CoordError::NegativeRow(row));
236        }
237        if col < 0 {
238            return Err(CoordError::NegativeCol(col));
239        }
240        let row = row as u32;
241        let col = col as u32;
242        Self::try_new(row, col)
243    }
244}
245
246impl From<RelativeCoord> for Coord {
247    fn from(value: RelativeCoord) -> Self {
248        Self::new(value.row(), value.col())
249    }
250}
251
252/// Relative coordinate (row, column) with anchor flags.
253///
254/// Anchor bits mirror Excel semantics:
255/// * `row_abs = true` keeps the row fixed during rebasing.
256/// * `col_abs = true` keeps the column fixed during rebasing.
257#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord)]
258pub struct RelativeCoord(u64);
259
260impl RelativeCoord {
261    const RESERVED_MASK: u64 = RESERVED_HIGH_MASK | RELATIVE_RESERVED_LOW_MASK;
262
263    /// Construct a 0-based coordinate with absolute-row/column flags.
264    ///
265    /// # Panics
266    ///
267    /// Panics if `row` exceeds 1,048,575 (20 bits) or `col` exceeds 16,383
268    /// (14 bits). Use [`RelativeCoord::try_new`] for values that come from a
269    /// file, a formula or an API caller.
270    pub fn new(row: u32, col: u32, row_abs: bool, col_abs: bool) -> Self {
271        assert!(row <= ROW_MAX, "Row {row} exceeds 20 bits");
272        assert!(col <= COL_MAX, "Col {col} exceeds 14 bits");
273        let mut raw = ((row as u64) << ROW_SHIFT) | ((col as u64) << COL_SHIFT);
274        if row_abs {
275            raw |= ROW_ABS_BIT;
276        }
277        if col_abs {
278            raw |= COL_ABS_BIT;
279        }
280        Self(raw)
281    }
282
283    /// Fallible constructor that reports overflow rather than panicking.
284    pub fn try_new(row: u32, col: u32, row_abs: bool, col_abs: bool) -> Result<Self, CoordError> {
285        if row > ROW_MAX {
286            return Err(CoordError::RowOverflow(row as i64));
287        }
288        if col > COL_MAX {
289            return Err(CoordError::ColOverflow(col as i64));
290        }
291        Ok(Self::new(row, col, row_abs, col_abs))
292    }
293
294    pub fn from_raw(raw: u64) -> Result<Self, CoordError> {
295        if raw & Self::RESERVED_MASK != 0 {
296            return Err(CoordError::ReservedBitsSet(raw & Self::RESERVED_MASK));
297        }
298        Ok(Self(raw))
299    }
300
301    #[inline(always)]
302    pub fn row(self) -> u32 {
303        ((self.0 & ROW_MASK) >> ROW_SHIFT) as u32
304    }
305
306    #[inline(always)]
307    pub fn col(self) -> u32 {
308        ((self.0 & COL_MASK) >> COL_SHIFT) as u32
309    }
310
311    #[inline(always)]
312    pub fn row_abs(self) -> bool {
313        self.0 & ROW_ABS_BIT != 0
314    }
315
316    #[inline(always)]
317    pub fn col_abs(self) -> bool {
318        self.0 & COL_ABS_BIT != 0
319    }
320
321    #[inline(always)]
322    pub fn with_row_abs(mut self, abs: bool) -> Self {
323        if abs {
324            self.0 |= ROW_ABS_BIT;
325        } else {
326            self.0 &= !ROW_ABS_BIT;
327        }
328        self
329    }
330
331    #[inline(always)]
332    pub fn with_col_abs(mut self, abs: bool) -> Self {
333        if abs {
334            self.0 |= COL_ABS_BIT;
335        } else {
336            self.0 &= !COL_ABS_BIT;
337        }
338        self
339    }
340
341    /// Offset by signed deltas, ignoring anchor flags (matching legacy behaviour).
342    #[inline(always)]
343    pub fn offset(self, drow: i32, dcol: i32) -> Self {
344        let row = ((self.row() as i32) + drow) as u32;
345        let col = ((self.col() as i32) + dcol) as u32;
346        Self::new(row, col, self.row_abs(), self.col_abs())
347    }
348
349    /// Rebase as if the enclosing formula moved from `origin` to `target`.
350    #[inline(always)]
351    pub fn rebase(self, origin: RelativeCoord, target: RelativeCoord) -> Self {
352        let drow = target.row() as i32 - origin.row() as i32;
353        let dcol = target.col() as i32 - origin.col() as i32;
354        let new_row = if self.row_abs() {
355            self.row()
356        } else {
357            ((self.row() as i32) + drow) as u32
358        };
359        let new_col = if self.col_abs() {
360            self.col()
361        } else {
362            ((self.col() as i32) + dcol) as u32
363        };
364        Self::new(new_row, new_col, self.row_abs(), self.col_abs())
365    }
366
367    #[inline(always)]
368    pub fn into_absolute(self) -> Coord {
369        Coord::new(self.row(), self.col())
370    }
371
372    #[inline(always)]
373    pub fn as_u64(self) -> u64 {
374        self.0
375    }
376
377    pub fn col_to_letters(col: u32) -> String {
378        column_to_letters(col)
379    }
380
381    pub fn letters_to_col(s: &str) -> Option<u32> {
382        letters_to_column_index(s)
383    }
384
385    /// Parse an A1-style reference into a [`RelativeCoord`].
386    pub fn try_from_a1(input: &str) -> Result<Self, A1ParseError> {
387        let (row, col, row_abs, col_abs) = parse_a1_components(input)?;
388        let row0 = row.checked_sub(1).ok_or(A1ParseError::RowOutOfRange(0))?;
389        let col0 = col
390            .checked_sub(1)
391            .ok_or(A1ParseError::ColumnOutOfRange(0))?;
392        RelativeCoord::try_new(row0, col0, row_abs, col_abs).map_err(A1ParseError::from)
393    }
394}
395
396impl fmt::Display for RelativeCoord {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        if self.col_abs() {
399            write!(f, "$")?;
400        }
401        write!(f, "{}", column_to_letters(self.col()))?;
402        if self.row_abs() {
403            write!(f, "$")?;
404        }
405        write!(f, "{}", self.row() + 1)
406    }
407}
408
409impl From<Coord> for RelativeCoord {
410    fn from(coord: Coord) -> Self {
411        Self::new(coord.row(), coord.col(), true, true)
412    }
413}
414
415impl TryFrom<(u32, u32, bool, bool)> for RelativeCoord {
416    type Error = CoordError;
417
418    fn try_from(value: (u32, u32, bool, bool)) -> Result<Self, Self::Error> {
419        Self::try_new(value.0, value.1, value.2, value.3)
420    }
421}
422
423fn column_to_letters(mut col: u32) -> String {
424    let mut buf = Vec::new();
425    loop {
426        let rem = (col % 26) as u8;
427        buf.push(b'A' + rem);
428        col /= 26;
429        if col == 0 {
430            break;
431        }
432        col -= 1;
433    }
434    buf.reverse();
435    String::from_utf8(buf).expect("only ASCII A-Z")
436}
437
438fn letters_to_column_index(s: &str) -> Option<u32> {
439    if s.is_empty() {
440        return None;
441    }
442    let mut col: u32 = 0;
443    for (idx, byte) in s.bytes().enumerate() {
444        let upper = byte.to_ascii_uppercase();
445        if !upper.is_ascii_uppercase() {
446            return None;
447        }
448        let val = (upper - b'A') as u32;
449        col = col.checked_mul(26)?;
450        col = col.checked_add(val)?;
451        if idx != s.len() - 1 {
452            col = col.checked_add(1)?;
453        }
454    }
455    Some(col)
456}
457
458/// Convert a 1-based column index into its Excel letters (1 → "A").
459pub fn col_letters_from_1based(col: u32) -> Result<String, A1ParseError> {
460    if col == 0 || col > COL_MAX_1BASED {
461        return Err(A1ParseError::ColumnOutOfRange(col));
462    }
463    Ok(column_to_letters(col - 1))
464}
465
466/// Convert Excel column letters into a 1-based column index.
467pub fn col_index_from_letters_1based(col: &str) -> Result<u32, A1ParseError> {
468    if col.is_empty() {
469        return Err(A1ParseError::MissingColumn);
470    }
471    for ch in col.chars() {
472        if !ch.is_ascii_alphabetic() {
473            return Err(A1ParseError::InvalidColumnChar(ch));
474        }
475    }
476    match letters_to_column_index(col) {
477        Some(zero_based) if zero_based <= COL_MAX => Ok(zero_based + 1),
478        Some(zero_based) => Err(A1ParseError::ColumnOutOfRange(zero_based + 1)),
479        None => Err(A1ParseError::ColumnOutOfRange(COL_MAX_1BASED + 1)),
480    }
481}
482
483fn parse_a1_components(input: &str) -> Result<(u32, u32, bool, bool), A1ParseError> {
484    if input.is_empty() {
485        return Err(A1ParseError::Empty);
486    }
487
488    let bytes = input.as_bytes();
489    let len = bytes.len();
490    let mut idx = 0usize;
491
492    let mut col_abs = false;
493    let mut row_abs = false;
494
495    if bytes[idx] == b'$' {
496        col_abs = true;
497        idx += 1;
498        if idx >= len {
499            return Err(A1ParseError::MissingColumn);
500        }
501    }
502
503    let col_start = idx;
504    while idx < len && bytes[idx].is_ascii_alphabetic() {
505        idx += 1;
506    }
507
508    if idx == col_start {
509        return Err(A1ParseError::MissingColumn);
510    }
511
512    let col_letters = &input[col_start..idx];
513
514    if idx < len && bytes[idx] == b'$' {
515        row_abs = true;
516        idx += 1;
517    }
518
519    if idx >= len {
520        return Err(A1ParseError::MissingRow);
521    }
522
523    let row_start = idx;
524    while idx < len && bytes[idx].is_ascii_digit() {
525        idx += 1;
526    }
527
528    if row_start == idx {
529        let invalid = input[row_start..].chars().next().unwrap_or('\0');
530        if invalid == '\0' {
531            return Err(A1ParseError::MissingRow);
532        }
533        return Err(A1ParseError::InvalidRowChar(invalid));
534    }
535
536    if idx != len {
537        return Err(A1ParseError::TrailingCharacters(input[idx..].to_string()));
538    }
539
540    let col = col_index_from_letters_1based(col_letters)?;
541    let row_str = &input[row_start..idx];
542    if !row_str.bytes().all(|b| b.is_ascii_digit()) {
543        let invalid = row_str.chars().find(|c| !c.is_ascii_digit()).unwrap();
544        return Err(A1ParseError::InvalidRowChar(invalid));
545    }
546    let row: u32 = row_str
547        .parse()
548        .map_err(|_| A1ParseError::RowOutOfRange(ROW_MAX_1BASED + 1))?;
549
550    if row == 0 || row > ROW_MAX_1BASED {
551        return Err(A1ParseError::RowOutOfRange(row));
552    }
553
554    Ok((row, col, row_abs, col_abs))
555}
556
557/// Parse an A1-style reference and return 1-based coordinates plus absolute flags.
558pub fn parse_a1_1based(input: &str) -> Result<(u32, u32, bool, bool), A1ParseError> {
559    parse_a1_components(input)
560}
561
562impl TryFrom<&str> for Coord {
563    type Error = A1ParseError;
564
565    fn try_from(value: &str) -> Result<Self, Self::Error> {
566        Coord::try_from_a1(value)
567    }
568}
569
570impl FromStr for Coord {
571    type Err = A1ParseError;
572
573    fn from_str(s: &str) -> Result<Self, Self::Err> {
574        Coord::try_from_a1(s)
575    }
576}
577
578impl FromStr for RelativeCoord {
579    type Err = A1ParseError;
580
581    fn from_str(s: &str) -> Result<Self, Self::Err> {
582        RelativeCoord::try_from_a1(s)
583    }
584}
585
586impl TryFrom<&str> for RelativeCoord {
587    type Error = A1ParseError;
588
589    fn try_from(value: &str) -> Result<Self, Self::Error> {
590        RelativeCoord::try_from_a1(value)
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn absolute_roundtrip() {
600        let coord = Coord::new(1_048_575, 16_383);
601        assert_eq!(coord.row(), 1_048_575);
602        assert_eq!(coord.col(), 16_383);
603        let expected = (0xFFFFF_u64 << ROW_SHIFT) | (0x3FFF_u64 << COL_SHIFT);
604        assert_eq!(coord.as_u64(), expected);
605    }
606
607    #[test]
608    fn absolute_invalid_const() {
609        let invalid = Coord::INVALID;
610        assert!(!invalid.is_valid());
611        assert_eq!(invalid.as_u64(), u64::MAX);
612    }
613
614    #[test]
615    fn absolute_try_new() {
616        assert!(Coord::try_new(ROW_MAX, COL_MAX).is_ok());
617        assert_eq!(
618            Coord::try_new(ROW_MAX + 1, 0),
619            Err(CoordError::RowOverflow((ROW_MAX + 1) as i64))
620        );
621        assert_eq!(
622            Coord::try_new(0, COL_MAX + 1),
623            Err(CoordError::ColOverflow((COL_MAX + 1) as i64))
624        );
625    }
626
627    #[test]
628    fn relative_flags() {
629        let coord = RelativeCoord::new(0, 0, true, false);
630        assert!(coord.row_abs());
631        assert!(!coord.col_abs());
632        let toggled = coord.with_col_abs(true);
633        assert!(toggled.col_abs());
634    }
635
636    #[test]
637    fn relative_display() {
638        let coord = RelativeCoord::new(5, 27, true, false);
639        assert_eq!(coord.to_string(), "AB$6");
640        let coord = RelativeCoord::new(0, 0, false, false);
641        assert_eq!(coord.to_string(), "A1");
642    }
643
644    #[test]
645    fn rebase_behaviour() {
646        let origin = RelativeCoord::new(0, 0, false, false);
647        let target = RelativeCoord::new(1, 1, false, false);
648        let formula = RelativeCoord::new(2, 0, false, true);
649        let rebased = formula.rebase(origin, target);
650        assert_eq!(rebased, RelativeCoord::new(3, 0, false, true));
651    }
652
653    #[test]
654    fn column_letter_roundtrip() {
655        let letters = RelativeCoord::col_to_letters(27);
656        assert_eq!(letters, "AB");
657        let idx = RelativeCoord::letters_to_col(&letters).unwrap();
658        assert_eq!(idx, 27);
659        assert!(RelativeCoord::letters_to_col("a1").is_none());
660    }
661
662    #[test]
663    fn col_letters_from_1based_roundtrip() {
664        assert_eq!(col_letters_from_1based(1).unwrap(), "A");
665        assert_eq!(col_letters_from_1based(26).unwrap(), "Z");
666        assert_eq!(col_letters_from_1based(27).unwrap(), "AA");
667        assert_eq!(col_letters_from_1based(52).unwrap(), "AZ");
668        assert_eq!(col_letters_from_1based(53).unwrap(), "BA");
669    }
670
671    #[test]
672    fn col_index_from_letters_handles_lowercase() {
673        assert_eq!(col_index_from_letters_1based("a").unwrap(), 1);
674        assert_eq!(col_index_from_letters_1based("zz").unwrap(), 702);
675        assert_eq!(
676            col_index_from_letters_1based("XFD").unwrap(),
677            COL_MAX_1BASED
678        );
679        assert!(col_index_from_letters_1based("xfda").is_err());
680        assert!(col_index_from_letters_1based("!").is_err());
681    }
682
683    #[test]
684    fn parse_a1_components_basic() {
685        let (row, col, row_abs, col_abs) = parse_a1_1based("A1").unwrap();
686        assert_eq!((row, col, row_abs, col_abs), (1, 1, false, false));
687
688        let (row, col, row_abs, col_abs) = parse_a1_1based("$C$10").unwrap();
689        assert_eq!((row, col, row_abs, col_abs), (10, 3, true, true));
690
691        let (row, col, row_abs, col_abs) = parse_a1_1based("d$5").unwrap();
692        assert_eq!((row, col, row_abs, col_abs), (5, 4, true, false));
693    }
694
695    #[test]
696    fn parse_a1_components_errors() {
697        assert!(matches!(parse_a1_1based(""), Err(A1ParseError::Empty)));
698        assert!(matches!(
699            parse_a1_1based("$"),
700            Err(A1ParseError::MissingColumn)
701        ));
702        assert!(matches!(
703            parse_a1_1based("A"),
704            Err(A1ParseError::MissingRow)
705        ));
706        assert!(matches!(
707            parse_a1_1based("A0"),
708            Err(A1ParseError::RowOutOfRange(0))
709        ));
710        assert!(matches!(
711            parse_a1_1based("XFE1"),
712            Err(A1ParseError::ColumnOutOfRange(_))
713        ));
714    }
715
716    #[test]
717    fn coord_try_from_a1_matches_relative() {
718        let coord = Coord::try_from_a1("$B$2").unwrap();
719        assert_eq!((coord.row(), coord.col()), (1, 1));
720
721        let rel = RelativeCoord::try_from_a1("$B$2").unwrap();
722        assert!(rel.row_abs() && rel.col_abs());
723        assert_eq!((rel.row(), rel.col()), (1, 1));
724    }
725}