use super::funejson::Value; use super::units_convert_data::{UnitDefinition, UNITS}; use super::units_parse_quantity_data::UNIT_ALIASES; 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 = 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 = 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::>().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())) }