Shared Rust kernels

value_text

All modules · quoted at commit 9e1884369a67 of DnaCalc/DnaCalc. Comments are the source's own.

measured_host_reader · 6 lines · calc-func/src/value_text.rs on GitHub
/// The numeric-text host grammar measured by the fn8 scalar questions.
pub(crate) fn measured_host_reader(profile: &FormatProfile) -> bool {
    matches!(profile.id, LocaleId::EnZa | LocaleId::CurrentExcelHost)
        && profile.decimal_separator == "." && profile.thousands_separator == " "
        && profile.currency_symbol == "R"
}

Used by 14 functions.

measured_space · 6 lines · calc-func/src/value_text.rs on GitHub
/// Spaces with retained negative numeric-text controls. A configured positive
/// separator remains subject to the existing not-modelled treatment below.
pub(crate) fn measured_space(ch: char, profile: &FormatProfile) -> bool {
    matches!(ch, '\u{00a0}' | '\u{2003}' | '\u{202f}' | '\u{3000}')
        || (ch == '\u{2009}' && measured_host_reader(profile))
}

Used by 14 functions.

read · 74 lines · calc-func/src/value_text.rs on GitHub
/// Reads `text` (UTF-16 code units).
pub(crate) fn read(units: &[u16], context: &ReadContext<'_>) -> Result<Reading, Unread> {
    let mut text = String::with_capacity(units.len());
    let ascii_profile = [
        context.profile.decimal_separator,
        context.profile.thousands_separator,
        context.profile.currency_symbol,
    ]
    .iter()
    .all(|s| s.is_ascii());
    let measured_digits = ascii_profile
        && matches!(context.profile.id, LocaleId::EnZa | LocaleId::CurrentExcelHost);
    let mut translated_digits = false;
    let mut beyond = false;
    let mut has_measured_space = false;
    for decoded in char::decode_utf16(units.iter().copied()) {
        match decoded {
            Ok(c) if c.is_ascii() => text.push(c),
            Ok('€') if measured_host_reader(context.profile) => text.push('€'),
            Ok(c) if measured_space(c, context.profile) => {
                text.push(c);
                has_measured_space = true;
            }
            // Fn4 qt031..qt096 accepts these four digit scripts in the measured profiles.
            // Fn7 live SQRT/DATEVALUE controls also confirm mixed-script numbers, exponent
            // digits and three-component dates; unmeasured fraction/time forms stay refused.
            Ok(c @ ('\u{0660}'..='\u{0669}' | '\u{06f0}'..='\u{06f9}'
                | '\u{0966}'..='\u{096f}' | '\u{0e50}'..='\u{0e59}')) if measured_digits => {
                translated_digits = true;
                let zero = match c {
                    '\u{0660}'..='\u{0669}' => 0x0660,
                    '\u{06f0}'..='\u{06f9}' => 0x06f0,
                    '\u{0966}'..='\u{096f}' => 0x0966,
                    _ => 0x0e50,
                };
                text.push(char::from_u32(u32::from('0') + u32::from(c) - zero).unwrap());
            }
            Ok('\u{ff10}'..='\u{ff19}') if measured_digits => return Err(Unread::Rejected),
            // A digit of another script, a space other than ASCII's, or a profile whose
            // separators or currency lie beyond ASCII: what Excel reads is not modelled.
            Ok(c) if c.is_numeric() || c.is_whitespace() || !ascii_profile => {
                return Err(Unread::NotModelled);
            }
            // Any other character beyond ASCII is in no number, date or time form.
            Ok(_) => beyond = true,
            Err(_) => return Err(Unread::NotModelled),
        }
    }
    if beyond {
        return Err(Unread::Rejected);
    }
    let text = text.trim_matches(' ');
    if text.is_empty() {
        return Err(Unread::Rejected);
    }
    if let Some(n) = number(text, context.profile)? {
        if has_measured_space {
            return Err(Unread::NotModelled);
        }
        return Ok(Reading::Number(n));
    }
    // Only ASCII spaces belong to the measured fraction/date/time grammar. The numeric
    // parser above can consume a configured separator; none of these spaces is trimming.
    if has_measured_space {
        return Err(Unread::Rejected);
    }
    if let Some(n) = fraction(text) {
        if translated_digits {
            return Err(Unread::NotModelled);
        }
        return n.map(Reading::Number);
    }
    date_time(text, context, !translated_digits).map(Reading::DateTime)
}

