cellrune 0.1.18

Bounded XLSX/XLSM reading, deterministic calculation, editing, and writing for Rust
Documentation
use super::super::ast::Expr;
use super::super::coerce::to_number;
use super::super::eval::{Engine, EvalContext};
use super::super::value::{ErrorKind, Value};
use super::kernel::RomanFunction;
use super::util::required_number;

const MAX_ARABIC_TEXT_CHARS: usize = 255;
const MAX_ARABIC_MAGNITUDE: i32 = 255_000;

#[derive(Debug, Clone, Copy)]
struct RomanSymbol {
    symbol: char,
    value: i32,
}

const ROMAN_SYMBOLS: [RomanSymbol; 7] = [
    RomanSymbol {
        symbol: 'M',
        value: 1000,
    },
    RomanSymbol {
        symbol: 'D',
        value: 500,
    },
    RomanSymbol {
        symbol: 'C',
        value: 100,
    },
    RomanSymbol {
        symbol: 'L',
        value: 50,
    },
    RomanSymbol {
        symbol: 'X',
        value: 10,
    },
    RomanSymbol {
        symbol: 'V',
        value: 5,
    },
    RomanSymbol {
        symbol: 'I',
        value: 1,
    },
];

pub(super) fn call(
    engine: &Engine<'_>,
    context: EvalContext<'_>,
    function: RomanFunction,
    args: &[Expr],
) -> Value {
    match function {
        RomanFunction::Arabic => arabic(engine, context, args),
        RomanFunction::Roman => roman(engine, context, args),
    }
}

fn arabic(engine: &Engine<'_>, context: EvalContext<'_>, args: &[Expr]) -> Value {
    let [argument] = args else {
        return Value::Error(ErrorKind::Value);
    };
    let text = match engine.eval_scalar(context, argument) {
        Value::Text(text) => text,
        Value::Error(kind) => return Value::Error(kind),
        Value::Blank | Value::Number(_) | Value::Logical(_) => {
            return Value::Error(ErrorKind::Value);
        }
    };
    let character_count = text.chars().count();
    if let Err(kind) = engine.charge_function_iterations(context, character_count as u64) {
        return Value::Error(kind);
    }
    let parsed = match parse_arabic(&text) {
        Ok(value) => value,
        Err(kind) => return Value::Error(kind),
    };
    Value::Number(f64::from(parsed))
}

fn roman(engine: &Engine<'_>, context: EvalContext<'_>, args: &[Expr]) -> Value {
    if args.is_empty() || args.len() > 2 {
        return Value::Error(ErrorKind::Value);
    }
    let number = match required_number(engine, context, &args[0]) {
        Ok(value) => value,
        Err(kind) => return Value::Error(kind),
    };
    if !number.is_finite() || !(0.0..=3999.0).contains(&number) {
        return Value::Error(ErrorKind::Value);
    }
    let form = match roman_form(engine, context, args.get(1)) {
        Ok(form) => form,
        Err(kind) => return Value::Error(kind),
    };
    let number = number.trunc() as i32;
    let text = match format_roman(number, form) {
        Ok(text) => text,
        Err(kind) => return Value::Error(kind),
    };
    if let Err(kind) = engine.ensure_text_bytes(text.len()) {
        return Value::Error(kind);
    }
    if let Err(kind) = engine.charge_function_iterations(context, text.len() as u64) {
        return Value::Error(kind);
    }
    Value::Text(text)
}

fn roman_form(
    engine: &Engine<'_>,
    context: EvalContext<'_>,
    argument: Option<&Expr>,
) -> Result<u8, ErrorKind> {
    let Some(argument) = argument.filter(|argument| !matches!(argument, Expr::Missing)) else {
        return Ok(0);
    };
    let value = engine.eval_scalar(context, argument);
    let form = match value {
        Value::Logical(true) => 0.0,
        Value::Logical(false) => 4.0,
        value => to_number(&value)?,
    };
    let form = form.trunc();
    if !form.is_finite() || !(0.0..=4.0).contains(&form) {
        return Err(ErrorKind::Value);
    }
    Ok(form as u8)
}

