spreadsheet-column 0.1.0

Convert between spreadsheet column numbers and letters (1 <-> A, 27 <-> AA). A faithful port of the spreadsheet-column npm package. Zero deps, no_std.
Documentation
//! # spreadsheet-column — convert between column numbers and letters
//!
//! Convert between spreadsheet column numbers and their letter names — `1` ↔ `A`, `26` ↔
//! `Z`, `27` ↔ `AA` (the bijective base-26 used by Excel, Google Sheets, …). A faithful Rust
//! port of the [`spreadsheet-column`](https://www.npmjs.com/package/spreadsheet-column) npm
//! package.
//!
//! ```
//! use spreadsheet_column::{from_int, from_str};
//!
//! assert_eq!(from_int(27).unwrap(), "AA");
//! assert_eq!(from_str("AA").unwrap(), 27);
//! ```
//!
//! By default columns are one-based (`1` → `A`). Use [`SpreadsheetColumn::zero_based`] for
//! zero-based columns (`0` → `A`):
//!
//! ```
//! use spreadsheet_column::SpreadsheetColumn;
//!
//! let sc = SpreadsheetColumn::zero_based();
//! assert_eq!(sc.from_int(0).unwrap(), "A");
//! assert_eq!(sc.from_str("AA").unwrap(), 26);
//! ```
//!
//! **Zero dependencies** and `#![no_std]`.

#![no_std]
#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/spreadsheet-column/0.1.0")]

extern crate alloc;

use alloc::string::String;
use core::fmt;

// Compile-test the README's examples as part of `cargo test`.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;

/// An error from a column conversion.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
    /// The number was negative.
    Negative,
    /// The number was zero in one-based mode (where the smallest column is `1`).
    ZeroNotAllowed,
    /// The string was empty or contained a non-letter character.
    InvalidString,
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let message = match self {
            Error::Negative => "negative numeric coordinate is not allowed",
            Error::ZeroNotAllowed => "numeric coordinate cannot be zero when starting from one",
            Error::InvalidString => "column must be a non-empty string of ASCII letters",
        };
        f.write_str(message)
    }
}

impl core::error::Error for Error {}

/// A spreadsheet column converter, optionally zero-based.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SpreadsheetColumn {
    zero: bool,
}

impl SpreadsheetColumn {
    /// A one-based converter (`1` → `A`).
    #[must_use]
    pub fn new() -> Self {
        Self { zero: false }
    }

    /// A zero-based converter (`0` → `A`).
    #[must_use]
    pub fn zero_based() -> Self {
        Self { zero: true }
    }

    /// Convert a column number to its letter name.
    ///
    /// # Errors
    /// Returns [`Error::Negative`] for negative input, or [`Error::ZeroNotAllowed`] for `0`
    /// in one-based mode.
    #[allow(clippy::cast_sign_loss)] // `(value - 1) % 26` is always in `0..26`.
    pub fn from_int(&self, n: i64) -> Result<String, Error> {
        if n < 0 {
            return Err(Error::Negative);
        }
        if !self.zero && n == 0 {
            return Err(Error::ZeroNotAllowed);
        }

        // Reduce to a one-based index, then emit the bijective base-26 representation.
        let mut value = if self.zero { n + 1 } else { n };
        let mut letters = String::new();
        while value > 0 {
            let remainder = (value - 1) % 26;
            letters.insert(0, char::from(b'A' + remainder as u8));
            value = (value - 1) / 26;
        }
        Ok(letters)
    }

    /// Convert a column letter name to its number.
    ///
    /// # Errors
    /// Returns [`Error::InvalidString`] if `column` is empty or contains a non-letter.
    pub fn from_str(&self, column: &str) -> Result<i64, Error> {
        if column.is_empty() || !column.bytes().all(|b| b.is_ascii_alphabetic()) {
            return Err(Error::InvalidString);
        }

        let mut value: i64 = 0;
        let mut place: i64 = 1;
        for byte in column.bytes().rev() {
            let index = i64::from(byte.to_ascii_lowercase() - b'a' + 1);
            value = value.wrapping_add(index.wrapping_mul(place));
            place = place.wrapping_mul(26);
        }
        Ok(if self.zero { value - 1 } else { value })
    }
}

/// Convert a one-based column number to its letter name (`27` → `"AA"`).
///
/// # Errors
/// Returns [`Error::Negative`] for negative input or [`Error::ZeroNotAllowed`] for `0`.
///
/// ```
/// # use spreadsheet_column::from_int;
/// assert_eq!(from_int(1).unwrap(), "A");
/// ```
pub fn from_int(n: i64) -> Result<String, Error> {
    SpreadsheetColumn::new().from_int(n)
}

/// Convert a column letter name to its one-based number (`"AA"` → `27`).
///
/// # Errors
/// Returns [`Error::InvalidString`] if `column` is empty or contains a non-letter.
///
/// ```
/// # use spreadsheet_column::from_str;
/// assert_eq!(from_str("Z").unwrap(), 26);
/// ```
pub fn from_str(column: &str) -> Result<i64, Error> {
    SpreadsheetColumn::new().from_str(column)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_based_int_to_letters() {
        assert_eq!(from_int(1).unwrap(), "A");
        assert_eq!(from_int(26).unwrap(), "Z");
        assert_eq!(from_int(27).unwrap(), "AA");
        assert_eq!(from_int(52).unwrap(), "AZ");
        assert_eq!(from_int(53).unwrap(), "BA");
        assert_eq!(from_int(702).unwrap(), "ZZ");
        assert_eq!(from_int(703).unwrap(), "AAA");
        assert_eq!(from_int(16384).unwrap(), "XFD"); // Excel's last column
    }

    #[test]
    fn one_based_letters_to_int() {
        assert_eq!(from_str("A").unwrap(), 1);
        assert_eq!(from_str("Z").unwrap(), 26);
        assert_eq!(from_str("AA").unwrap(), 27);
        assert_eq!(from_str("ZZ").unwrap(), 702);
        assert_eq!(from_str("XFD").unwrap(), 16384);
        assert_eq!(from_str("aa").unwrap(), 27); // case-insensitive
    }

    #[test]
    fn round_trip() {
        for n in 1..=5000 {
            let letters = from_int(n).unwrap();
            assert_eq!(from_str(&letters).unwrap(), n, "round-trip {n}");
        }
    }

    #[test]
    fn zero_based() {
        let sc = SpreadsheetColumn::zero_based();
        assert_eq!(sc.from_int(0).unwrap(), "A");
        assert_eq!(sc.from_int(1).unwrap(), "B");
        assert_eq!(sc.from_int(25).unwrap(), "Z");
        assert_eq!(sc.from_int(26).unwrap(), "AA");
        assert_eq!(sc.from_str("A").unwrap(), 0);
        assert_eq!(sc.from_str("AA").unwrap(), 26);
    }

    #[test]
    fn errors() {
        assert_eq!(from_int(0), Err(Error::ZeroNotAllowed));
        assert_eq!(from_int(-1), Err(Error::Negative));
        assert_eq!(from_str(""), Err(Error::InvalidString));
        assert_eq!(from_str("a1"), Err(Error::InvalidString));
        // Zero is allowed in zero-based mode.
        assert_eq!(SpreadsheetColumn::zero_based().from_int(0).unwrap(), "A");
    }
}