Used by 14 functions.

number · 162 lines · calc-func/src/value_text.rs on GitHub
/// Number text, `None` when the text is not in the number form (it may be a date or a fraction).
fn number(text: &str, profile: &FormatProfile) -> Result<Option<f64>, Unread> {
    let bytes = text.as_bytes();
    let currency = profile.currency_symbol.as_bytes();
    let euro = "€".as_bytes();
    let currency_length = |at: usize| {
        if !currency.is_empty() && bytes[at..].starts_with(currency) { currency.len() }
        else if measured_host_reader(profile) && bytes[at..].starts_with(euro) { euro.len() }
        else { 0 }
    };
    let group = profile.thousands_separator.as_bytes();
    let point = profile.decimal_separator.as_bytes();
    let mut at = 0;
    let (mut signed, mut negative, mut paren, mut has_currency, mut percent) =
        (false, false, false, false, false);
    let skip = |at: &mut usize| {
        while bytes.get(*at) == Some(&b' ') {
            *at += 1;
        }
    };
    loop {
        skip(&mut at);
        match bytes.get(at) {
            Some(b'+' | b'-') if !signed && !paren => {
                signed = true;
                negative = bytes[at] == b'-';
                at += 1;
            }
            Some(b'(') if !signed && !paren => {
                paren = true;
                at += 1;
            }
            Some(b'%') if !percent => {
                percent = true;
                at += 1;
            }
            _ if !has_currency && currency_length(at) > 0 => {
                has_currency = true;
                at += currency_length(at);
            }
            _ => break,
        }
    }
    // The mantissa: digits with group separators, then a decimal point and digits.
    let mut digits = String::new();
    let mut fraction_digits = 0i64;
    let mut any = false;
    let mut group_run: Option<usize> = None;
    loop {
        match bytes.get(at) {
            Some(d) if d.is_ascii_digit() => {
                digits.push(char::from(*d));
                any = true;
                if let Some(run) = group_run.as_mut() {
                    *run += 1;
                }
                at += 1;
            }
            _ if any
                && !group.is_empty()
                && bytes[at..].starts_with(group)
                && bytes.get(at + group.len()).is_some_and(u8::is_ascii_digit) =>
            {
                if group_run.is_some_and(|run| run < 3) {
                    return Ok(None);
                }
                group_run = Some(0);
                at += group.len();
            }
            _ => break,
        }
    }
    if group_run.is_some_and(|run| run < 3) {
        return Ok(None);
    }
    if bytes[at..].starts_with(point) {
        at += point.len();
        while let Some(d) = bytes.get(at).filter(|d| d.is_ascii_digit()) {
            digits.push(char::from(*d));
            fraction_digits += 1;
            any = true;
            at += 1;
        }
    }
    if !any {
        return Ok(None);
    }
    let mut exponent = 0i64;
    if matches!(bytes.get(at), Some(b'e' | b'E')) {
        let mut cursor = at + 1;
        let exp_negative = match bytes.get(cursor) {
            Some(b'-') => {
                cursor += 1;
                true
            }
            Some(b'+') => {
                cursor += 1;
                false
            }
            _ => false,
        };
        let start = cursor;
        while bytes.get(cursor).is_some_and(u8::is_ascii_digit) {
            exponent = (exponent * 10 + i64::from(bytes[cursor] - b'0')).min(1_000_000);
            cursor += 1;
        }
        if cursor == start {
            return Ok(None);
        }
        if exp_negative {
            exponent = -exponent;
        }
        at = cursor;
    }
    // Euro may also follow the amount, inside or outside parentheses;
    // the configured Rand remains prefix-only (fn8 paired currency controls).
    let take_euro = |at: &mut usize, has_currency: &mut bool| {
        if !*has_currency && measured_host_reader(profile) && bytes[*at..].starts_with(euro) {
            *has_currency = true;
            *at += euro.len();
            skip(at);
        }
    };
    skip(&mut at);
    take_euro(&mut at, &mut has_currency);
    if bytes.get(at) == Some(&b'%') && !percent {
        percent = true;
        at += 1;
        skip(&mut at);
    }
    if paren {
        if bytes.get(at) != Some(&b')') {
            return Ok(None);
        }
        at += 1;
        negative = true;
        skip(&mut at);
    }
    take_euro(&mut at, &mut has_currency);
    // W111 numeric-text-core-heldout: the percent may follow the complete parenthesized
    // amount, with ASCII spaces between. The same flag rejects a second percent.
    if bytes.get(at) == Some(&b'%') && !percent {
        percent = true;
        at += 1;
        skip(&mut at);
    }
    if at != bytes.len() {
        return Ok(None);
    }
    if has_currency && percent && measured_host_reader(profile) {
        return Err(Unread::Rejected);
    }
    let scale = exponent - fraction_digits - if percent { 2 } else { 0 };
    // W111 percent-bounds: a zero coefficient still validates the percent-adjusted
    // exponent. decimal()'s zero fast return otherwise bypasses this admission rule.
    if percent && digits.bytes().all(|digit| digit == b'0') && !(-308..=308).contains(&scale) {
        return Err(Unread::Rejected);
    }
    decimal(&digits, scale)
        .map(|n| Some(if negative && n != 0.0 { -n } else { n }))
        .ok_or(Unread::Rejected)
}

