Skip to main content

spreadsheet_formulas/
cellref.rs

1/// Cell reference parsing and representation.
2/// Supports formats like A1, B12, $A$1, $A1, A$1.
3
4use core::fmt;
5
6/// A cell reference, optionally with absolute column/row.
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
8pub struct CellRef {
9    pub col: usize,      // 0-indexed column (A=0, B=1, ...)
10    pub row: usize,      // 0-indexed row
11    pub abs_col: bool,   // $A style
12    pub abs_row: bool,   // $1 style
13}
14
15impl CellRef {
16    /// Parse a cell reference string like "A1", "$B$12", "C$5", "$D3".
17    pub fn parse(input: &str) -> Option<Self> {
18        let input = input.trim();
19        if input.is_empty() {
20            return None;
21        }
22
23        let mut chars = input.chars().peekable();
24        let mut abs_col = false;
25        let mut abs_row = false;
26
27        // Optional $ for absolute column
28        if chars.peek() == Some(&'$') {
29            abs_col = true;
30            chars.next();
31        }
32
33        // Column letter(s)
34        let mut col_str = String::new();
35        while let Some(&c) = chars.peek() {
36            if c.is_ascii_alphabetic() {
37                col_str.push(chars.next().unwrap().to_ascii_uppercase());
38            } else {
39                break;
40            }
41        }
42        if col_str.is_empty() {
43            return None;
44        }
45
46        // Optional $ for absolute row
47        if chars.peek() == Some(&'$') {
48            abs_row = true;
49            chars.next();
50        }
51
52        // Row number
53        let mut row_str = String::new();
54        while let Some(&c) = chars.peek() {
55            if c.is_ascii_digit() {
56                row_str.push(chars.next().unwrap());
57            } else {
58                break;
59            }
60        }
61
62        if row_str.is_empty() {
63            return None;
64        }
65
66        // Must consume entire input
67        if chars.next().is_some() {
68            return None;
69        }
70
71        let col = col_letters_to_index(&col_str)?;
72        let row: usize = row_str.parse().ok()?;
73        if row == 0 {
74            return None;
75        }
76
77        Some(CellRef {
78            col,
79            row: row - 1, // 0-indexed
80            abs_col,
81            abs_row,
82        })
83    }
84
85    /// Convert to A1-style string.
86    pub fn to_a1(&self) -> String {
87        format!(
88            "{}{}{}{}",
89            if self.abs_col { "$" } else { "" },
90            index_to_col_letters(self.col),
91            if self.abs_row { "$" } else { "" },
92            self.row + 1,
93        )
94    }
95}
96
97impl fmt::Display for CellRef {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        write!(f, "{}", self.to_a1())
100    }
101}
102
103/// Convert column letters (A=0, B=1, ..., Z=25, AA=26, ...) to index.
104pub fn col_letters_to_index(letters: &str) -> Option<usize> {
105    let mut index: usize = 0;
106    for c in letters.chars() {
107        if !c.is_ascii_alphabetic() {
108            return None;
109        }
110        index = index * 26 + (c.to_ascii_uppercase() as usize - 'A' as usize + 1);
111    }
112    Some(index - 1)
113}
114
115/// Convert 0-indexed column to letters.
116pub fn index_to_col_letters(mut index: usize) -> String {
117    let mut result = String::new();
118    loop {
119        result.insert(0, (b'A' + (index % 26) as u8) as char);
120        if index < 26 {
121            break;
122        }
123        index = index / 26 - 1;
124    }
125    result
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn parse_simple() {
134        let c = CellRef::parse("A1").unwrap();
135        assert_eq!(c.col, 0);
136        assert_eq!(c.row, 0);
137        assert!(!c.abs_col);
138        assert!(!c.abs_row);
139    }
140
141    #[test]
142    fn parse_absolute_both() {
143        let c = CellRef::parse("$B$12").unwrap();
144        assert_eq!(c.col, 1);
145        assert_eq!(c.row, 11);
146        assert!(c.abs_col);
147        assert!(c.abs_row);
148    }
149
150    #[test]
151    fn parse_absolute_col() {
152        let c = CellRef::parse("$C5").unwrap();
153        assert_eq!(c.col, 2);
154        assert_eq!(c.row, 4);
155        assert!(c.abs_col);
156        assert!(!c.abs_row);
157    }
158
159    #[test]
160    fn parse_absolute_row() {
161        let c = CellRef::parse("D$7").unwrap();
162        assert_eq!(c.col, 3);
163        assert_eq!(c.row, 6);
164        assert!(!c.abs_col);
165        assert!(c.abs_row);
166    }
167
168    #[test]
169    fn parse_multi_letter_col() {
170        let c = CellRef::parse("AA1").unwrap();
171        assert_eq!(c.col, 26);
172        assert_eq!(c.row, 0);
173    }
174
175    #[test]
176    fn parse_invalid_no_row() {
177        assert!(CellRef::parse("A").is_none());
178    }
179
180    #[test]
181    fn parse_invalid_empty() {
182        assert!(CellRef::parse("").is_none());
183    }
184
185    #[test]
186    fn roundtrip() {
187        for input in &["A1", "$B$12", "C$5", "$D3", "AA100"] {
188            assert_eq!(CellRef::parse(input).unwrap().to_a1(), *input);
189        }
190    }
191
192    #[test]
193    fn col_index_roundtrip() {
194        for i in 0..702 {
195            assert_eq!(col_letters_to_index(&index_to_col_letters(i)), Some(i));
196        }
197    }
198}