fn parse_arabic(input: &str) -> Result<i32, ErrorKind> {
    if input.chars().count() > MAX_ARABIC_TEXT_CHARS {
        return Err(ErrorKind::Value);
    }
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Ok(0);
    }
    let (negative, numeral) = match trimmed.strip_prefix('-') {
        Some(numeral) => (true, numeral),
        None => (false, trimmed),
    };
    if numeral.is_empty() {
        return Err(ErrorKind::Value);
    }
    let mut magnitude = 0_i32;
    let mut maximum_to_right = 0_i32;
    for character in numeral.chars().rev() {
        let value = symbol_value(character.to_ascii_uppercase()).ok_or(ErrorKind::Value)?;
        magnitude = if value < maximum_to_right {
            magnitude.checked_sub(value)
        } else {
            maximum_to_right = value;
            magnitude.checked_add(value)
        }
        .ok_or(ErrorKind::Value)?;
    }
    if magnitude > MAX_ARABIC_MAGNITUDE {
        return Err(ErrorKind::Value);
    }
    Ok(if negative { -magnitude } else { magnitude })
}

fn symbol_value(symbol: char) -> Option<i32> {
    ROMAN_SYMBOLS
        .iter()
        .find(|candidate| candidate.symbol == symbol)
        .map(|candidate| candidate.value)
}

fn format_roman(number: i32, form: u8) -> Result<String, ErrorKind> {
    if !(0..=3999).contains(&number) || form > 4 {
        return Err(ErrorKind::Value);
    }
    let mut remaining = number;
    let mut result = String::new();
    for place in 0..=3 {
        let mut symbol_index = place * 2;
        let unit = ROMAN_SYMBOLS[symbol_index].value;
        let digit = remaining / unit;
        if digit % 5 == 4 {
            let target_index = symbol_index
                .checked_sub(if digit == 4 { 1 } else { 2 })
                .ok_or(ErrorKind::Value)?;
            let mut steps = 0_u8;
            while steps < form && symbol_index + 1 < ROMAN_SYMBOLS.len() {
                steps += 1;
                let concise_value =
                    ROMAN_SYMBOLS[target_index].value - ROMAN_SYMBOLS[symbol_index + 1].value;
                if concise_value <= remaining {
                    symbol_index += 1;
                } else {
                    break;
                }
            }
            result.push(ROMAN_SYMBOLS[symbol_index].symbol);
            result.push(ROMAN_SYMBOLS[target_index].symbol);
            remaining += ROMAN_SYMBOLS[symbol_index].value;
            remaining -= ROMAN_SYMBOLS[target_index].value;
        } else {
            if digit > 4 {
                result.push(
                    ROMAN_SYMBOLS
                        .get(symbol_index.checked_sub(1).ok_or(ErrorKind::Value)?)
                        .ok_or(ErrorKind::Value)?
                        .symbol,
                );
            }
            for _ in 0..(digit % 5) {
                result.push(ROMAN_SYMBOLS[symbol_index].symbol);
            }
            remaining %= unit;
        }
    }
    Ok(result)
}

#[cfg(test)]
mod tests {
    use super::{format_roman, parse_arabic};
    use crate::calculation::value::ErrorKind;

    #[test]
    fn formatter_covers_every_excel_conciseness_form() {
        let expected = ["CDXCIX", "LDVLIV", "XDIX", "VDIV", "ID"];
        for (form, expected) in expected.into_iter().enumerate() {
            assert_eq!(format_roman(499, form as u8).unwrap(), expected);
        }
        assert_eq!(format_roman(0, 0).unwrap(), "");
        assert_eq!(format_roman(3999, 0).unwrap(), "MMMCMXCIX");
    }

    #[test]
    fn parser_handles_case_space_negative_and_wide_excel_domain() {
        assert_eq!(parse_arabic("  mxmvii  "), Ok(1997));
        assert_eq!(parse_arabic("-MMXI"), Ok(-2011));
        assert_eq!(parse_arabic(" "), Ok(0));
        assert_eq!(parse_arabic(&"M".repeat(255)), Ok(255_000));
        assert_eq!(parse_arabic(&"M".repeat(256)), Err(ErrorKind::Value));
        assert_eq!(parse_arabic("not-roman"), Err(ErrorKind::Value));
    }

    #[test]
    fn every_formatter_form_round_trips_through_the_parser() {
        for number in 0..=3999 {
            for form in 0..=4 {
                let formatted = format_roman(number, form).unwrap();
                assert_eq!(
                    parse_arabic(&formatted),
                    Ok(number),
                    "{number}, form {form}"
                );
            }
        }
    }
}