Functional Weave
Code in Rust

units.parse-quantity

Parse "2.5 kg", "1,200 m" or "12 ft 6 in" into an exact decimal value and a canonical unit symbol, strictly.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 31 tests, run in TypeScript, Python and Rust.

What it does

Turns what a person typed into a value and a canonical unit that `units.convert` accepts: `"2.5 kg"`, `"1,200 m"`, `"12 ft 6 in"`, `"5'11\""`. The value is decimal text, never a float, so nothing is lost between parsing and converting.

## Grammar

For example

  • parse_quantity(2.5 kg) → value 2.5, unit kg, dimension mass a decimal and a symbol
  • parse_quantity(1,200 m) → value 1200, unit m, dimension length thousands separators are removed
  • parse_quantity(12 ft 6 in) → value 150, unit in, dimension length feet and inches sum exactly into inches

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

pub fn parse_quantity(text: &str) -> Quantity
textstringa number and a unit, or a compound such as "5 lb 3 oz" (largest unit first)
returnsQuantitya compound is summed exactly into its last (smallest) unit

The type it declares, generated into your project

/// A parsed quantity, ready to hand to units.convert.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Quantity {
    /// plain decimal text: commas removed, trailing zeros trimmed
    pub value: String,
    /// canonical units.convert symbol, e.g. "in", "gal_us", "degC"
    pub unit: String,
    /// length, mass, area, volume, temperature or energy
    pub dimension: String,
}

Your code names it in one line, in the file that uses it

fune!(units.parse-quantity@^1);  // then call parse_quantity(…)
impl/rust.rs · 259 lines · open · 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()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add units.parse-quantity
Download for Rust units.parse-quantity-1.0.0-rust.fune · 32,880 bytes sha256 64ee1c89e50779e18c346fcd82234357e23fa80623d22a0029a89b26f139956d

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./units.parse-quantity-1.0.0-rust.fune, or fetch it from a terminal with fune pull units.parse-quantity@1.0.0:rust.

The whole function, every language, is one file too: units.parse-quantity-1.0.0.fune, 48,085 bytes, sha256 8edaf9580576a532d30ec6a175c43a336e2e53ee57d48381fc833377b290fa8c. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before units.parse-quantity

after — your function gets the result and the arguments, and returns the final result.

// fune: after units.parse-quantity

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace units.convert in units.parse-quantity

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show units.parse-quantity --steps.

// fune: step units.parse-quantity after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
a decimal and a symbol 2.5 kg → value 2.5, unit kg, dimension mass
thousands separators are removed 1,200 m → value 1200, unit m, dimension length
feet and inches sum exactly into inches 12 ft 6 in → value 150, unit in, dimension length
prime and double-prime marks 5'11" → value 71, unit in, dimension length
a fraction on the last part is kept 5 ft 11.5 in → value 71.5, unit in, dimension length
pounds and ounces 5 lb 3 oz → value 83, unit oz, dimension mass
stones and pounds 11 st 4 lb → value 158, unit lb, dimension mass
metric compounds work too 1 m 5 cm → value 105, unit cm, dimension length
a negative sign applies to the whole compound -5 ft 3 in → value -63, unit in, dimension length
word units in any letter case, trailing zeros trimmed 2.50 Kilograms → value 2.5, unit kg, dimension mass
Show the other 21 tests
CaseArgumentsExpected
degree sign temperature, negative -40 °F → value -40, unit degF, dimension temperature
grouped digits with a fraction 12,345,678.90 mL → value 12345678.9, unit mL, dimension volume
a two-word unit 8 US fl oz → value 8, unit floz_us, dimension volume
no space between number and unit, leading zeros dropped 007kWh → value 7, unit kWh, dimension energy
negative zero is zero -0.0 kg → value 0, unit kg, dimension mass
square feet 3 sq ft → value 3, unit ft2, dimension area
empty text → error: quantity text is empty
a unit with no number kg → error: expected a number at "kg"
a number with no unit 12 → error: missing unit after "12"
bad thousands grouping 1,20 m → error: bad thousands grouping in "1,20"
two decimal points 1.2.3 m → error: malformed number "1.2.3"
unknown unit 3 furlongs → error: unknown unit "furlongs"
a bare gallon is ambiguous 2 gal → error: ambiguous unit "gal": use gal_us or gal_imp
food Calorie is not the calorie symbol, whatever the case 250 CAL → error: ambiguous unit "CAL": use cal (thermochemical calorie) or kcal (food Calorie)
smallest unit first is refused 6 in 12 ft → error: units must run from largest to smallest: "in" cannot come before "ft"
mixed dimensions are refused 5 ft 3 lb → error: cannot combine ft (length) and lb (mass) in one quantity
mixed systems cannot sum exactly 1 ft 2 cm → error: cannot combine ft and cm exactly: 1 ft is not a whole number of cm
a fraction before the last part is refused 5.5 ft 3 in → error: only the last number of a compound quantity may have a fraction
a minus sign inside a compound is refused 5 ft -3 in → error: only the first number may be negative
temperatures are never compound 10 degC 5 K → error: temperatures cannot be compound
more than 15 significant digits 1234567890123456 m → error: has too many digits

More from the author

One or more parts separated by whitespace; each part is a number, optional whitespace, then a unit. The unit runs until the next digit, so two-word units ("fl oz", "sq ft", "US gallon") work.

- Numbers are ASCII digits with an optional `.` fraction. Thousands separators are allowed only as strict groups of three (`1,200`, `12,345,678`); `1,20` is refused rather than guessed at, because in half the world it means 1.20. At most 15 significant digits and 15 decimal places, as in `units.convert`. - A leading `-` is allowed on the first number only, and negates the whole quantity: `-5 ft 3 in` is -63 in. - The value comes back normalised: commas removed, leading zeros and trailing fractional zeros trimmed, and `-0` written as `0`.

## Units

A unit is matched first as a `units.convert` symbol (`kg`, `gal_us`, `degC`), then as an alias from `data/aliases.json` (`kilograms`, `lbs`, `feet`, `'`, `°C`, `sq ft`), then both again ignoring ASCII letter case (`KG`, `Feet`). Only ASCII letters fold, so the answer is the same in every language.

Words that mean different things in different places are refused with the choices spelled out, instead of silently picking one: `gal`, `pint`, `quart`, `fl oz` (US or imperial), `cup`, `ton` (tonne, short or long) and `Cal`/`calorie` (a food Calorie is a kilocalorie). The case-insensitive step never hides this: `CAL` is ambiguous even though `cal` is a symbol. A bare `oz` is the avoirdupois ounce of mass; say `US fl oz` for volume.

Every alias names a symbol that `units.convert` defines; the implementation reads `units.convert`'s own table, so the two cannot drift apart.

## Compound quantities

`12 ft 6 in` is returned as `150 in`: the parts are summed exactly into the last, smallest unit. For that to be exact, the rules are strict:

- every part has the same dimension, and temperatures are never compound; - units run from largest to smallest and never repeat; - only the last number may have a fraction (`5 ft 11.5 in`, not `5.5 ft 3 in`); - each earlier unit must be a whole number of the last one, so `1 ft 2 cm` (30.48 cm per foot) is refused rather than approximated.

Files

PathBytes
README.md2,421
data/aliases.json11,467
impl/python.py7,089
impl/rust.rs9,215
impl/typescript.ts7,482
vectors.json4,148