Used by 14 functions.

decimal · 17 lines · calc-func/src/value_text.rs on GitHub
/// The binary64 nearest `digits` (ASCII decimal digits) times 10^scale, its significant digits
/// cut to 15; `None` when the value's decimal point lies outside -308..=308, as Excel's number
/// text reading rejects it.
pub(crate) fn decimal(digits: &str, scale: i64) -> Option<f64> {
    let trimmed = digits.trim_start_matches('0');
    if trimmed.is_empty() {
        return Some(0.0);
    }
    let kept = &trimmed[..trimmed.len().min(15)];
    let dropped = (trimmed.len() - kept.len()) as i64;
    let position = scale + trimmed.len() as i64;
    if !(-308..=308).contains(&position) {
        return None;
    }
    let n: f64 = format!("{kept}e{}", scale + dropped).parse().ok()?;
    Some(if n.is_subnormal() { 0.0 } else { n })
}

Used by 14 functions.

fraction · 26 lines · calc-func/src/value_text.rs on GitHub
/// A whole number and a fraction: `[sign] int spaces num/den`. `None` when the text is not in
/// this form; #VALUE! for a zero denominator.
fn fraction(text: &str) -> Option<Result<f64, Unread>> {
    let (negative, body) = match text.as_bytes().first() {
        Some(b'-') => (true, text[1..].trim_start_matches(' ')),
        Some(b'+') => (false, text[1..].trim_start_matches(' ')),
        _ => (false, text),
    };
    let (whole, rest) = body.split_once(' ')?;
    let rest = rest.trim_start_matches(' ');
    let (numerator, denominator) = rest.split_once('/')?;
    let all_digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) && s.len() <= 15;
    if !all_digits(whole) || !all_digits(numerator) || !all_digits(denominator) {
        return None;
    }
    let (w, n, d): (f64, f64, f64) = (
        whole.parse().ok()?,
        numerator.parse().ok()?,
        denominator.parse().ok()?,
    );
    if d == 0.0 {
        return Some(Err(Unread::Rejected));
    }
    let value = w + n / d;
    Some(Ok(if negative { -value } else { value }))
}

Used by 14 functions.

