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 };
}