openprxl 0.1.0

A Rust spreadsheet library inspired by Python's openpyxl
Documentation
//! Parsing and formatting of A1-style cell references.

use crate::error::{Error, Result};
use regex::Regex;
use std::sync::OnceLock;

/// A single cell reference, 1-indexed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CellRef {
    pub row: u32,
    pub col: u32,
}

impl CellRef {
    /// Create a new cell reference.
    ///
    /// # Errors
    /// Returns an error if `row` or `col` is zero.
    pub fn new(row: u32, col: u32) -> Result<Self> {
        if row == 0 || col == 0 {
            return Err(Error::InvalidReference(format!(
                "row and col must be greater than zero, got ({row}, {col})"
            )));
        }
        Ok(Self { row, col })
    }

    /// Parse an A1-style reference such as `A1`, `AB12`, or `$B$2`.
    pub fn parse<S: AsRef<str>>(s: S) -> Result<Self> {
        parse_a1(s.as_ref())
    }

    /// Format this reference as an A1-style string.
    pub fn to_a1(&self) -> String {
        format!("{}{}", col_to_letters(self.col), self.row)
    }
}

impl std::fmt::Display for CellRef {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.to_a1())
    }
}

/// A rectangular range of cells, inclusive.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RangeRef {
    pub start: CellRef,
    pub end: CellRef,
}

impl RangeRef {
    /// Create a range from two cell references.
    pub fn new(start: CellRef, end: CellRef) -> Self {
        Self { start, end }
    }

    /// Parse an A1-style range such as `A1:B2`.
    pub fn parse<S: AsRef<str>>(s: S) -> Result<Self> {
        parse_range_a1(s.as_ref())
    }

    /// Normalize the range so that `start` is the top-left and `end` is the bottom-right.
    pub fn normalized(&self) -> Self {
        Self {
            start: CellRef {
                row: self.start.row.min(self.end.row),
                col: self.start.col.min(self.end.col),
            },
            end: CellRef {
                row: self.start.row.max(self.end.row),
                col: self.start.col.max(self.end.col),
            },
        }
    }

    /// Format this range as an A1-style string.
    pub fn to_a1(&self) -> String {
        format!("{}:{}", self.start.to_a1(), self.end.to_a1())
    }
}

impl std::fmt::Display for RangeRef {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.to_a1())
    }
}

fn parse_a1(s: &str) -> Result<CellRef> {
    static RE: OnceLock<Regex> = OnceLock::new();
    let re = RE.get_or_init(|| Regex::new(r"^\$?([A-Za-z]+)\$?(\d+)$").unwrap());

    let caps = re
        .captures(s)
        .ok_or_else(|| Error::InvalidReference(s.to_string()))?;
    let letters = caps.get(1).unwrap().as_str();
    let row: u32 = caps
        .get(2)
        .unwrap()
        .as_str()
        .parse()
        .map_err(|_| Error::InvalidReference(s.to_string()))?;
    let col = letters_to_col(letters)?;
    CellRef::new(row, col)
}

fn parse_range_a1(s: &str) -> Result<RangeRef> {
    let parts: Vec<&str> = s.split(':').collect();
    match parts.len() {
        1 => {
            let cell = parse_a1(parts[0])?;
            Ok(RangeRef::new(cell, cell))
        }
        2 => {
            let start = parse_a1(parts[0])?;
            let end = parse_a1(parts[1])?;
            Ok(RangeRef::new(start, end).normalized())
        }
        _ => Err(Error::InvalidReference(s.to_string())),
    }
}

pub fn letters_to_col(letters: &str) -> Result<u32> {
    let mut col: u32 = 0;
    for ch in letters.chars() {
        if !ch.is_ascii_alphabetic() {
            return Err(Error::InvalidReference(format!(
                "invalid column letters: {letters}"
            )));
        }
        let val = (ch.to_ascii_uppercase() as u32) - ('A' as u32) + 1;
        col = col
            .checked_mul(26)
            .and_then(|c| c.checked_add(val))
            .ok_or_else(|| Error::InvalidReference(format!("column out of range: {letters}")))?;
    }
    if col == 0 {
        return Err(Error::InvalidReference(format!(
            "invalid column letters: {letters}"
        )));
    }
    Ok(col)
}

fn col_to_letters(col: u32) -> String {
    let mut col = col;
    let mut letters = String::new();
    while col > 0 {
        let rem = (col - 1) % 26;
        letters.push((b'A' + rem as u8) as char);
        col = (col - 1) / 26;
    }
    letters.chars().rev().collect()
}

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

    #[test]
    fn parse_basic() {
        assert_eq!(CellRef::parse("A1").unwrap(), CellRef::new(1, 1).unwrap());
        assert_eq!(CellRef::parse("B2").unwrap(), CellRef::new(2, 2).unwrap());
        assert_eq!(
            CellRef::parse("AB12").unwrap(),
            CellRef::new(12, 28).unwrap()
        );
        assert_eq!(
            CellRef::parse("$B$2").unwrap(),
            CellRef::new(2, 2).unwrap()
        );
    }

    #[test]
    fn round_trip_columns() {
        for col in [1, 26, 27, 28, 256, 16384] {
            let letters = col_to_letters(col);
            assert_eq!(letters_to_col(&letters).unwrap(), col);
        }
    }

    #[test]
    fn range_parse() {
        let r = RangeRef::parse("B2:A1").unwrap();
        assert_eq!(r.start, CellRef::new(1, 1).unwrap());
        assert_eq!(r.end, CellRef::new(2, 2).unwrap());
    }
}