tokens · 36 lines · calc-func/src/value_text.rs on GitHub
fn tokens(text: &str) -> Result<Vec<Token>, Unread> {
    let bytes = text.as_bytes();
    let mut out = Vec::new();
    let mut at = 0;
    while at < bytes.len() {
        let b = bytes[at];
        if b.is_ascii_digit() {
            let start = at;
            while at < bytes.len() && bytes[at].is_ascii_digit() {
                at += 1;
            }
            let run = &text[start..at];
            if run.len() > 9 {
                return Err(Unread::Rejected);
            }
            out.push(Token::Number(run.parse().expect("digits"), run.len()));
        } else if b.is_ascii_alphabetic() {
            let start = at;
            while at < bytes.len() && bytes[at].is_ascii_alphabetic() {
                at += 1;
            }
            out.push(Token::Word(text[start..at].to_ascii_lowercase()));
        } else if b == b' ' {
            while at < bytes.len() && bytes[at] == b' ' {
                at += 1;
            }
            out.push(Token::Sep(' '));
        } else if matches!(b, b'/' | b'-' | b':' | b'.' | b',') {
            out.push(Token::Sep(char::from(b)));
            at += 1;
        } else {
            return Err(Unread::Rejected);
        }
    }
    Ok(out)
}

Used by 14 functions.

month_named · 12 lines · calc-func/src/value_text.rs on GitHub
fn month_named(word: &str) -> Option<i64> {
    const NAMES: [&str; 12] = [
        "january", "february", "march", "april", "may", "june", "july", "august", "september",
        "october", "november", "december",
    ];
    if word == "sept" {
        return Some(9);
    }
    NAMES.iter().position(|name| {
        *name == word || (word.len() == 3 && name.starts_with(word))
    }).map(|at| at as i64 + 1)
}

Used by 14 functions.

time_at · 66 lines · calc-func/src/value_text.rs on GitHub
/// The time at the start of `tokens[from..]`, its fraction of a day and the tokens it took.
fn time_at(tokens: &[Token], from: usize) -> Option<(f64, usize)> {
    let mut at = from;
    let Token::Number(hour, hour_len) = tokens.get(at)? else {
        return None;
    };
    let (mut hour, hour_len) = (*hour, *hour_len);
    at += 1;
    let mut minute = 0i64;
    let mut second = 0f64;
    let mut colon = false;
    if tokens.get(at) == Some(&Token::Sep(':')) {
        colon = true;
        at += 1;
        if let Some(Token::Number(m, len)) = tokens.get(at) {
            if *len > 2 {
                return None;
            }
            minute = *m;
            at += 1;
            if tokens.get(at) == Some(&Token::Sep(':')) {
                at += 1;
                if let Some(Token::Number(s, len)) = tokens.get(at) {
                    if *len > 2 {
                        return None;
                    }
                    second = *s as f64;
                    at += 1;
                    if tokens.get(at) == Some(&Token::Sep('.'))
                        && let Some(Token::Number(f, len)) = tokens.get(at + 1)
                    {
                        second += *f as f64 / 10f64.powi(*len as i32);
                        at += 2;
                    }
                }
            }
        }
    }
    if hour_len > 4 {
        return None;
    }
    // An AM/PM marker after a space.
    let mut marked = false;
    if tokens.get(at) == Some(&Token::Sep(' '))
        && let Some(Token::Word(word)) = tokens.get(at + 1)
        && matches!(word.as_str(), "a" | "am" | "p" | "pm")
    {
        if hour > 12 {
            return None;
        }
        let pm = word.starts_with('p');
        if hour == 12 {
            hour = 0;
        }
        if pm {
            hour += 12;
        }
        marked = true;
        at += 2;
    }
    if !colon && !marked {
        return None;
    }
    let seconds = (hour * 3600 + minute * 60) as f64 + second;
    Some((seconds / 86_400.0, at - from))
}

Used by 14 functions.

full_year · 12 lines · calc-func/src/value_text.rs on GitHub
/// A two-digit year as Excel reads it: below 30 in the 2000s, else in the 1900s.
fn full_year(year: i64, len: usize) -> i64 {
    if len <= 2 {
        if year < 30 {
            2000 + year
        } else {
            1900 + year
        }
    } else {
        year
    }
}

Used by 14 functions.

valid_day · 5 lines · calc-func/src/value_text.rs on GitHub
fn valid_day(year: i64, month: i64, day: i64) -> bool {
    (1..=12).contains(&month)
        && day >= 1
        && (day <= days_in_month(year, month) || (year == 1900 && month == 2 && day == 29))
}

Used by 14 functions.

