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()))
}