Functional Weave
Code in Rust

units.parse-quantity@1.0.0

impl/typescript.ts

7,482 bytes · the TypeScript 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.

import { UNITS, type UnitDefinition } from "./units_convert_data.ts";  ← units.convert’s rule data (^1.0.0) · built alongside by fune
import { UNIT_ALIASES } from "./units_parse_quantity_data.ts";  ← this capability’s own data, compiled from data/aliases.json into the same file by fune build
import { type Quantity } from "./units_parse_quantity_types.ts";

const MAX_DIGITS = 15;

// Explicit sets rather than regex classes: \s and toLowerCase() differ between
// languages on non-ASCII input, and these must not.
function isSpace(c: string): boolean {
  return c === " " || c === "\t" || c === "\n" || c === "\r";
}
function isDigit(c: string): boolean {
  return c >= "0" && c <= "9";
}
function asciiLower(s: string): string {
  return s.replace(/[A-Z]/g, (c) => c.toLowerCase());
}
function allDigits(s: string): boolean {
  return s.length > 0 && [...s].every(isDigit);
}
function trimSpace(s: string): string {
  let a = 0;
  let b = s.length;
  while (a < b && isSpace(s[a])) a++;
  while (b > a && isSpace(s[b - 1])) b--;
  return s.slice(a, b);
}

function wellGrouped(whole: string): boolean {
  if (!whole.includes(",")) return allDigits(whole);
  const groups = whole.split(",");
  return groups[0].length >= 1 && groups[0].length <= 3 && allDigits(groups[0]) && groups.slice(1).every((g) => g.length === 3 && allDigits(g));
}

type Part = { written: string; whole: bigint; fraction: string; unit: UnitDefinition };

function parseNumber(written: string): { whole: bigint; fraction: string } {
  const pieces = written.split(".");
  const plain = written.split(",").join("");
  const plainPieces = plain.split(".");
  const plainOk = plainPieces.length <= 2 && allDigits(plainPieces[0]) && (plainPieces.length === 1 || allDigits(plainPieces[1]));
  const ok = pieces.length <= 2 && wellGrouped(pieces[0]) && (pieces.length === 1 || allDigits(pieces[1]));
  if (!ok) {
    if (written.includes(",") && plainOk) throw new RangeError(`bad thousands grouping in "${written}"`);
    throw new RangeError(`malformed number "${written}"`);
  }
  const whole = plainPieces[0].replace(/^0+/, "");
  const fraction = (plainPieces[1] ?? "").replace(/0+$/, "");
  if ((whole + fraction).replace(/^0+/, "").length > MAX_DIGITS || fraction.length > MAX_DIGITS) {
    throw new RangeError(`number "${written}" has too many digits: at most 15 significant digits and 15 decimal places`);
  }
  return { whole: BigInt(whole === "" ? "0" : whole), fraction };
}

function unitBySymbol(symbol: string): UnitDefinition {
  const found = UNITS.find((u) => u.symbol === symbol);
  // Only reachable if the alias table and units.convert drift apart.
  if (!found) throw new RangeError(`alias table names unit "${symbol}", which units.convert does not define`);
  return found;
}

function resolveUnit(text: string): UnitDefinition {
  const exactUnit = UNITS.find((u) => u.symbol === text);
  if (exactUnit) return exactUnit;
  const exactAlias = UNIT_ALIASES.find((a) => a.alias === text);
  if (exactAlias) {
    if (exactAlias.symbol === null) throw new RangeError(`ambiguous unit "${text}": use ${exactAlias.choices}`);
    return unitBySymbol(exactAlias.symbol);
  }
  // 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.
  const lower = asciiLower(text);
  const ambiguous = UNIT_ALIASES.find((a) => a.symbol === null && asciiLower(a.alias) === lower);
  if (ambiguous) throw new RangeError(`ambiguous unit "${text}": use ${ambiguous.choices}`);
  const symbols: string[] = [];
  for (const u of UNITS) if (asciiLower(u.symbol) === lower && !symbols.includes(u.symbol)) symbols.push(u.symbol);
  for (const a of UNIT_ALIASES) if (a.symbol !== null && asciiLower(a.alias) === lower && !symbols.includes(a.symbol)) symbols.push(a.symbol);
  if (symbols.length === 1) return unitBySymbol(symbols[0]);
  if (symbols.length > 1) throw new RangeError(`ambiguous unit "${text}": letter case matters, use ${symbols.join(" or ")}`);
  throw new RangeError(`unknown unit "${text}"`);
}