this_year · 8 lines · calc-func/src/value_text.rs on GitHub
/// The calculation's year, from its clock.
fn this_year(context: &ReadContext<'_>) -> Result<i64, Unread> {
    let now = context.now.ok_or(Unread::NotModelled)?;
    if !(0.0..2_958_466.0).contains(&now) {
        return Err(Unread::NotModelled);
    }
    Ok(ymd_1900(now.trunc() as i64).0)
}

Used by 14 functions.

serial_1900 · 7 lines · calc-func/src/value_text.rs on GitHub
/// The 1900 serial of a date, or #VALUE! outside 1900..=9999.
fn serial_1900(year: i64, month: i64, day: i64) -> Result<i64, Unread> {
    if !(1900..=9999).contains(&year) || !valid_day(year, month, day) {
        return Err(Unread::Rejected);
    }
    Ok(crate::ported::date_kernels::serial_unbounded_1900(year, month, day))
}

Used by 14 functions.

date_of · 131 lines · calc-func/src/value_text.rs on GitHub
/// The date that `tokens` (without a time) name, as a 1900 serial.
fn date_of(tokens: &[Token], context: &ReadContext<'_>) -> Result<i64, Unread> {
    // The elements and the separators between them.
    let mut elements: Vec<&Token> = Vec::new();
    let mut separators: Vec<Option<char>> = Vec::new();
    let mut pending: Option<char> = None;
    let mut had_separator = false;
    for token in tokens {
        match token {
            Token::Sep(c) => {
                if had_separator && !(pending == Some(',') && *c == ' ') {
                    return Err(Unread::Rejected);
                }
                if elements.is_empty() {
                    return Err(Unread::Rejected);
                }
                pending = Some(match (pending, *c) {
                    (Some(','), ' ') => ',',
                    (_, c) => c,
                });
                had_separator = true;
            }
            _ => {
                if !elements.is_empty() {
                    separators.push(pending.take());
                }
                elements.push(token);
                had_separator = false;
                pending = None;
            }
        }
    }
    if had_separator || elements.is_empty() {
        return Err(Unread::Rejected);
    }
    let months: Vec<usize> = elements
        .iter()
        .enumerate()
        .filter(|(_, t)| matches!(t, Token::Word(_)))
        .map(|(i, _)| i)
        .collect();
    let number = |t: &Token| match t {
        Token::Number(n, len) => Some((*n, *len)),
        _ => None,
    };
    match months.as_slice() {
        [] => {
            // Numbers only, separated by / or -.
            if separators.iter().any(|s| !matches!(s, Some('/' | '-'))) {
                return Err(Unread::Rejected);
            }
            let nums: Vec<(i64, usize)> = elements.iter().filter_map(|t| number(t)).collect();
            match nums.as_slice() {
                [a, b, c] => {
                    let (y, m, d) = match context.profile.short_date_order {
                        DateComponentOrder::Ymd => (a, b, c),
                        DateComponentOrder::Mdy | DateComponentOrder::Dmy => {
                            return Err(Unread::NotModelled);
                        }
                    };
                    // Fn4 list-controls ql041..ql085: month/day tokens have one or two
                    // digits. A three/four-digit final token is not a day with leading zeros.
                    if matches!(context.profile.id, LocaleId::EnZa | LocaleId::CurrentExcelHost)
                        && (m.1 > 2 || d.1 > 2) {
                        return Err(Unread::Rejected);
                    }
                    serial_1900(full_year(y.0, y.1), m.0, d.0)
                }
                [a, b] => {
                    if context.profile.short_date_order != DateComponentOrder::Ymd {
                        return Err(Unread::NotModelled);
                    }
                    // Month and day, or day and month, of the clock's year (2024, a leap year,
                    // stands in to test them when there is no clock); else year and month.
                    let year = this_year(context);
                    let test_year = *year.as_ref().unwrap_or(&2024);
                    if valid_day(test_year, a.0, b.0) {
                        serial_1900(year?, a.0, b.0)
                    } else if valid_day(test_year, b.0, a.0) {
                        serial_1900(year?, b.0, a.0)
                    } else if (1..=12).contains(&b.0) {
                        serial_1900(full_year(a.0, a.1), b.0, 1)
                    } else {
                        Err(Unread::Rejected)
                    }
                }
                _ => Err(Unread::Rejected),
            }
        }
        [at] => {
            let Token::Word(word) = elements[*at] else {
                unreachable!()
            };
            let month = month_named(word).ok_or(Unread::Rejected)?;
            // A comma may follow the month name only.
            for (i, s) in separators.iter().enumerate() {
                if *s == Some(',') && i != *at {
                    return Err(Unread::Rejected);
                }
                if matches!(s, Some('/' | ':' | '.')) {
                    return Err(Unread::Rejected);
                }
            }
            let nums: Vec<(i64, usize)> = elements.iter().filter_map(|t| number(t)).collect();
            match (*at, nums.as_slice()) {
                // day month year
                (1, [d, y]) if elements.len() == 3 => serial_1900(full_year(y.0, y.1), month, d.0),
                // day month
                (1, [d]) if elements.len() == 2 => {
                    let year = this_year(context)?;
                    serial_1900(year, month, d.0)
                }
                // month day-or-year
                (0, [n]) if elements.len() == 2 => {
                    if separators[0] == Some(',') {
                        return Err(Unread::Rejected);
                    }
                    let year = this_year(context);
                    let test_year = *year.as_ref().unwrap_or(&2024);
                    if n.1 <= 2 && valid_day(test_year, month, n.0) {
                        serial_1900(year?, month, n.0)
                    } else {
                        serial_1900(full_year(n.0, n.1), month, 1)
                    }
                }
                _ => Err(Unread::Rejected),
            }
        }
        _ => Err(Unread::Rejected),
    }
}

