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