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