Functional Weave
Code in TypeScript

units.parse-quantity@1.0.0

impl/rust.rs

9,215 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::units_convert_data::{UnitDefinition, UNITS};  ← units.convert’s rule data (^1.0.0) · built alongside by fune
use super::units_parse_quantity_data::UNIT_ALIASES;  ← this capability’s own data, compiled from data/aliases.json into the same file by fune build

const MAX_DIGITS: usize = 15;

// Explicit sets rather than char::is_whitespace/to_lowercase: those differ from
// the other languages on non-ASCII input, and these must not.
fn is_space(c: char) -> bool {
    c == ' ' || c == '\t' || c == '\n' || c == '\r'
}

fn all_digits(s: &str) -> bool {
    !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
}

fn well_grouped(whole: &str) -> bool {
    if !whole.contains(',') {
        return all_digits(whole);
    }
    let groups: Vec<&str> = whole.split(',').collect();
    (1..=3).contains(&groups[0].len())
        && all_digits(groups[0])
        && groups[1..].iter().all(|g| g.len() == 3 && all_digits(g))
}

fn parse_number(written: &str) -> (i128, String) {
    let pieces: Vec<&str> = written.split('.').collect();
    let plain = written.replace(',', "");
    let plain_pieces: Vec<&str> = plain.split('.').collect();
    let plain_ok = plain_pieces.len() <= 2
        && all_digits(plain_pieces[0])
        && (plain_pieces.len() == 1 || all_digits(plain_pieces[1]));
    let ok = pieces.len() <= 2
        && well_grouped(pieces[0])
        && (pieces.len() == 1 || all_digits(pieces[1]));
    if !ok {
        if written.contains(',') && plain_ok {
            panic!("bad thousands grouping in \"{}\"", written);
        }
        panic!("malformed number \"{}\"", written);
    }
    let whole = plain_pieces[0].trim_start_matches('0');
    let fraction = if plain_pieces.len() > 1 { plain_pieces[1] } else { "" }.trim_end_matches('0');
    let joined = format!("{}{}", whole, fraction);
    if joined.trim_start_matches('0').len() > MAX_DIGITS || fraction.len() > MAX_DIGITS {
        panic!(
            "number \"{}\" has too many digits: at most 15 significant digits and 15 decimal places",
            written
        );
    }
    let value: i128 = if whole.is_empty() { 0 } else { whole.parse().unwrap() };
    (value, fraction.to_string())
}

fn unit_by_symbol(symbol: &str) -> &'static UnitDefinition {
    match UNITS.iter().find(|u| u.symbol == symbol) {
        Some(u) => u,
        // Only reachable if the alias table and units.convert drift apart.
        None => panic!(
            "alias table names unit \"{}\", which units.convert does not define",
            symbol
        ),
    }
}

fn resolve_unit(text: &str) -> &'static UnitDefinition {
    if let Some(u) = UNITS.iter().find(|u| u.symbol == text) {
        return u;
    }
    if let Some(a) = UNIT_ALIASES.iter().find(|a| a.alias == text) {
        return match a.symbol {
            Some(symbol) => unit_by_symbol(symbol),
            None => panic!("ambiguous unit \"{}\": use {}", text, a.choices.unwrap_or("")),
        };
    }
    // Case-insensitive fallback, so "KG" and "Feet" work. Where two spellings
    // differ only in case and mean different things ("cal" and food "Cal"),
    // the ambiguity wins rather than a guess.
    let lower = text.to_ascii_lowercase();
    if let Some(a) = UNIT_ALIASES
        .iter()
        .find(|a| a.symbol.is_none() && a.alias.to_ascii_lowercase() == lower)
    {
        panic!("ambiguous unit \"{}\": use {}", text, a.choices.unwrap_or(""));
    }
    let mut symbols: Vec<&'static str> = Vec::new();
    for u in UNITS {
        if u.symbol.to_ascii_lowercase() == lower && !symbols.contains(&u.symbol) {
            symbols.push(u.symbol);
        }
    }
    for a in UNIT_ALIASES {
        if let Some(symbol) = a.symbol {
            if a.alias.to_ascii_lowercase() == lower && !symbols.contains(&symbol) {
                symbols.push(symbol);
            }
        }
    }
    match symbols.len() {
        1 => unit_by_symbol(symbols[0]),
        0 => panic!("unknown unit \"{}\"", text),
        _ => panic!(
            "ambiguous unit \"{}\": letter case matters, use {}",
            text,
            symbols.join(" or ")
        ),
    }
}

fn format_value(negative: bool, whole: i128, fraction: &str) -> String {
    let mut text = whole.to_string();
    if !fraction.is_empty() {
        text.push('.');
        text.push_str(fraction);
    }
    if negative && (whole != 0 || !fraction.is_empty()) {
        text.insert(0, '-');
    }
    text
}

