Skip to main content

spreadsheet_column/
lib.rs

1//! # spreadsheet-column — convert between column numbers and letters
2//!
3//! Convert between spreadsheet column numbers and their letter names — `1` ↔ `A`, `26` ↔
4//! `Z`, `27` ↔ `AA` (the bijective base-26 used by Excel, Google Sheets, …). A faithful Rust
5//! port of the [`spreadsheet-column`](https://www.npmjs.com/package/spreadsheet-column) npm
6//! package.
7//!
8//! ```
9//! use spreadsheet_column::{from_int, from_str};
10//!
11//! assert_eq!(from_int(27).unwrap(), "AA");
12//! assert_eq!(from_str("AA").unwrap(), 27);
13//! ```
14//!
15//! By default columns are one-based (`1` → `A`). Use [`SpreadsheetColumn::zero_based`] for
16//! zero-based columns (`0` → `A`):
17//!
18//! ```
19//! use spreadsheet_column::SpreadsheetColumn;
20//!
21//! let sc = SpreadsheetColumn::zero_based();
22//! assert_eq!(sc.from_int(0).unwrap(), "A");
23//! assert_eq!(sc.from_str("AA").unwrap(), 26);
24//! ```
25//!
26//! **Zero dependencies** and `#![no_std]`.
27
28#![no_std]
29#![forbid(unsafe_code)]
30#![doc(html_root_url = "https://docs.rs/spreadsheet-column/0.1.0")]
31
32extern crate alloc;
33
34use alloc::string::String;
35use core::fmt;
36
37// Compile-test the README's examples as part of `cargo test`.
38#[cfg(doctest)]
39#[doc = include_str!("../README.md")]
40struct ReadmeDoctests;
41
42/// An error from a column conversion.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum Error {
45    /// The number was negative.
46    Negative,
47    /// The number was zero in one-based mode (where the smallest column is `1`).
48    ZeroNotAllowed,
49    /// The string was empty or contained a non-letter character.
50    InvalidString,
51}
52
53impl fmt::Display for Error {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        let message = match self {
56            Error::Negative => "negative numeric coordinate is not allowed",
57            Error::ZeroNotAllowed => "numeric coordinate cannot be zero when starting from one",
58            Error::InvalidString => "column must be a non-empty string of ASCII letters",
59        };
60        f.write_str(message)
61    }
62}
63
64impl core::error::Error for Error {}
65
66/// A spreadsheet column converter, optionally zero-based.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub struct SpreadsheetColumn {
69    zero: bool,
70}
71
72impl SpreadsheetColumn {
73    /// A one-based converter (`1` → `A`).
74    #[must_use]
75    pub fn new() -> Self {
76        Self { zero: false }
77    }
78
79    /// A zero-based converter (`0` → `A`).
80    #[must_use]
81    pub fn zero_based() -> Self {
82        Self { zero: true }
83    }
84
85    /// Convert a column number to its letter name.
86    ///
87    /// # Errors
88    /// Returns [`Error::Negative`] for negative input, or [`Error::ZeroNotAllowed`] for `0`
89    /// in one-based mode.
90    #[allow(clippy::cast_sign_loss)] // `(value - 1) % 26` is always in `0..26`.
91    pub fn from_int(&self, n: i64) -> Result<String, Error> {
92        if n < 0 {
93            return Err(Error::Negative);
94        }
95        if !self.zero && n == 0 {
96            return Err(Error::ZeroNotAllowed);
97        }
98
99        // Reduce to a one-based index, then emit the bijective base-26 representation.
100        let mut value = if self.zero { n + 1 } else { n };
101        let mut letters = String::new();
102        while value > 0 {
103            let remainder = (value - 1) % 26;
104            letters.insert(0, char::from(b'A' + remainder as u8));
105            value = (value - 1) / 26;
106        }
107        Ok(letters)
108    }
109
110    /// Convert a column letter name to its number.
111    ///
112    /// # Errors
113    /// Returns [`Error::InvalidString`] if `column` is empty or contains a non-letter.
114    pub fn from_str(&self, column: &str) -> Result<i64, Error> {
115        if column.is_empty() || !column.bytes().all(|b| b.is_ascii_alphabetic()) {
116            return Err(Error::InvalidString);
117        }
118
119        let mut value: i64 = 0;
120        let mut place: i64 = 1;
121        for byte in column.bytes().rev() {
122            let index = i64::from(byte.to_ascii_lowercase() - b'a' + 1);
123            value = value.wrapping_add(index.wrapping_mul(place));
124            place = place.wrapping_mul(26);
125        }
126        Ok(if self.zero { value - 1 } else { value })
127    }
128}
129
130/// Convert a one-based column number to its letter name (`27` → `"AA"`).
131///
132/// # Errors
133/// Returns [`Error::Negative`] for negative input or [`Error::ZeroNotAllowed`] for `0`.
134///
135/// ```
136/// # use spreadsheet_column::from_int;
137/// assert_eq!(from_int(1).unwrap(), "A");
138/// ```
139pub fn from_int(n: i64) -> Result<String, Error> {
140    SpreadsheetColumn::new().from_int(n)
141}
142
143/// Convert a column letter name to its one-based number (`"AA"` → `27`).
144///
145/// # Errors
146/// Returns [`Error::InvalidString`] if `column` is empty or contains a non-letter.
147///
148/// ```
149/// # use spreadsheet_column::from_str;
150/// assert_eq!(from_str("Z").unwrap(), 26);
151/// ```
152pub fn from_str(column: &str) -> Result<i64, Error> {
153    SpreadsheetColumn::new().from_str(column)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn one_based_int_to_letters() {
162        assert_eq!(from_int(1).unwrap(), "A");
163        assert_eq!(from_int(26).unwrap(), "Z");
164        assert_eq!(from_int(27).unwrap(), "AA");
165        assert_eq!(from_int(52).unwrap(), "AZ");
166        assert_eq!(from_int(53).unwrap(), "BA");
167        assert_eq!(from_int(702).unwrap(), "ZZ");
168        assert_eq!(from_int(703).unwrap(), "AAA");
169        assert_eq!(from_int(16384).unwrap(), "XFD"); // Excel's last column
170    }
171
172    #[test]
173    fn one_based_letters_to_int() {
174        assert_eq!(from_str("A").unwrap(), 1);
175        assert_eq!(from_str("Z").unwrap(), 26);
176        assert_eq!(from_str("AA").unwrap(), 27);
177        assert_eq!(from_str("ZZ").unwrap(), 702);
178        assert_eq!(from_str("XFD").unwrap(), 16384);
179        assert_eq!(from_str("aa").unwrap(), 27); // case-insensitive
180    }
181
182    #[test]
183    fn round_trip() {
184        for n in 1..=5000 {
185            let letters = from_int(n).unwrap();
186            assert_eq!(from_str(&letters).unwrap(), n, "round-trip {n}");
187        }
188    }
189
190    #[test]
191    fn zero_based() {
192        let sc = SpreadsheetColumn::zero_based();
193        assert_eq!(sc.from_int(0).unwrap(), "A");
194        assert_eq!(sc.from_int(1).unwrap(), "B");
195        assert_eq!(sc.from_int(25).unwrap(), "Z");
196        assert_eq!(sc.from_int(26).unwrap(), "AA");
197        assert_eq!(sc.from_str("A").unwrap(), 0);
198        assert_eq!(sc.from_str("AA").unwrap(), 26);
199    }
200
201    #[test]
202    fn errors() {
203        assert_eq!(from_int(0), Err(Error::ZeroNotAllowed));
204        assert_eq!(from_int(-1), Err(Error::Negative));
205        assert_eq!(from_str(""), Err(Error::InvalidString));
206        assert_eq!(from_str("a1"), Err(Error::InvalidString));
207        // Zero is allowed in zero-based mode.
208        assert_eq!(SpreadsheetColumn::zero_based().from_int(0).unwrap(), "A");
209    }
210}