Used by 14 functions.

date_time · 75 lines · calc-func/src/value_text.rs on GitHub
fn date_time(text: &str, context: &ReadContext<'_>, allow_time: bool) -> Result<f64, Unread> {
    let tokens = tokens(text)?;
    // A time at the start, or after the last space.
    let mut time = None;
    let mut date_tokens: &[Token] = &tokens;
    if let Some((fraction, taken)) = time_at(&tokens, 0) {
        let rest = &tokens[taken..];
        let rest = match rest.first() {
            None => rest,
            Some(Token::Sep(' ')) => &rest[1..],
            _ => return Err(Unread::Rejected),
        };
        time = Some(fraction);
        date_tokens = rest;
    } else {
        for start in (1..tokens.len()).rev() {
            if tokens[start - 1] != Token::Sep(' ') {
                continue;
            }
            if let Some((fraction, taken)) = time_at(&tokens, start)
                && start + taken == tokens.len()
            {
                time = Some(fraction);
                date_tokens = &tokens[..start - 1];
                break;
            }
        }
    }
    // A trailing colon after the time ("12:" and "1:30:").
    if time.is_none()
        && let Some(Token::Sep(':')) = tokens.last()
        && let Some((fraction, taken)) = time_at(&tokens[..tokens.len() - 1], 0)
        && taken == tokens.len() - 1
    {
        time = Some(fraction);
        date_tokens = &[];
    }
    if time.is_none() && tokens.len() >= 2 {
        let trimmed = &tokens[..tokens.len() - 1];
        if tokens.last() == Some(&Token::Sep(':'))
            && let Some((fraction, taken)) = time_at(trimmed, 0)
            && taken == trimmed.len()
        {
            time = Some(fraction);
            date_tokens = &[];
        }
    }
    if time.is_some() && !allow_time {
        return Err(Unread::NotModelled);
    }
    let date = if date_tokens.is_empty() {
        None
    } else {
        Some(date_of(date_tokens, context)?)
    };
    if date.is_none() && time.is_none() {
        return Err(Unread::Rejected);
    }
    let serial = match (date, context.date_system) {
        (None, _) => 0.0,
        (Some(serial), DateSystem::System1900) => serial as f64,
        (Some(serial), DateSystem::System1904) => {
            let serial = serial as f64 - OFFSET_1904;
            if serial < 0.0 {
                return Err(Unread::Rejected);
            }
            serial
        }
    };
    Ok(match time {
        Some(fraction) if date.is_some() => serial + fraction,
        Some(fraction) => fraction,
        None => serial,
    })
}

Used by 14 functions.