Skip to main content

sheetkit_core/utils/
cell_ref.rs

1//! Cell reference conversion utilities.
2//!
3//! Provides functions for converting between A1-style cell references
4//! (e.g. `"A1"`, `"$AB$100"`, `"XFD1048576"`) and 1-based `(col, row)`
5//! numeric coordinates.
6
7use crate::error::{Error, Result};
8use crate::utils::constants::{MAX_COLUMNS, MAX_ROWS};
9
10/// Convert a column name (e.g. `"A"`, `"Z"`, `"AA"`, `"XFD"`) to a 1-based
11/// column number.
12///
13/// # Examples
14///
15/// ```
16/// use sheetkit_core::utils::cell_ref::column_name_to_number;
17///
18/// assert_eq!(column_name_to_number("A").unwrap(), 1);
19/// assert_eq!(column_name_to_number("Z").unwrap(), 26);
20/// assert_eq!(column_name_to_number("AA").unwrap(), 27);
21/// assert_eq!(column_name_to_number("XFD").unwrap(), 16384);
22/// ```
23pub fn column_name_to_number(name: &str) -> Result<u32> {
24    if name.is_empty() {
25        return Err(Error::InvalidCellReference("empty column name".to_string()));
26    }
27
28    let mut result: u32 = 0;
29
30    for c in name.chars() {
31        if !c.is_ascii_alphabetic() {
32            return Err(Error::InvalidCellReference(format!(
33                "non-alphabetic character in column name: '{c}'"
34            )));
35        }
36
37        let digit = (c.to_ascii_uppercase() as u32) - ('A' as u32) + 1;
38
39        result = result
40            .checked_mul(26)
41            .and_then(|r| r.checked_add(digit))
42            .ok_or(Error::InvalidColumnNumber(0))?;
43    }
44
45    if result > MAX_COLUMNS {
46        return Err(Error::InvalidColumnNumber(result));
47    }
48
49    Ok(result)
50}
51
52/// Convert a 1-based column number to its letter name.
53///
54/// # Examples
55///
56/// ```
57/// use sheetkit_core::utils::cell_ref::column_number_to_name;
58///
59/// assert_eq!(column_number_to_name(1).unwrap(), "A");
60/// assert_eq!(column_number_to_name(26).unwrap(), "Z");
61/// assert_eq!(column_number_to_name(27).unwrap(), "AA");
62/// assert_eq!(column_number_to_name(16384).unwrap(), "XFD");
63/// ```
64pub fn column_number_to_name(num: u32) -> Result<String> {
65    if !(1..=MAX_COLUMNS).contains(&num) {
66        return Err(Error::InvalidColumnNumber(num));
67    }
68
69    let mut col = num;
70    let mut result = String::with_capacity(3);
71
72    while col > 0 {
73        col -= 1; // adjust to 0-indexed
74        let remainder = (col % 26) as u8;
75        result.insert(0, (b'A' + remainder) as char);
76        col /= 26;
77    }
78
79    Ok(result)
80}
81
82/// Parse an A1-style cell reference into `(col, row)` coordinates (both
83/// 1-based).
84///
85/// Absolute-reference markers (`$`) are stripped before parsing.
86///
87/// # Examples
88///
89/// ```
90/// use sheetkit_core::utils::cell_ref::cell_name_to_coordinates;
91///
92/// assert_eq!(cell_name_to_coordinates("A1").unwrap(), (1, 1));
93/// assert_eq!(cell_name_to_coordinates("$B$2").unwrap(), (2, 2));
94/// assert_eq!(cell_name_to_coordinates("AA100").unwrap(), (27, 100));
95/// ```
96pub fn cell_name_to_coordinates(cell: &str) -> Result<(u32, u32)> {
97    // Strip absolute-reference markers.
98    let cell = cell.replace('$', "");
99
100    if cell.is_empty() {
101        return Err(Error::InvalidCellReference(
102            "empty cell reference".to_string(),
103        ));
104    }
105
106    // Split into column letters and row digits.
107    let mut col_end = 0;
108    for (i, c) in cell.char_indices() {
109        if c.is_ascii_alphabetic() {
110            col_end = i + c.len_utf8();
111        } else {
112            break;
113        }
114    }
115
116    if col_end == 0 {
117        return Err(Error::InvalidCellReference(format!(
118            "no column letters in '{cell}'"
119        )));
120    }
121
122    let col_str = &cell[..col_end];
123    let row_str = &cell[col_end..];
124
125    if row_str.is_empty() {
126        return Err(Error::InvalidCellReference(format!(
127            "no row number in '{cell}'"
128        )));
129    }
130
131    let col = column_name_to_number(col_str)?;
132
133    let row: u32 = row_str
134        .parse()
135        .map_err(|_| Error::InvalidCellReference(format!("invalid row number in '{cell}'")))?;
136
137    if !(1..=MAX_ROWS).contains(&row) {
138        return Err(Error::InvalidRowNumber(row));
139    }
140
141    Ok((col, row))
142}
143
144/// Convert 1-based `(col, row)` coordinates to an A1-style cell reference.
145///
146/// # Examples
147///
148/// ```
149/// use sheetkit_core::utils::cell_ref::coordinates_to_cell_name;
150///
151/// assert_eq!(coordinates_to_cell_name(1, 1).unwrap(), "A1");
152/// assert_eq!(coordinates_to_cell_name(27, 100).unwrap(), "AA100");
153/// ```
154pub fn coordinates_to_cell_name(col: u32, row: u32) -> Result<String> {
155    if !(1..=MAX_COLUMNS).contains(&col) {
156        return Err(Error::InvalidColumnNumber(col));
157    }
158    if !(1..=MAX_ROWS).contains(&row) {
159        return Err(Error::InvalidRowNumber(row));
160    }
161
162    let col_name = column_number_to_name(col)?;
163    Ok(format!("{col_name}{row}"))
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_column_name_a() {
172        assert_eq!(column_name_to_number("A").unwrap(), 1);
173    }
174
175    #[test]
176    fn test_column_name_z() {
177        assert_eq!(column_name_to_number("Z").unwrap(), 26);
178    }
179
180    #[test]
181    fn test_column_name_aa() {
182        assert_eq!(column_name_to_number("AA").unwrap(), 27);
183    }
184
185    #[test]
186    fn test_column_name_az() {
187        assert_eq!(column_name_to_number("AZ").unwrap(), 52);
188    }
189
190    #[test]
191    fn test_column_name_ba() {
192        assert_eq!(column_name_to_number("BA").unwrap(), 53);
193    }
194
195    #[test]
196    fn test_column_name_xfd() {
197        assert_eq!(column_name_to_number("XFD").unwrap(), 16384);
198    }
199
200    #[test]
201    fn test_column_name_lowercase() {
202        assert_eq!(column_name_to_number("a").unwrap(), 1);
203        assert_eq!(column_name_to_number("xfd").unwrap(), 16384);
204    }
205
206    #[test]
207    fn test_column_name_xfe_out_of_range() {
208        assert!(column_name_to_number("XFE").is_err());
209    }
210
211    #[test]
212    fn test_column_name_empty() {
213        assert!(column_name_to_number("").is_err());
214    }
215
216    #[test]
217    fn test_column_name_with_digit() {
218        assert!(column_name_to_number("A1").is_err());
219    }
220
221    #[test]
222    fn test_column_number_1() {
223        assert_eq!(column_number_to_name(1).unwrap(), "A");
224    }
225
226    #[test]
227    fn test_column_number_26() {
228        assert_eq!(column_number_to_name(26).unwrap(), "Z");
229    }
230
231    #[test]
232    fn test_column_number_27() {
233        assert_eq!(column_number_to_name(27).unwrap(), "AA");
234    }
235
236    #[test]
237    fn test_column_number_52() {
238        assert_eq!(column_number_to_name(52).unwrap(), "AZ");
239    }
240
241    #[test]
242    fn test_column_number_53() {
243        assert_eq!(column_number_to_name(53).unwrap(), "BA");
244    }
245
246    #[test]
247    fn test_column_number_16384() {
248        assert_eq!(column_number_to_name(16384).unwrap(), "XFD");
249    }
250
251    #[test]
252    fn test_column_number_0_err() {
253        assert!(column_number_to_name(0).is_err());
254    }
255
256    #[test]
257    fn test_column_number_16385_err() {
258        assert!(column_number_to_name(16385).is_err());
259    }
260
261    #[test]
262    fn test_column_roundtrip_all() {
263        for n in 1..=MAX_COLUMNS {
264            let name = column_number_to_name(n).unwrap();
265            let back = column_name_to_number(&name).unwrap();
266            assert_eq!(n, back, "roundtrip failed for column {n} (name={name})");
267        }
268    }
269
270    #[test]
271    fn test_cell_a1() {
272        assert_eq!(cell_name_to_coordinates("A1").unwrap(), (1, 1));
273    }
274
275    #[test]
276    fn test_cell_z10() {
277        assert_eq!(cell_name_to_coordinates("Z10").unwrap(), (26, 10));
278    }
279
280    #[test]
281    fn test_cell_aa1() {
282        assert_eq!(cell_name_to_coordinates("AA1").unwrap(), (27, 1));
283    }
284
285    #[test]
286    fn test_cell_absolute_a1() {
287        assert_eq!(cell_name_to_coordinates("$A$1").unwrap(), (1, 1));
288    }
289
290    #[test]
291    fn test_cell_absolute_ab100() {
292        assert_eq!(cell_name_to_coordinates("$AB$100").unwrap(), (28, 100));
293    }
294
295    #[test]
296    fn test_cell_mixed_absolute() {
297        assert_eq!(cell_name_to_coordinates("$A1").unwrap(), (1, 1));
298        assert_eq!(cell_name_to_coordinates("A$1").unwrap(), (1, 1));
299    }
300
301    #[test]
302    fn test_cell_max() {
303        assert_eq!(
304            cell_name_to_coordinates("XFD1048576").unwrap(),
305            (16384, 1_048_576)
306        );
307    }
308
309    #[test]
310    fn test_cell_empty_err() {
311        assert!(cell_name_to_coordinates("").is_err());
312    }
313
314    #[test]
315    fn test_cell_only_letters_err() {
316        assert!(cell_name_to_coordinates("ABC").is_err());
317    }
318
319    #[test]
320    fn test_cell_only_digits_err() {
321        assert!(cell_name_to_coordinates("123").is_err());
322    }
323
324    #[test]
325    fn test_cell_row_zero_err() {
326        assert!(cell_name_to_coordinates("A0").is_err());
327    }
328
329    #[test]
330    fn test_cell_row_too_large_err() {
331        assert!(cell_name_to_coordinates("A1048577").is_err());
332    }
333
334    #[test]
335    fn test_cell_col_too_large_err() {
336        assert!(cell_name_to_coordinates("XFE1").is_err());
337    }
338
339    #[test]
340    fn test_coords_1_1() {
341        assert_eq!(coordinates_to_cell_name(1, 1).unwrap(), "A1");
342    }
343
344    #[test]
345    fn test_coords_27_100() {
346        assert_eq!(coordinates_to_cell_name(27, 100).unwrap(), "AA100");
347    }
348
349    #[test]
350    fn test_coords_max() {
351        assert_eq!(
352            coordinates_to_cell_name(16384, 1_048_576).unwrap(),
353            "XFD1048576"
354        );
355    }
356
357    #[test]
358    fn test_coords_col_0_err() {
359        assert!(coordinates_to_cell_name(0, 1).is_err());
360    }
361
362    #[test]
363    fn test_coords_row_0_err() {
364        assert!(coordinates_to_cell_name(1, 0).is_err());
365    }
366
367    #[test]
368    fn test_coords_col_too_large_err() {
369        assert!(coordinates_to_cell_name(16385, 1).is_err());
370    }
371
372    #[test]
373    fn test_coords_row_too_large_err() {
374        assert!(coordinates_to_cell_name(1, 1_048_577).is_err());
375    }
376
377    #[test]
378    fn test_cell_roundtrip() {
379        let cases = vec![
380            (1, 1, "A1"),
381            (26, 1, "Z1"),
382            (27, 1, "AA1"),
383            (52, 10, "AZ10"),
384            (16384, 1_048_576, "XFD1048576"),
385        ];
386
387        for (col, row, expected_name) in cases {
388            let name = coordinates_to_cell_name(col, row).unwrap();
389            assert_eq!(name, expected_name);
390
391            let (c, r) = cell_name_to_coordinates(&name).unwrap();
392            assert_eq!((c, r), (col, row));
393        }
394    }
395}