struct Part {
    whole: i128,
    fraction: String,
    unit: &'static UnitDefinition,
}

/// Parse a written quantity into an exact decimal value and a canonical unit.
///
/// A compound such as "12 ft 6 in" is summed into its last unit ("150" in)
/// using exact whole-number ratios, which is why mixing systems ("1 ft 2 cm")
/// is refused rather than approximated.
///
/// # Panics
/// Panics on empty text, malformed numbers, unknown or ambiguous units, and
/// compounds that mix dimensions, run small to large or cannot sum exactly.
pub fn parse_quantity(text: &str) -> Quantity {
    let src: String = text.trim_matches(is_space).to_string();
    if src.is_empty() {
        panic!("quantity text is empty");
    }
    let chars: Vec<char> = src.chars().collect();
    let n = chars.len();
    let slice = |a: usize, b: usize| -> String { chars[a..b].iter().collect() };

    let mut negative = false;
    let mut parts: Vec<Part> = Vec::new();
    let mut i = 0;
    while i < n {
        while i < n && is_space(chars[i]) {
            i += 1;
        }
        let start = i;
        if i < n && chars[i] == '-' {
            if !parts.is_empty() {
                panic!("only the first number may be negative: \"{}\"", src);
            }
            negative = true;
            i += 1;
        }
        let number_start = i;
        while i < n && (chars[i].is_ascii_digit() || chars[i] == ',' || chars[i] == '.') {
            i += 1;
        }
        let written = slice(number_start, i);
        if written.is_empty() {
            panic!("expected a number at \"{}\"", slice(start, n));
        }
        let (whole, fraction) = parse_number(&written);
        let unit_start = i;
        while i < n && !chars[i].is_ascii_digit() && chars[i] != '-' {
            i += 1;
        }
        let raw = slice(unit_start, i);
        let unit_text = raw.split(is_space).filter(|t| !t.is_empty()).collect::<Vec<_>>().join(" ");
        if unit_text.is_empty() {
            panic!("missing unit after \"{}\"", written);
        }
        parts.push(Part {
            whole,
            fraction,
            unit: resolve_unit(&unit_text),
        });
    }

    let last = parts.last().unwrap();
    if parts.len() == 1 {
        return Quantity {
            value: format_value(negative, last.whole, &last.fraction),
            unit: last.unit.symbol.to_string(),
            dimension: last.unit.dimension.to_string(),
        };
    }

    for k in 1..parts.len() {
        let prev = parts[k - 1].unit;
        let cur = parts[k].unit;
        if cur.dimension != prev.dimension {
            panic!(
                "cannot combine {} ({}) and {} ({}) in one quantity",
                prev.symbol, prev.dimension, cur.symbol, cur.dimension
            );
        }
        if cur.dimension == "temperature" {
            panic!("temperatures cannot be compound: \"{}\"", src);
        }
        if cur.symbol == prev.symbol {
            panic!("unit \"{}\" appears twice in \"{}\"", cur.symbol, src);
        }
        let cur_size = cur.factor_numerator as i128 * prev.factor_denominator as i128;
        let prev_size = prev.factor_numerator as i128 * cur.factor_denominator as i128;
        if cur_size >= prev_size {
            panic!(
                "units must run from largest to smallest: \"{}\" cannot come before \"{}\"",
                prev.symbol, cur.symbol
            );
        }
    }

    // Earlier parts are whole numbers and every ratio is a whole number, so
    // the fraction of the total is the last part's fraction, unchanged, and
    // the whole part stays far inside i128 (15 digits x a ratio below 3e18).
    let mut total = last.whole;
    for part in &parts[..parts.len() - 1] {
        if !part.fraction.is_empty() {
            panic!(
                "only the last number of a compound quantity may have a fraction: \"{}\"",
                src
            );
        }
        let num = part.unit.factor_numerator as i128 * last.unit.factor_denominator as i128;
        let den = part.unit.factor_denominator as i128 * last.unit.factor_numerator as i128;
        if num % den != 0 {
            panic!(
                "cannot combine {} and {} exactly: 1 {} is not a whole number of {}",
                part.unit.symbol, last.unit.symbol, part.unit.symbol, last.unit.symbol
            );
        }
        total += part.whole * (num / den);
    }
    Quantity {
        value: format_value(negative, total, &last.fraction),
        unit: last.unit.symbol.to_string(),
        dimension: last.unit.dimension.to_string(),
    }
}

pub fn quantity_to_value(q: &Quantity) -> Value {
    Value::obj(vec![
        ("value", Value::str(&q.value)),
        ("unit", Value::str(&q.unit)),
        ("dimension", Value::str(&q.dimension)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    quantity_to_value(&parse_quantity(args[0].as_str()))
}