function format(negative: boolean, whole: bigint, fraction: string): string {
  const text = whole.toString() + (fraction === "" ? "" : "." + fraction);
  return negative && (whole !== 0n || fraction !== "") ? "-" + text : text;
}

/**
 * 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.
 */
export function parseQuantity(text: string): Quantity {
  if (typeof text !== "string") throw new TypeError("quantity text must be a string");
  const src = trimSpace(text);
  if (src === "") throw new RangeError("quantity text is empty");

  let negative = false;
  const parts: Part[] = [];
  let i = 0;
  while (i < src.length) {
    while (i < src.length && isSpace(src[i])) i++;
    const start = i;
    if (src[i] === "-") {
      if (parts.length > 0) throw new RangeError(`only the first number may be negative: "${src}"`);
      negative = true;
      i++;
    }
    const numberStart = i;
    while (i < src.length && (isDigit(src[i]) || src[i] === "," || src[i] === ".")) i++;
    const written = src.slice(numberStart, i);
    if (written === "") throw new RangeError(`expected a number at "${src.slice(start)}"`);
    const { whole, fraction } = parseNumber(written);
    const unitStart = i;
    while (i < src.length && !isDigit(src[i]) && src[i] !== "-") i++;
    const unitText = trimSpace(src.slice(unitStart, i))
      .split(/[ \t\n\r]+/)
      .join(" ");
    if (unitText === "") throw new RangeError(`missing unit after "${written}"`);
    parts.push({ written, whole, fraction, unit: resolveUnit(unitText) });
  }

  const last = parts[parts.length - 1];
  if (parts.length === 1) {
    return { value: format(negative, last.whole, last.fraction), unit: last.unit.symbol, dimension: last.unit.dimension };
  }

  for (let k = 1; k < parts.length; k++) {
    const prev = parts[k - 1].unit;
    const cur = parts[k].unit;
    if (cur.dimension !== prev.dimension) {
      throw new RangeError(`cannot combine ${prev.symbol} (${prev.dimension}) and ${cur.symbol} (${cur.dimension}) in one quantity`);
    }
    if (cur.dimension === "temperature") throw new RangeError(`temperatures cannot be compound: "${src}"`);
    if (cur.symbol === prev.symbol) throw new RangeError(`unit "${cur.symbol}" appears twice in "${src}"`);
    const curSize = BigInt(cur.factorNumerator) * BigInt(prev.factorDenominator);
    const prevSize = BigInt(prev.factorNumerator) * BigInt(cur.factorDenominator);
    if (curSize >= prevSize) throw new RangeError(`units must run from largest to smallest: "${prev.symbol}" cannot come before "${cur.symbol}"`);
  }

  let total = last.whole;
  for (const part of parts.slice(0, -1)) {
    if (part.fraction !== "") {
      throw new RangeError(`only the last number of a compound quantity may have a fraction: "${src}"`);
    }
    const num = BigInt(part.unit.factorNumerator) * BigInt(last.unit.factorDenominator);
    const den = BigInt(part.unit.factorDenominator) * BigInt(last.unit.factorNumerator);
    if (num % den !== 0n) {
      throw new RangeError(`cannot combine ${part.unit.symbol} and ${last.unit.symbol} exactly: 1 ${part.unit.symbol} is not a whole number of ${last.unit.symbol}`);
    }
    total += part.whole * (num / den);
  }
  return { value: format(negative, total, last.fraction), unit: last.unit.symbol, dimension: last.unit.dimension };
}