unit conversion using Number union

This commit is contained in:
Emil Lerch 2026-07-28 07:45:37 -07:00
parent 4eda2f62e8
commit df69b070ef
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 942 additions and 241 deletions

View file

@ -734,10 +734,14 @@ pub const UnitDef = struct {
name: []const u8, // canonical name (e.g., "km")
aliases: []const []const u8, // alternatives (e.g., "kilometer", "kilometers")
category: UnitCategory,
/// Conversion to base unit: base = value * to_base_factor + to_base_offset
/// For most units offset = 0. Temperature is the only category that uses it.
/// base = value * to_base_factor + to_base_offset
/// DERIVED at comptime from the exact text below; never written by hand.
to_base_factor: f64,
to_base_offset: f64 = 0,
/// The factor as exact text (decimal or `p/q`), or null when the factor is
/// irrational and no exact form exists.
factor_text: ?[]const u8 = null,
offset_text: ?[]const u8 = null,
pub fn toBase(self: UnitDef, value: f64) f64 {
return value * self.to_base_factor + self.to_base_offset;
@ -750,6 +754,20 @@ pub const UnitDef = struct {
};
```
**Factors are declared as exact text; the f64 is derived.** Tables are written
with `define(.length, &.{ .{ .name = "in", .factor = "0.0254" }, ... })`, and
`define` computes the f64 fields at comptime. This is the same single-source-of-
truth argument as `fromBase` below, applied one level up: an inch is exactly
127/5000 m, and if the exact and approximate forms were both hand-written they
could disagree.
Some factors need the `p/q` form because they have no terminating decimal
expansion: Fahrenheit scales by exactly 5/9, a knot is exactly 1852/3600 m/s, a
torr exactly 101325/760 Pa. A few units are defined in terms of pi (the angle
units, and the parsec at 648000/pi au) and so have NO exact rational factor;
those declare `approx_factor` instead and their conversions fall back to the
inexact tier.
**Only the to-base mapping is stored; the reverse is derived.** An earlier draft
of this design gave each unit four fields (`to_base_factor`/`to_base_offset`
plus `from_base_factor`/`from_base_offset`). That was changed because it lets a

View file

@ -71,7 +71,7 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
- **FR-4.2**: Support unit conversions within expressions: `5 kg + 3 lb` evaluates in the left-hand unit. NOT YET IMPLEMENTED (needs unit-aware tokenizing and evaluation, unlike FR-4.1 which only needs the `to` keyword as a trigger).
- **FR-4.3**: Length: nm, um, mm, cm, m, km, in, ft, yd, mi, nmi (nautical mile), ly, au, pc. Base unit: m.
- **FR-4.4**: Mass/Weight: mg, g, kg, t (tonne), oz, lb, st (stone), ton (US short), lt (long ton). Base unit: kg.
- **FR-4.5**: Temperature: C (Celsius), F (Fahrenheit), K (Kelvin), R (Rankine). Base unit: C. These are the only affine conversions (they need an offset, not just a factor).
- **FR-4.5**: Temperature: C (Celsius), F (Fahrenheit), K (Kelvin), R (Rankine). Base unit: C. These are the only affine conversions (they need an offset, not just a factor). Fahrenheit and Rankine scale by exactly 5/9, which has no terminating decimal form.
- **FR-4.6**: Time: ns, us, ms, s, min, h, d, wk, yr. Base unit: s. A year is the Julian year (365.25 days), the usual unit-conversion convention.
- **FR-4.7**: Digital storage: bit, B, and both decimal (kB, MB, GB, TB, PB) and binary (KiB, MiB, GiB, TiB, PiB) prefixes, kept distinct. Base unit: B.
- **FR-4.8**: Speed: m/s, km/h, mph, ft/s, kn (knots), c (speed of light). Base unit: m/s.
@ -87,7 +87,8 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
- **FR-4.18**: Unit names resolve by canonical name or alias (long forms and plurals). An exact case-sensitive match is tried first so case-distinguished units keep their meaning (`K` is Kelvin, `B` is byte, `kB` is not `KiB`); a case-insensitive pass runs only as a fallback, so forgiving input like `KM` or `Celsius` still works.
- **FR-4.19**: Converting between units of different categories is an error (`IncompatibleUnits`); an unrecognized unit name is an error (`UnknownUnit`).
- **FR-4.20**: Conversion detection must not change the meaning of ordinary expressions. Only a standalone whitespace-delimited `to` or `in` triggers it, and a unit name is only recognized at a token boundary (so the trailing `s` of `smoots` is not read as seconds, and the `in` inside `min` is not read as a separator). Without a separator, `e` remains Euler's number rather than becoming a unit.
- **FR-4.21**: Because `in` is both a separator and the canonical name for inches, candidate separators are tried last-to-first and the first reading that yields a valid conversion wins. This must resolve all of: `5 in in cm` (5 inches to cm), `100 mm in in` (mm to inches), `1 in in in` (identity), and `60 min in h` (minutes to hours).
- **FR-4.21**: Because `in` is both a separator and the canonical name for inches, candidate separators are tried last-to-first and the first reading that yields a valid conversion wins. This must resolve all of: `5 in in cm` (5 inches to cm), `100 mm in in` (mm to inches), `1 in in in` (identity), and `60 min in h` (minutes to hours). The `convert` subcommand must accept the same separators as bare expressions.
- **FR-4.22**: Conversions must be exact wherever the units allow it. Conversion factors are declared as exact text (decimal or `p/q`) and the f64 forms derived from them, so `12 in to ft` is exactly `1`, `98.6 F` is exactly `37 C`, and `3.6 km/h` is exactly `1 m/s`. Units defined in terms of pi (the angle units, and the parsec) have no exact rational factor and fall back to the inexact tier; a result reports whether its conversion was lossless.
### FR-5: Financial Mode

View file

@ -238,23 +238,45 @@ because that is the precision we can justify; see design.md 2.7.8 for why f128 i
NOT the upgrade path), and any change to programmer mode's `u128` semantics or the
IEEE 754 float view.
### Task 2.0e: Exact unit conversion factors [NOT STARTED]
Discovered while implementing 2.0b: making the evaluator exact does NOT fix
### Task 2.0e: Exact unit conversion factors [DONE]
Discovered while implementing 2.0b: making the evaluator exact did NOT fix
`12 in in ft` = `0.9999999999999998`, because unit conversion never goes through
the evaluator. `UnitDef.to_base_factor` is an `f64`, so `0.0254` is already the
binary approximation before `convertUnits` does its own f64 multiply and divide.
the evaluator. `UnitDef.to_base_factor` was an `f64`, so `0.0254` was already the
binary approximation before `convertUnits` did its own multiply and divide.
- Declare factors as exact decimal text (e.g. `"0.0254"`) so they can be parsed
into rationals; an inch is exactly `127/5000` m and a foot exactly `381/1250` m,
which makes `12 in to ft` exactly `1`.
- Provide an exact conversion path returning `Number`, keeping the f64 path for
callers that want it.
- Mechanical across ~100 table entries, hence its own commit: the existing
invariant tests (every unit and unit pair round-trips) become far stronger when
the round trip is exact rather than within a tolerance.
- Non-terminating conversions (`100 km to mi` = `781250/12573`) still render as
rounded decimals, but from an exact value, and the exact fraction becomes
available for display.
- Factors and offsets are now written as EXACT TEXT and the f64 fields are
DERIVED from them at comptime by `define`. One source of truth per unit, so the
exact and approximate forms cannot drift apart the way two hand-written fields
would. (`@setEvalBranchQuota` is needed: `parseFloat` is loop-heavy at comptime.)
- `Rational.parse` accepts `p/q` as well as decimals, because several exact
factors have no terminating decimal form: Fahrenheit scales by exactly 5/9, a
knot is exactly 1852/3600 m/s, a torr exactly 101325/760 Pa.
- `convertExactUnits(allocator, value: Number, from, to) -> Number` does the
conversion in the exact tier, falling back to floats when either unit is
defined via pi (the angle units and the parsec) or when the value is already
inexact. The result's `exact` tag therefore reports whether the conversion was
lossless.
- Verified: `12 in = 1 ft`, `98.6 F = 37 C`, `3.6 km/h = 1 m/s`,
`1 kn = 1.852 km/h`, `1 acre = 43,560 ft2`, `1 atm = 760 torr`,
`1 GiB = 1,024 MiB`, `-40 C = -40 F`, `0 C = 273.15 K`.
- Non-terminating conversions stay exact as VALUES: `100 km to mi` is exactly
`781250/12573`, rendered as a rounded decimal and flagged approximate, with the
exact fraction available.
- The invariant test is much stronger now: every unit pair within a category
round-trips EXACTLY rather than within a tolerance, which catches a mistyped
table entry that a tolerance would hide.
- CLI and TUI (both the standard-mode `to`/`in` path and convert mode) all use
the exact path. `App.conv_value` became a `Number` so the live convert view
matches the CLI.
- ALSO FIXED, found by smoke-testing the whole matrix: the `convert` subcommand
only accepted `to` as a separator, so `convert 1 acre in ft2` and
`convert 98.6F in C` failed. It now handles both separators with the same
backtracking approach as `units.parseRequest`, validating candidate readings by
resolving the unit names. This also required whole-token numeric validation
(`isFullyNumeric`): the old first-character check accepted `98.6F` as a bare
value, which then failed in the number parser instead of being retried as a
glued value+unit.
- 596 tests pass (was 564). Coverage 99.40%.
### Task 2.1: Implement struct DSL tokenizer and parser
- Create `engine/src/struct_layout.zig`

View file

@ -135,6 +135,23 @@ pub const Rational = struct {
// -- Parsing --
/// Parse exact numeric text, accepting either a decimal literal or a
/// fraction: `"0.0254"`, `"-1.25e3"`, `"5/9"`, `"463/900"`.
///
/// The fraction form exists because several exact conversion factors have no
/// terminating decimal expansion: Fahrenheit's is exactly 5/9 and a knot's is
/// exactly 463/900. Writing those as rounded decimals would defeat the point.
pub fn parse(allocator: Allocator, text: []const u8) Error!Rational {
const slash = std.mem.indexOfScalar(u8, text, '/') orelse
return parseDecimal(allocator, text);
var numerator = try parseDecimal(allocator, text[0..slash]);
defer numerator.deinit();
var denominator = try parseDecimal(allocator, text[slash + 1 ..]);
defer denominator.deinit();
return div(allocator, numerator, denominator);
}
/// Parse a decimal numeric literal exactly.
///
/// Accepts an optional sign, digits with an optional fractional part, and an
@ -1596,3 +1613,52 @@ test "OOM safety: factorial" {
test "OOM safety: decimal and fraction rendering" {
try oomSweep(bodyRendering);
}
test "parse: accepts decimal text" {
var a = try Rational.parse(alloc, "0.0254");
defer a.deinit();
try expectFrac("127/5000", a);
}
test "parse: accepts fraction text" {
var a = try Rational.parse(alloc, "5/9");
defer a.deinit();
try expectFrac("5/9", a);
var b = try Rational.parse(alloc, "463/900");
defer b.deinit();
try expectFrac("463/900", b);
}
test "parse: fraction text is reduced" {
var a = try Rational.parse(alloc, "10/4");
defer a.deinit();
try expectFrac("5/2", a);
}
test "parse: fraction reduces to lowest terms" {
// 101325/760 is the exact torr factor; gcd is 5.
var a = try Rational.parse(alloc, "101325/760");
defer a.deinit();
try expectFrac("20265/152", a);
}
test "parse: negative fractions" {
var a = try Rational.parse(alloc, "-160/9");
defer a.deinit();
try expectFrac("-160/9", a);
var b = try Rational.parse(alloc, "160/-9");
defer b.deinit();
try expectFrac("-160/9", b);
}
test "parse: fraction with a zero denominator errors" {
try testing.expectError(Error.DivisionByZero, Rational.parse(alloc, "1/0"));
}
test "parse: malformed fractions error" {
try testing.expectError(Error.InvalidNumber, Rational.parse(alloc, "1/"));
try testing.expectError(Error.InvalidNumber, Rational.parse(alloc, "/2"));
try testing.expectError(Error.InvalidNumber, Rational.parse(alloc, "a/b"));
}

View file

@ -21,6 +21,10 @@
const std = @import("std");
const types = @import("types.zig");
const CalcError = types.CalcError;
const rational_mod = @import("rational.zig");
const Rational = rational_mod.Rational;
const number_mod = @import("number.zig");
const Number = number_mod.Number;
pub const UnitCategory = enum {
length,
@ -80,8 +84,16 @@ pub const UnitDef = struct {
aliases: []const []const u8 = &.{},
category: UnitCategory,
/// base = value * to_base_factor + to_base_offset
///
/// Derived at comptime from `factor_text` / `offset_text`, which are the
/// single source of truth. Never write these directly in a table.
to_base_factor: f64,
to_base_offset: f64 = 0,
/// The factor as exact text (decimal or `p/q`), or null when the factor is
/// irrational and no exact form exists (the pi-based angle units).
factor_text: ?[]const u8 = null,
/// The offset as exact text. Null only when the factor is also inexact.
offset_text: ?[]const u8 = null,
/// Convert a value in this unit to the category's base unit.
pub fn toBase(self: UnitDef, value: f64) f64 {
@ -97,8 +109,68 @@ pub const UnitDef = struct {
pub fn isLinear(self: UnitDef) bool {
return self.to_base_offset == 0;
}
/// True if this unit's mapping to its base can be represented exactly.
/// False for units defined in terms of pi.
pub fn isExact(self: UnitDef) bool {
return self.factor_text != null;
}
};
/// A table entry. Factors and offsets are written as exact text; the f64 forms
/// are computed from them at comptime by `define`, so the two representations
/// cannot drift apart the way two hand-written fields would.
const UnitSpec = struct {
name: []const u8,
aliases: []const []const u8 = &.{},
/// Exact factor as a decimal or `p/q`. Null means the factor is irrational
/// (pi-based) and only `approx_factor` applies.
factor: ?[]const u8 = null,
/// Exact offset as a decimal or `p/q`. Defaults to zero.
offset: []const u8 = "0",
/// The f64 factor, supplied ONLY for irrational factors where no exact text
/// exists. Otherwise it is derived from `factor`.
approx_factor: ?f64 = null,
};
/// Build a unit table, deriving the f64 fields from the exact text.
fn define(comptime category: UnitCategory, comptime specs: []const UnitSpec) [specs.len]UnitDef {
// parseFloat is loop-heavy, and this runs it once per factor and offset.
@setEvalBranchQuota(200_000);
var out: [specs.len]UnitDef = undefined;
for (specs, 0..) |spec, i| {
const factor: f64 = if (spec.factor) |text|
parseExactAtComptime(text)
else
spec.approx_factor orelse @compileError("unit '" ++ spec.name ++ "' has neither an exact nor an approximate factor");
out[i] = .{
.name = spec.name,
.aliases = spec.aliases,
.category = category,
.to_base_factor = factor,
.to_base_offset = parseExactAtComptime(spec.offset),
.factor_text = spec.factor,
.offset_text = if (spec.factor == null) null else spec.offset,
};
}
return out;
}
/// Evaluate exact factor text to the nearest f64 at comptime.
/// Handles the `p/q` form, which `parseFloat` alone cannot.
fn parseExactAtComptime(comptime text: []const u8) f64 {
if (std.mem.indexOfScalar(u8, text, '/')) |slash| {
const num = std.fmt.parseFloat(f64, text[0..slash]) catch
@compileError("bad numerator in '" ++ text ++ "'");
const den = std.fmt.parseFloat(f64, text[slash + 1 ..]) catch
@compileError("bad denominator in '" ++ text ++ "'");
return num / den;
}
return std.fmt.parseFloat(f64, text) catch
@compileError("bad numeric text '" ++ text ++ "'");
}
/// Result of a conversion, including metadata for display.
pub const ConvertResult = struct {
value: f64,
@ -113,161 +185,175 @@ pub const ConvertResult = struct {
// -- Unit tables --
//
// Each table's base unit has to_base_factor = 1 and no offset.
// Factors and offsets are written as EXACT text (a decimal, or `p/q` where the
// value has no terminating decimal form). The f64 fields are derived from that
// text at comptime by `define`, so there is one source of truth per unit and the
// exact and approximate forms cannot drift apart.
//
// Each table's base unit has factor "1" and no offset.
const length_units = [_]UnitDef{
.{ .name = "nm", .aliases = &.{ "nanometer", "nanometers", "nanometre", "nanometres" }, .category = .length, .to_base_factor = 1e-9 },
.{ .name = "um", .aliases = &.{ "micrometer", "micrometers", "micron", "microns" }, .category = .length, .to_base_factor = 1e-6 },
.{ .name = "mm", .aliases = &.{ "millimeter", "millimeters", "millimetre", "millimetres" }, .category = .length, .to_base_factor = 0.001 },
.{ .name = "cm", .aliases = &.{ "centimeter", "centimeters", "centimetre", "centimetres" }, .category = .length, .to_base_factor = 0.01 },
.{ .name = "m", .aliases = &.{ "meter", "meters", "metre", "metres" }, .category = .length, .to_base_factor = 1.0 },
.{ .name = "km", .aliases = &.{ "kilometer", "kilometers", "kilometre", "kilometres" }, .category = .length, .to_base_factor = 1000.0 },
.{ .name = "in", .aliases = &.{ "inch", "inches" }, .category = .length, .to_base_factor = 0.0254 },
.{ .name = "ft", .aliases = &.{ "foot", "feet" }, .category = .length, .to_base_factor = 0.3048 },
.{ .name = "yd", .aliases = &.{ "yard", "yards" }, .category = .length, .to_base_factor = 0.9144 },
.{ .name = "mi", .aliases = &.{ "mile", "miles" }, .category = .length, .to_base_factor = 1609.344 },
.{ .name = "nmi", .aliases = &.{ "nauticalmile", "nauticalmiles" }, .category = .length, .to_base_factor = 1852.0 },
.{ .name = "ly", .aliases = &.{ "lightyear", "lightyears" }, .category = .length, .to_base_factor = 9.4607304725808e15 },
.{ .name = "au", .aliases = &.{ "astronomicalunit", "astronomicalunits" }, .category = .length, .to_base_factor = 1.495978707e11 },
.{ .name = "pc", .aliases = &.{ "parsec", "parsecs" }, .category = .length, .to_base_factor = 3.0856775814913673e16 },
};
const length_units = define(.length, &.{
.{ .name = "nm", .aliases = &.{ "nanometer", "nanometers", "nanometre", "nanometres" }, .factor = "1e-9" },
.{ .name = "um", .aliases = &.{ "micrometer", "micrometers", "micron", "microns" }, .factor = "1e-6" },
.{ .name = "mm", .aliases = &.{ "millimeter", "millimeters", "millimetre", "millimetres" }, .factor = "0.001" },
.{ .name = "cm", .aliases = &.{ "centimeter", "centimeters", "centimetre", "centimetres" }, .factor = "0.01" },
.{ .name = "m", .aliases = &.{ "meter", "meters", "metre", "metres" }, .factor = "1" },
.{ .name = "km", .aliases = &.{ "kilometer", "kilometers", "kilometre", "kilometres" }, .factor = "1000" },
.{ .name = "in", .aliases = &.{ "inch", "inches" }, .factor = "0.0254" },
.{ .name = "ft", .aliases = &.{ "foot", "feet" }, .factor = "0.3048" },
.{ .name = "yd", .aliases = &.{ "yard", "yards" }, .factor = "0.9144" },
.{ .name = "mi", .aliases = &.{ "mile", "miles" }, .factor = "1609.344" },
.{ .name = "nmi", .aliases = &.{ "nauticalmile", "nauticalmiles" }, .factor = "1852" },
.{ .name = "ly", .aliases = &.{ "lightyear", "lightyears" }, .factor = "9460730472580800" },
.{ .name = "au", .aliases = &.{ "astronomicalunit", "astronomicalunits" }, .factor = "149597870700" },
// A parsec is exactly 648000/pi au, so it has no exact decimal form.
.{ .name = "pc", .aliases = &.{ "parsec", "parsecs" }, .approx_factor = 3.0856775814913673e16 },
});
const mass_units = [_]UnitDef{
.{ .name = "mg", .aliases = &.{ "milligram", "milligrams" }, .category = .mass, .to_base_factor = 1e-6 },
.{ .name = "g", .aliases = &.{ "gram", "grams" }, .category = .mass, .to_base_factor = 0.001 },
.{ .name = "kg", .aliases = &.{ "kilogram", "kilograms" }, .category = .mass, .to_base_factor = 1.0 },
.{ .name = "t", .aliases = &.{ "tonne", "tonnes", "metricton", "metrictons" }, .category = .mass, .to_base_factor = 1000.0 },
.{ .name = "oz", .aliases = &.{ "ounce", "ounces" }, .category = .mass, .to_base_factor = 0.028349523125 },
.{ .name = "lb", .aliases = &.{ "lbs", "pound", "pounds" }, .category = .mass, .to_base_factor = 0.45359237 },
.{ .name = "st", .aliases = &.{ "stone", "stones" }, .category = .mass, .to_base_factor = 6.35029318 },
.{ .name = "ton", .aliases = &.{ "shortton", "shorttons" }, .category = .mass, .to_base_factor = 907.18474 },
.{ .name = "lt", .aliases = &.{ "longton", "longtons" }, .category = .mass, .to_base_factor = 1016.0469088 },
};
const mass_units = define(.mass, &.{
.{ .name = "mg", .aliases = &.{ "milligram", "milligrams" }, .factor = "1e-6" },
.{ .name = "g", .aliases = &.{ "gram", "grams" }, .factor = "0.001" },
.{ .name = "kg", .aliases = &.{ "kilogram", "kilograms" }, .factor = "1" },
.{ .name = "t", .aliases = &.{ "tonne", "tonnes", "metricton", "metrictons" }, .factor = "1000" },
.{ .name = "oz", .aliases = &.{ "ounce", "ounces" }, .factor = "0.028349523125" },
.{ .name = "lb", .aliases = &.{ "lbs", "pound", "pounds" }, .factor = "0.45359237" },
.{ .name = "st", .aliases = &.{ "stone", "stones" }, .factor = "6.35029318" },
.{ .name = "ton", .aliases = &.{ "shortton", "shorttons" }, .factor = "907.18474" },
.{ .name = "lt", .aliases = &.{ "longton", "longtons" }, .factor = "1016.0469088" },
});
// Base: Celsius. These are the only units in the engine that need an offset.
const temperature_units = [_]UnitDef{
.{ .name = "C", .aliases = &.{ "celsius", "centigrade" }, .category = .temperature, .to_base_factor = 1.0 },
.{ .name = "F", .aliases = &.{"fahrenheit"}, .category = .temperature, .to_base_factor = 5.0 / 9.0, .to_base_offset = -160.0 / 9.0 },
.{ .name = "K", .aliases = &.{"kelvin"}, .category = .temperature, .to_base_factor = 1.0, .to_base_offset = -273.15 },
.{ .name = "R", .aliases = &.{ "rankine", "Ra" }, .category = .temperature, .to_base_factor = 5.0 / 9.0, .to_base_offset = -273.15 },
};
// Fahrenheit and Rankine scale by exactly 5/9, which has no terminating decimal
// form, hence the fraction notation.
const temperature_units = define(.temperature, &.{
.{ .name = "C", .aliases = &.{ "celsius", "centigrade" }, .factor = "1" },
.{ .name = "F", .aliases = &.{"fahrenheit"}, .factor = "5/9", .offset = "-160/9" },
.{ .name = "K", .aliases = &.{"kelvin"}, .factor = "1", .offset = "-273.15" },
.{ .name = "R", .aliases = &.{ "rankine", "Ra" }, .factor = "5/9", .offset = "-273.15" },
});
const time_units = [_]UnitDef{
.{ .name = "ns", .aliases = &.{ "nanosecond", "nanoseconds" }, .category = .time, .to_base_factor = 1e-9 },
.{ .name = "us", .aliases = &.{ "microsecond", "microseconds" }, .category = .time, .to_base_factor = 1e-6 },
.{ .name = "ms", .aliases = &.{ "millisecond", "milliseconds" }, .category = .time, .to_base_factor = 0.001 },
.{ .name = "s", .aliases = &.{ "sec", "secs", "second", "seconds" }, .category = .time, .to_base_factor = 1.0 },
.{ .name = "min", .aliases = &.{ "minute", "minutes" }, .category = .time, .to_base_factor = 60.0 },
.{ .name = "h", .aliases = &.{ "hr", "hrs", "hour", "hours" }, .category = .time, .to_base_factor = 3600.0 },
.{ .name = "d", .aliases = &.{ "day", "days" }, .category = .time, .to_base_factor = 86400.0 },
.{ .name = "wk", .aliases = &.{ "week", "weeks" }, .category = .time, .to_base_factor = 604800.0 },
const time_units = define(.time, &.{
.{ .name = "ns", .aliases = &.{ "nanosecond", "nanoseconds" }, .factor = "1e-9" },
.{ .name = "us", .aliases = &.{ "microsecond", "microseconds" }, .factor = "1e-6" },
.{ .name = "ms", .aliases = &.{ "millisecond", "milliseconds" }, .factor = "0.001" },
.{ .name = "s", .aliases = &.{ "sec", "secs", "second", "seconds" }, .factor = "1" },
.{ .name = "min", .aliases = &.{ "minute", "minutes" }, .factor = "60" },
.{ .name = "h", .aliases = &.{ "hr", "hrs", "hour", "hours" }, .factor = "3600" },
.{ .name = "d", .aliases = &.{ "day", "days" }, .factor = "86400" },
.{ .name = "wk", .aliases = &.{ "week", "weeks" }, .factor = "604800" },
// Julian year, the usual convention for "a year" in unit conversion.
.{ .name = "yr", .aliases = &.{ "year", "years" }, .category = .time, .to_base_factor = 31557600.0 },
};
.{ .name = "yr", .aliases = &.{ "year", "years" }, .factor = "31557600" },
});
// Base: byte. Decimal (kB) and binary (KiB) prefixes are both provided.
const digital_storage_units = [_]UnitDef{
.{ .name = "bit", .aliases = &.{"bits"}, .category = .digital_storage, .to_base_factor = 0.125 },
.{ .name = "B", .aliases = &.{ "byte", "bytes" }, .category = .digital_storage, .to_base_factor = 1.0 },
.{ .name = "kB", .aliases = &.{ "kilobyte", "kilobytes" }, .category = .digital_storage, .to_base_factor = 1e3 },
.{ .name = "MB", .aliases = &.{ "megabyte", "megabytes" }, .category = .digital_storage, .to_base_factor = 1e6 },
.{ .name = "GB", .aliases = &.{ "gigabyte", "gigabytes" }, .category = .digital_storage, .to_base_factor = 1e9 },
.{ .name = "TB", .aliases = &.{ "terabyte", "terabytes" }, .category = .digital_storage, .to_base_factor = 1e12 },
.{ .name = "PB", .aliases = &.{ "petabyte", "petabytes" }, .category = .digital_storage, .to_base_factor = 1e15 },
.{ .name = "KiB", .aliases = &.{ "kibibyte", "kibibytes" }, .category = .digital_storage, .to_base_factor = 1024.0 },
.{ .name = "MiB", .aliases = &.{ "mebibyte", "mebibytes" }, .category = .digital_storage, .to_base_factor = 1048576.0 },
.{ .name = "GiB", .aliases = &.{ "gibibyte", "gibibytes" }, .category = .digital_storage, .to_base_factor = 1073741824.0 },
.{ .name = "TiB", .aliases = &.{ "tebibyte", "tebibytes" }, .category = .digital_storage, .to_base_factor = 1099511627776.0 },
.{ .name = "PiB", .aliases = &.{ "pebibyte", "pebibytes" }, .category = .digital_storage, .to_base_factor = 1125899906842624.0 },
};
const digital_storage_units = define(.digital_storage, &.{
.{ .name = "bit", .aliases = &.{"bits"}, .factor = "0.125" },
.{ .name = "B", .aliases = &.{ "byte", "bytes" }, .factor = "1" },
.{ .name = "kB", .aliases = &.{ "kilobyte", "kilobytes" }, .factor = "1e3" },
.{ .name = "MB", .aliases = &.{ "megabyte", "megabytes" }, .factor = "1e6" },
.{ .name = "GB", .aliases = &.{ "gigabyte", "gigabytes" }, .factor = "1e9" },
.{ .name = "TB", .aliases = &.{ "terabyte", "terabytes" }, .factor = "1e12" },
.{ .name = "PB", .aliases = &.{ "petabyte", "petabytes" }, .factor = "1e15" },
.{ .name = "KiB", .aliases = &.{ "kibibyte", "kibibytes" }, .factor = "1024" },
.{ .name = "MiB", .aliases = &.{ "mebibyte", "mebibytes" }, .factor = "1048576" },
.{ .name = "GiB", .aliases = &.{ "gibibyte", "gibibytes" }, .factor = "1073741824" },
.{ .name = "TiB", .aliases = &.{ "tebibyte", "tebibytes" }, .factor = "1099511627776" },
.{ .name = "PiB", .aliases = &.{ "pebibyte", "pebibytes" }, .factor = "1125899906842624" },
});
const speed_units = [_]UnitDef{
.{ .name = "m/s", .aliases = &.{ "mps", "meterpersecond", "meterspersecond" }, .category = .speed, .to_base_factor = 1.0 },
.{ .name = "km/h", .aliases = &.{ "kph", "kmh", "kilometerperhour", "kilometersperhour" }, .category = .speed, .to_base_factor = 1.0 / 3.6 },
.{ .name = "mph", .aliases = &.{ "mileperhour", "milesperhour" }, .category = .speed, .to_base_factor = 0.44704 },
.{ .name = "ft/s", .aliases = &.{ "fps", "footpersecond", "feetpersecond" }, .category = .speed, .to_base_factor = 0.3048 },
.{ .name = "kn", .aliases = &.{ "knot", "knots" }, .category = .speed, .to_base_factor = 1852.0 / 3600.0 },
.{ .name = "c", .aliases = &.{ "lightspeed", "speedoflight" }, .category = .speed, .to_base_factor = 299792458.0 },
};
// km/h is exactly 5/18 m/s and a knot exactly 463/900 m/s.
const speed_units = define(.speed, &.{
.{ .name = "m/s", .aliases = &.{ "mps", "meterpersecond", "meterspersecond" }, .factor = "1" },
.{ .name = "km/h", .aliases = &.{ "kph", "kmh", "kilometerperhour", "kilometersperhour" }, .factor = "1000/3600" },
.{ .name = "mph", .aliases = &.{ "mileperhour", "milesperhour" }, .factor = "0.44704" },
.{ .name = "ft/s", .aliases = &.{ "fps", "footpersecond", "feetpersecond" }, .factor = "0.3048" },
.{ .name = "kn", .aliases = &.{ "knot", "knots" }, .factor = "1852/3600" },
.{ .name = "c", .aliases = &.{ "lightspeed", "speedoflight" }, .factor = "299792458" },
});
const area_units = [_]UnitDef{
.{ .name = "mm2", .aliases = &.{ "mm^2", "squaremillimeter", "squaremillimeters" }, .category = .area, .to_base_factor = 1e-6 },
.{ .name = "cm2", .aliases = &.{ "cm^2", "squarecentimeter", "squarecentimeters" }, .category = .area, .to_base_factor = 1e-4 },
.{ .name = "m2", .aliases = &.{ "m^2", "squaremeter", "squaremeters" }, .category = .area, .to_base_factor = 1.0 },
.{ .name = "km2", .aliases = &.{ "km^2", "squarekilometer", "squarekilometers" }, .category = .area, .to_base_factor = 1e6 },
.{ .name = "in2", .aliases = &.{ "in^2", "squareinch", "squareinches" }, .category = .area, .to_base_factor = 0.00064516 },
.{ .name = "ft2", .aliases = &.{ "ft^2", "squarefoot", "squarefeet" }, .category = .area, .to_base_factor = 0.09290304 },
.{ .name = "yd2", .aliases = &.{ "yd^2", "squareyard", "squareyards" }, .category = .area, .to_base_factor = 0.83612736 },
.{ .name = "mi2", .aliases = &.{ "mi^2", "squaremile", "squaremiles" }, .category = .area, .to_base_factor = 2589988.110336 },
.{ .name = "ha", .aliases = &.{ "hectare", "hectares" }, .category = .area, .to_base_factor = 10000.0 },
.{ .name = "acre", .aliases = &.{"acres"}, .category = .area, .to_base_factor = 4046.8564224 },
};
const area_units = define(.area, &.{
.{ .name = "mm2", .aliases = &.{ "mm^2", "squaremillimeter", "squaremillimeters" }, .factor = "1e-6" },
.{ .name = "cm2", .aliases = &.{ "cm^2", "squarecentimeter", "squarecentimeters" }, .factor = "1e-4" },
.{ .name = "m2", .aliases = &.{ "m^2", "squaremeter", "squaremeters" }, .factor = "1" },
.{ .name = "km2", .aliases = &.{ "km^2", "squarekilometer", "squarekilometers" }, .factor = "1e6" },
.{ .name = "in2", .aliases = &.{ "in^2", "squareinch", "squareinches" }, .factor = "0.00064516" },
.{ .name = "ft2", .aliases = &.{ "ft^2", "squarefoot", "squarefeet" }, .factor = "0.09290304" },
.{ .name = "yd2", .aliases = &.{ "yd^2", "squareyard", "squareyards" }, .factor = "0.83612736" },
.{ .name = "mi2", .aliases = &.{ "mi^2", "squaremile", "squaremiles" }, .factor = "2589988.110336" },
.{ .name = "ha", .aliases = &.{ "hectare", "hectares" }, .factor = "10000" },
.{ .name = "acre", .aliases = &.{"acres"}, .factor = "4046.8564224" },
});
// Base: liter.
const volume_units = [_]UnitDef{
.{ .name = "mL", .aliases = &.{ "ml", "milliliter", "milliliters" }, .category = .volume, .to_base_factor = 0.001 },
.{ .name = "L", .aliases = &.{ "l", "liter", "liters", "litre", "litres" }, .category = .volume, .to_base_factor = 1.0 },
.{ .name = "m3", .aliases = &.{ "m^3", "cubicmeter", "cubicmeters" }, .category = .volume, .to_base_factor = 1000.0 },
.{ .name = "cm3", .aliases = &.{ "cm^3", "cc", "cubiccentimeter", "cubiccentimeters" }, .category = .volume, .to_base_factor = 0.001 },
.{ .name = "in3", .aliases = &.{ "in^3", "cubicinch", "cubicinches" }, .category = .volume, .to_base_factor = 0.016387064 },
.{ .name = "ft3", .aliases = &.{ "ft^3", "cubicfoot", "cubicfeet" }, .category = .volume, .to_base_factor = 28.316846592 },
.{ .name = "gal", .aliases = &.{ "gallon", "gallons" }, .category = .volume, .to_base_factor = 3.785411784 },
.{ .name = "qt", .aliases = &.{ "quart", "quarts" }, .category = .volume, .to_base_factor = 0.946352946 },
.{ .name = "pt", .aliases = &.{ "pint", "pints" }, .category = .volume, .to_base_factor = 0.473176473 },
.{ .name = "cup", .aliases = &.{"cups"}, .category = .volume, .to_base_factor = 0.2365882365 },
.{ .name = "floz", .aliases = &.{ "fluidounce", "fluidounces" }, .category = .volume, .to_base_factor = 0.0295735295625 },
.{ .name = "tbsp", .aliases = &.{ "tablespoon", "tablespoons" }, .category = .volume, .to_base_factor = 0.01478676478125 },
.{ .name = "tsp", .aliases = &.{ "teaspoon", "teaspoons" }, .category = .volume, .to_base_factor = 0.00492892159375 },
.{ .name = "bbl", .aliases = &.{ "barrel", "barrels" }, .category = .volume, .to_base_factor = 158.987294928 },
};
const volume_units = define(.volume, &.{
.{ .name = "mL", .aliases = &.{ "ml", "milliliter", "milliliters" }, .factor = "0.001" },
.{ .name = "L", .aliases = &.{ "l", "liter", "liters", "litre", "litres" }, .factor = "1" },
.{ .name = "m3", .aliases = &.{ "m^3", "cubicmeter", "cubicmeters" }, .factor = "1000" },
.{ .name = "cm3", .aliases = &.{ "cm^3", "cc", "cubiccentimeter", "cubiccentimeters" }, .factor = "0.001" },
.{ .name = "in3", .aliases = &.{ "in^3", "cubicinch", "cubicinches" }, .factor = "0.016387064" },
.{ .name = "ft3", .aliases = &.{ "ft^3", "cubicfoot", "cubicfeet" }, .factor = "28.316846592" },
.{ .name = "gal", .aliases = &.{ "gallon", "gallons" }, .factor = "3.785411784" },
.{ .name = "qt", .aliases = &.{ "quart", "quarts" }, .factor = "0.946352946" },
.{ .name = "pt", .aliases = &.{ "pint", "pints" }, .factor = "0.473176473" },
.{ .name = "cup", .aliases = &.{"cups"}, .factor = "0.2365882365" },
.{ .name = "floz", .aliases = &.{ "fluidounce", "fluidounces" }, .factor = "0.0295735295625" },
.{ .name = "tbsp", .aliases = &.{ "tablespoon", "tablespoons" }, .factor = "0.01478676478125" },
.{ .name = "tsp", .aliases = &.{ "teaspoon", "teaspoons" }, .factor = "0.00492892159375" },
.{ .name = "bbl", .aliases = &.{ "barrel", "barrels" }, .factor = "158.987294928" },
});
const energy_units = [_]UnitDef{
.{ .name = "J", .aliases = &.{ "joule", "joules" }, .category = .energy, .to_base_factor = 1.0 },
.{ .name = "kJ", .aliases = &.{ "kilojoule", "kilojoules" }, .category = .energy, .to_base_factor = 1000.0 },
.{ .name = "cal", .aliases = &.{ "calorie", "calories" }, .category = .energy, .to_base_factor = 4.184 },
.{ .name = "kcal", .aliases = &.{ "kilocalorie", "kilocalories" }, .category = .energy, .to_base_factor = 4184.0 },
.{ .name = "Wh", .aliases = &.{ "watthour", "watthours" }, .category = .energy, .to_base_factor = 3600.0 },
.{ .name = "kWh", .aliases = &.{ "kilowatthour", "kilowatthours" }, .category = .energy, .to_base_factor = 3600000.0 },
.{ .name = "BTU", .aliases = &.{ "btu", "britishthermalunit" }, .category = .energy, .to_base_factor = 1055.05585262 },
.{ .name = "eV", .aliases = &.{ "electronvolt", "electronvolts" }, .category = .energy, .to_base_factor = 1.602176634e-19 },
.{ .name = "erg", .aliases = &.{"ergs"}, .category = .energy, .to_base_factor = 1e-7 },
};
const energy_units = define(.energy, &.{
.{ .name = "J", .aliases = &.{ "joule", "joules" }, .factor = "1" },
.{ .name = "kJ", .aliases = &.{ "kilojoule", "kilojoules" }, .factor = "1000" },
.{ .name = "cal", .aliases = &.{ "calorie", "calories" }, .factor = "4.184" },
.{ .name = "kcal", .aliases = &.{ "kilocalorie", "kilocalories" }, .factor = "4184" },
.{ .name = "Wh", .aliases = &.{ "watthour", "watthours" }, .factor = "3600" },
.{ .name = "kWh", .aliases = &.{ "kilowatthour", "kilowatthours" }, .factor = "3600000" },
.{ .name = "BTU", .aliases = &.{ "btu", "britishthermalunit" }, .factor = "1055.05585262" },
.{ .name = "eV", .aliases = &.{ "electronvolt", "electronvolts" }, .factor = "1.602176634e-19" },
.{ .name = "erg", .aliases = &.{"ergs"}, .factor = "1e-7" },
});
const pressure_units = [_]UnitDef{
.{ .name = "Pa", .aliases = &.{ "pascal", "pascals" }, .category = .pressure, .to_base_factor = 1.0 },
.{ .name = "kPa", .aliases = &.{ "kilopascal", "kilopascals" }, .category = .pressure, .to_base_factor = 1000.0 },
.{ .name = "MPa", .aliases = &.{ "megapascal", "megapascals" }, .category = .pressure, .to_base_factor = 1e6 },
.{ .name = "bar", .aliases = &.{"bars"}, .category = .pressure, .to_base_factor = 100000.0 },
.{ .name = "mbar", .aliases = &.{ "millibar", "millibars" }, .category = .pressure, .to_base_factor = 100.0 },
.{ .name = "atm", .aliases = &.{ "atmosphere", "atmospheres" }, .category = .pressure, .to_base_factor = 101325.0 },
.{ .name = "psi", .aliases = &.{"poundpersquareinch"}, .category = .pressure, .to_base_factor = 6894.757293168361 },
.{ .name = "torr", .aliases = &.{"torrs"}, .category = .pressure, .to_base_factor = 101325.0 / 760.0 },
.{ .name = "mmHg", .aliases = &.{"mmhg"}, .category = .pressure, .to_base_factor = 133.322387415 },
.{ .name = "inHg", .aliases = &.{"inhg"}, .category = .pressure, .to_base_factor = 3386.388640341 },
};
const pressure_units = define(.pressure, &.{
.{ .name = "Pa", .aliases = &.{ "pascal", "pascals" }, .factor = "1" },
.{ .name = "kPa", .aliases = &.{ "kilopascal", "kilopascals" }, .factor = "1000" },
.{ .name = "MPa", .aliases = &.{ "megapascal", "megapascals" }, .factor = "1e6" },
.{ .name = "bar", .aliases = &.{"bars"}, .factor = "100000" },
.{ .name = "mbar", .aliases = &.{ "millibar", "millibars" }, .factor = "100" },
.{ .name = "atm", .aliases = &.{ "atmosphere", "atmospheres" }, .factor = "101325" },
// psi is exactly 4.4482216152605 N over 0.00064516 m2.
.{ .name = "psi", .aliases = &.{"poundpersquareinch"}, .factor = "4.4482216152605/0.00064516" },
.{ .name = "torr", .aliases = &.{"torrs"}, .factor = "101325/760" },
.{ .name = "mmHg", .aliases = &.{"mmhg"}, .factor = "133.322387415" },
.{ .name = "inHg", .aliases = &.{"inhg"}, .factor = "3386.388640341" },
});
// Base: bits per second.
const data_rate_units = [_]UnitDef{
.{ .name = "bps", .aliases = &.{ "bitpersecond", "bitspersecond" }, .category = .data_rate, .to_base_factor = 1.0 },
.{ .name = "kbps", .aliases = &.{"kilobitpersecond"}, .category = .data_rate, .to_base_factor = 1e3 },
.{ .name = "Mbps", .aliases = &.{"megabitpersecond"}, .category = .data_rate, .to_base_factor = 1e6 },
.{ .name = "Gbps", .aliases = &.{"gigabitpersecond"}, .category = .data_rate, .to_base_factor = 1e9 },
.{ .name = "Tbps", .aliases = &.{"terabitpersecond"}, .category = .data_rate, .to_base_factor = 1e12 },
.{ .name = "Bps", .aliases = &.{ "bytepersecond", "bytespersecond" }, .category = .data_rate, .to_base_factor = 8.0 },
.{ .name = "kBps", .aliases = &.{"kilobytepersecond"}, .category = .data_rate, .to_base_factor = 8e3 },
.{ .name = "MBps", .aliases = &.{"megabytepersecond"}, .category = .data_rate, .to_base_factor = 8e6 },
.{ .name = "GBps", .aliases = &.{"gigabytepersecond"}, .category = .data_rate, .to_base_factor = 8e9 },
};
const data_rate_units = define(.data_rate, &.{
.{ .name = "bps", .aliases = &.{ "bitpersecond", "bitspersecond" }, .factor = "1" },
.{ .name = "kbps", .aliases = &.{"kilobitpersecond"}, .factor = "1e3" },
.{ .name = "Mbps", .aliases = &.{"megabitpersecond"}, .factor = "1e6" },
.{ .name = "Gbps", .aliases = &.{"gigabitpersecond"}, .factor = "1e9" },
.{ .name = "Tbps", .aliases = &.{"terabitpersecond"}, .factor = "1e12" },
.{ .name = "Bps", .aliases = &.{ "bytepersecond", "bytespersecond" }, .factor = "8" },
.{ .name = "kBps", .aliases = &.{"kilobytepersecond"}, .factor = "8e3" },
.{ .name = "MBps", .aliases = &.{"megabytepersecond"}, .factor = "8e6" },
.{ .name = "GBps", .aliases = &.{"gigabytepersecond"}, .factor = "8e9" },
});
const angle_units = [_]UnitDef{
.{ .name = "rad", .aliases = &.{ "radian", "radians" }, .category = .angle, .to_base_factor = 1.0 },
.{ .name = "mrad", .aliases = &.{ "milliradian", "milliradians" }, .category = .angle, .to_base_factor = 0.001 },
.{ .name = "deg", .aliases = &.{ "degree", "degrees" }, .category = .angle, .to_base_factor = std.math.pi / 180.0 },
.{ .name = "grad", .aliases = &.{ "gradian", "gradians", "gon" }, .category = .angle, .to_base_factor = std.math.pi / 200.0 },
.{ .name = "turn", .aliases = &.{ "turns", "rev", "revolution", "revolutions" }, .category = .angle, .to_base_factor = std.math.tau },
.{ .name = "arcmin", .aliases = &.{ "arcminute", "arcminutes" }, .category = .angle, .to_base_factor = std.math.pi / 10800.0 },
.{ .name = "arcsec", .aliases = &.{ "arcsecond", "arcseconds" }, .category = .angle, .to_base_factor = std.math.pi / 648000.0 },
};
// Base: radian. Every unit here except mrad is defined as a fraction of a turn,
// i.e. in terms of pi, so it has NO exact rational factor: converting degrees to
// radians is inherently inexact. Only these units and the parsec are marked
// approximate.
const angle_units = define(.angle, &.{
.{ .name = "rad", .aliases = &.{ "radian", "radians" }, .factor = "1" },
.{ .name = "mrad", .aliases = &.{ "milliradian", "milliradians" }, .factor = "0.001" },
.{ .name = "deg", .aliases = &.{ "degree", "degrees" }, .approx_factor = std.math.pi / 180.0 },
.{ .name = "grad", .aliases = &.{ "gradian", "gradians", "gon" }, .approx_factor = std.math.pi / 200.0 },
.{ .name = "turn", .aliases = &.{ "turns", "rev", "revolution", "revolutions" }, .approx_factor = std.math.tau },
.{ .name = "arcmin", .aliases = &.{ "arcminute", "arcminutes" }, .approx_factor = std.math.pi / 10800.0 },
.{ .name = "arcsec", .aliases = &.{ "arcsecond", "arcseconds" }, .approx_factor = std.math.pi / 648000.0 },
});
/// All unit tables, indexed in the same order as `UnitCategory`.
pub const categories = [_][]const UnitDef{
@ -508,6 +594,73 @@ pub fn parseRequest(text: []const u8) CalcError!?ConversionRequest {
return null;
}
/// Convert exactly, preserving the exact tier wherever the units allow it.
///
/// `12 in to ft` is exactly `1` here: an inch is exactly 127/5000 m and a foot
/// exactly 381/1250 m, so the ratio is exact. The f64 path cannot achieve this
/// because `0.0254` is already rounded before any arithmetic happens.
///
/// Falls back to the inexact tier when either unit is defined in terms of pi
/// (the angle units and the parsec), since those factors have no rational form.
/// The result's `exact` tag therefore tells the caller whether the conversion was
/// lossless.
pub fn convertExactUnits(
allocator: std.mem.Allocator,
value: Number,
from: UnitDef,
to: UnitDef,
) CalcError!Number {
if (from.category != to.category) return CalcError.IncompatibleUnits;
if (std.mem.eql(u8, from.name, to.name)) {
return value.cloneWith(allocator) catch |err| return mapNumberError(err);
}
// No exact factor available, or the value is already inexact: use floats.
if (!from.isExact() or !to.isExact() or value != .exact) {
const converted = try convertUnits(value.toFloat(allocator), from, to);
return Number.fromFloat(converted);
}
return convertExactInner(allocator, value, from, to) catch |err| mapNumberError(err);
}
fn convertExactInner(
allocator: std.mem.Allocator,
value: Number,
from: UnitDef,
to: UnitDef,
) number_mod.Error!Number {
// base = value * from.factor + from.offset
var from_factor = Number.fromRational(try Rational.parse(allocator, from.factor_text.?));
defer from_factor.deinit();
var from_offset = Number.fromRational(try Rational.parse(allocator, from.offset_text.?));
defer from_offset.deinit();
var scaled = try Number.mul(allocator, value, from_factor);
defer scaled.deinit();
var base = try Number.add(allocator, scaled, from_offset);
defer base.deinit();
// result = (base - to.offset) / to.factor
var to_factor = Number.fromRational(try Rational.parse(allocator, to.factor_text.?));
defer to_factor.deinit();
var to_offset = Number.fromRational(try Rational.parse(allocator, to.offset_text.?));
defer to_offset.deinit();
var shifted = try Number.sub(allocator, base, to_offset);
defer shifted.deinit();
return Number.div(allocator, shifted, to_factor);
}
fn mapNumberError(err: number_mod.Error) CalcError {
return switch (err) {
error.OutOfMemory => CalcError.OutOfMemory,
error.DivisionByZero => CalcError.DivisionByZero,
error.InvalidNumber => CalcError.InvalidNumber,
error.ExponentTooLarge => CalcError.Overflow,
};
}
// -- Tests --
const testing = std.testing;
@ -1082,3 +1235,231 @@ test "parseRequest: backtracking reports the useful error, not a parse error" {
try testing.expectError(CalcError.UnknownUnit, parseRequest("5 cm in smoots"));
try testing.expectError(CalcError.IncompatibleUnits, parseRequest("5 cm in kg"));
}
// -- Exact conversion (Task 2.0e) --
fn expectExactConversion(expected: []const u8, value_text: []const u8, from_name: []const u8, to_name: []const u8) !void {
const alloc = testing.allocator;
const from = findUnit(from_name).?;
const to = findUnit(to_name).?;
var value = try Number.parse(alloc, value_text);
defer value.deinit();
var result = try convertExactUnits(alloc, value, from, to);
defer result.deinit();
try testing.expect(result.isExact());
const shown = try result.toDecimalString(alloc, 30);
defer alloc.free(shown.text);
try testing.expectEqualStrings(expected, shown.text);
try testing.expect(shown.exact);
}
test "exact: 12 inches is exactly 1 foot" {
// The bug that motivated this task: the f64 path gives 0.9999999999999998
// because 0.0254 is already rounded before the multiply.
try expectExactConversion("1", "12", "in", "ft");
}
test "exact: the inch-based chain is exact throughout" {
try expectExactConversion("2.54", "1", "in", "cm");
try expectExactConversion("12", "1", "ft", "in");
try expectExactConversion("3", "1", "yd", "ft");
try expectExactConversion("5280", "1", "mi", "ft");
try expectExactConversion("1609.344", "1", "mi", "m");
}
test "exact: metric scaling" {
try expectExactConversion("1000", "1", "km", "m");
try expectExactConversion("100", "1", "m", "cm");
try expectExactConversion("1000", "1", "g", "mg");
}
test "exact: temperature, including the affine cases" {
try expectExactConversion("32", "0", "C", "F");
try expectExactConversion("212", "100", "C", "F");
try expectExactConversion("0", "32", "F", "C");
try expectExactConversion("100", "212", "F", "C");
try expectExactConversion("-40", "-40", "C", "F");
try expectExactConversion("273.15", "0", "C", "K");
try expectExactConversion("-273.15", "0", "K", "C");
// 98.6 F is exactly 37 C, which the f64 path only reaches approximately.
try expectExactConversion("37", "98.6", "F", "C");
}
test "exact: digital storage keeps binary and decimal prefixes distinct" {
try expectExactConversion("1024", "1", "KiB", "B");
try expectExactConversion("1000", "1", "kB", "B");
try expectExactConversion("1024", "1", "GiB", "MiB");
try expectExactConversion("8", "1", "B", "bit");
}
test "exact: speed factors that are fractions" {
// 3.6 km/h is exactly 1 m/s; a knot is exactly 1.852 km/h.
try expectExactConversion("1", "3.6", "km/h", "m/s");
try expectExactConversion("1.852", "1", "kn", "km/h");
try expectExactConversion("1.609344", "1", "mph", "km/h");
}
test "exact: area and volume" {
try expectExactConversion("10000", "1", "m2", "cm2");
try expectExactConversion("43560", "1", "acre", "ft2");
try expectExactConversion("1000", "1", "L", "mL");
try expectExactConversion("4", "1", "gal", "qt");
try expectExactConversion("3", "1", "tbsp", "tsp");
}
test "exact: energy and pressure" {
try expectExactConversion("1000", "1", "kcal", "cal");
try expectExactConversion("3600000", "1", "kWh", "J");
try expectExactConversion("760", "1", "atm", "torr");
try expectExactConversion("100", "1", "bar", "kPa");
}
test "exact: pi-based angle units fall back to inexact" {
const alloc = testing.allocator;
var value = try Number.parse(alloc, "180");
defer value.deinit();
var result = try convertExactUnits(alloc, value, findUnit("deg").?, findUnit("rad").?);
defer result.deinit();
// Degrees are defined via pi, so no exact rational answer exists.
try testing.expect(!result.isExact());
try testing.expectApproxEqAbs(std.math.pi, result.toFloat(alloc), 1e-15);
}
test "exact: radian to milliradian stays exact (neither involves pi)" {
try expectExactConversion("1000", "1", "rad", "mrad");
}
test "exact: an inexact input stays inexact even between exact units" {
// Contagion: the units could convert exactly, but the value already lost
// information, so the result must not claim exactness.
const alloc = testing.allocator;
var value = Number.fromFloat(12.0);
defer value.deinit();
var result = try convertExactUnits(alloc, value, findUnit("in").?, findUnit("ft").?);
defer result.deinit();
try testing.expect(!result.isExact());
}
test "exact: same unit is an exact identity" {
try expectExactConversion("42.5", "42.5", "m", "m");
}
test "exact: incompatible categories still error" {
const alloc = testing.allocator;
var value = try Number.parse(alloc, "1");
defer value.deinit();
try testing.expectError(
CalcError.IncompatibleUnits,
convertExactUnits(alloc, value, findUnit("kg").?, findUnit("m").?),
);
}
test "exact: non-terminating conversions are exact values with rounded display" {
const alloc = testing.allocator;
var value = try Number.parse(alloc, "100");
defer value.deinit();
var result = try convertExactUnits(alloc, value, findUnit("km").?, findUnit("mi").?);
defer result.deinit();
// The VALUE is exact even though its decimal expansion does not terminate.
try testing.expect(result.isExact());
const frac = (try result.toFractionString(alloc)).?;
defer alloc.free(frac);
try testing.expectEqualStrings("781250/12573", frac);
const shown = try result.toDecimalString(alloc, 9);
defer alloc.free(shown.text);
try testing.expect(!shown.exact);
try testing.expectEqualStrings("62.137119224", shown.text);
}
test "exact: every unit pair within a category round-trips EXACTLY" {
// The f64 version of this invariant could only assert a tolerance. With
// exact factors the round trip is bit-for-bit, which is a far stronger
// guarantee against a mistyped table entry.
const alloc = testing.allocator;
for (std.enums.values(UnitCategory)) |category| {
for (unitsIn(category)) |a| {
for (unitsIn(category)) |b| {
if (!a.isExact() or !b.isExact()) continue;
var value = try Number.parse(alloc, "7.25");
defer value.deinit();
var forward = try convertExactUnits(alloc, value, a, b);
defer forward.deinit();
var back = try convertExactUnits(alloc, forward, b, a);
defer back.deinit();
try testing.expect(back.isExact());
if (!try Number.eql(alloc, value, back)) {
std.debug.print("round trip {s} -> {s} -> {s} was not exact\n", .{ a.name, b.name, a.name });
return error.InexactRoundTrip;
}
}
}
}
}
test "exact: every category's base unit has factor 1 and no offset in TEXT form" {
// Guards the exact tables the same way the f64 invariant test does, but on
// the source of truth rather than the derived value.
for (std.enums.values(UnitCategory)) |category| {
const base = findUnit(category.baseUnit()).?;
try testing.expectEqualStrings("1", base.factor_text.?);
try testing.expectEqualStrings("0", base.offset_text.?);
}
}
test "exact: only pi-derived units lack an exact factor" {
const alloc = testing.allocator;
for (std.enums.values(UnitCategory)) |category| {
for (unitsIn(category)) |unit| {
if (unit.isExact()) {
// The exact text must parse and must equal the derived f64.
var parsed = try Rational.parse(alloc, unit.factor_text.?);
defer parsed.deinit();
try testing.expectApproxEqRel(unit.to_base_factor, parsed.toFloat(alloc), 1e-12);
continue;
}
// The only inexact units are the pi-based angles and the parsec.
const is_known_inexact = category == .angle or std.mem.eql(u8, unit.name, "pc");
if (!is_known_inexact) {
std.debug.print("unit '{s}' unexpectedly has no exact factor\n", .{unit.name});
return error.UnexpectedInexactUnit;
}
}
}
}
test "OOM safety: exact conversion releases everything at any failure point" {
// Also the only realistic way to reach mapNumberError, which translates the
// numeric model's errors into the engine's error set.
const alloc = testing.allocator;
const from = findUnit("in").?;
const to = findUnit("ft").?;
var fail_index: usize = 0;
while (fail_index < 512) : (fail_index += 1) {
var failing = std.testing.FailingAllocator.init(alloc, .{ .fail_index = fail_index });
const a = failing.allocator();
var value = Number.parse(a, "12") catch |err| {
try testing.expectEqual(rational_mod.Error.OutOfMemory, err);
continue;
};
defer value.deinit();
var result = convertExactUnits(a, value, from, to) catch |err| {
// The numeric model's OutOfMemory must surface as the engine's.
try testing.expectEqual(CalcError.OutOfMemory, err);
continue;
};
result.deinit();
// Completed without needing the failure: the sweep is done.
return;
}
return error.OomSweepNeverCompleted;
}

View file

@ -16,7 +16,8 @@ pub const ParsedArgs = union(enum) {
mode: engine.Mode,
},
conversion: struct {
value: f64,
/// Kept as text so it can be parsed exactly rather than through f64.
value_text: []const u8,
from: []const u8,
to: []const u8,
},
@ -66,40 +67,117 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedA
}
/// Parse the arguments following the `convert` subcommand.
/// Accepts `<value> <from> <to>` and `<value> <from> to <to>`, and also the
/// glued form `<value><from> to <to>` (e.g. "100km to mi").
///
/// Accepts `<value> <from> <to>`, either separator word between the units
/// (`<value> <from> to|in <to>`), and the glued form `<value><from> to <to>`.
///
/// `in` is both a separator and the name for inches, so candidate readings are
/// validated by resolving the unit names, and the first reading that resolves
/// wins. Later separators are tried first, which is what makes `1 in in cm`
/// (inches to centimetres) and `1 acre in ft2` both work. This mirrors the
/// backtracking in `units.parseRequest`, which handles the same ambiguity for
/// bare expressions.
fn parseConvertArgs(args: []const []const u8) ParsedArgs {
// Drop a literal "to" separator so both call styles work.
const max_tokens = 8;
if (args.len < 2 or args.len > max_tokens) {
return .{ .output = .{ .text = convert_usage, .is_error = true } };
}
// Try dropping each separator word, from the last one backwards, then try
// dropping nothing at all.
var skip = args.len;
while (true) {
const consider = skip == args.len or isSeparatorWord(args[skip]);
if (consider) {
if (interpretConvertTokens(args, skip)) |conversion| {
return .{ .conversion = conversion };
}
}
if (skip == 0) break;
skip -= 1;
}
// Nothing resolved. Distinguish a bad number from a bad unit so the message
// is useful.
if (args.len >= 2 and !looksNumeric(args[0])) {
if (splitValueAndUnit(args[0]) == null) {
return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } };
}
}
return .{ .output = .{ .text = convert_usage, .is_error = true } };
}
fn isSeparatorWord(text: []const u8) bool {
return std.ascii.eqlIgnoreCase(text, "to") or std.ascii.eqlIgnoreCase(text, "in");
}
/// Interpret the argument list with the token at `skip` removed (pass
/// `args.len` to remove nothing). Returns null when the reading does not
/// resolve to two known units plus a numeric value.
fn interpretConvertTokens(
args: []const []const u8,
skip: usize,
) ?@FieldType(ParsedArgs, "conversion") {
var parts: [3][]const u8 = undefined;
var count: usize = 0;
for (args) |arg| {
if (std.mem.eql(u8, arg, "to")) continue;
if (count >= parts.len) {
return .{ .output = .{ .text = convert_usage, .is_error = true } };
}
for (args, 0..) |arg, i| {
if (i == skip) continue;
if (count >= parts.len) return null;
parts[count] = arg;
count += 1;
}
if (count == 3) {
if (!isFullyNumeric(parts[0])) return null;
if (engine.units.findUnit(parts[1]) == null) return null;
if (engine.units.findUnit(parts[2]) == null) return null;
return .{ .value_text = parts[0], .from = parts[1], .to = parts[2] };
}
// Glued form: "100km" "mi" -> split the leading number from the unit.
if (count == 2) {
const split = splitValueAndUnit(parts[0]) orelse {
return .{ .output = .{ .text = convert_usage, .is_error = true } };
};
const value = std.fmt.parseFloat(f64, split.number) catch {
return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } };
};
return .{ .conversion = .{ .value = value, .from = split.unit, .to = parts[1] } };
const split = splitValueAndUnit(parts[0]) orelse return null;
if (engine.units.findUnit(split.unit) == null) return null;
if (engine.units.findUnit(parts[1]) == null) return null;
return .{ .value_text = split.number, .from = split.unit, .to = parts[1] };
}
if (count != 3) {
return .{ .output = .{ .text = convert_usage, .is_error = true } };
}
return null;
}
const value = std.fmt.parseFloat(f64, parts[0]) catch {
return .{ .output = .{ .text = "error: invalid number\n", .is_error = true } };
};
return .{ .conversion = .{ .value = value, .from = parts[1], .to = parts[2] } };
/// Cheap shape check for a numeric literal, so a malformed value is rejected at
/// the argument layer rather than surfacing later from the formatter.
///
/// Deliberately not a full parse: the value text is handed to the exact rational
/// parser, which is the real authority, and duplicating its grammar here would
/// be a second source of truth.
fn looksNumeric(text: []const u8) bool {
if (text.len == 0) return false;
const first = text[0];
return (first >= '0' and first <= '9') or first == '.' or first == '-' or first == '+';
}
/// True when every character could belong to a numeric literal and at least one
/// digit is present.
///
/// This has to check the WHOLE token, not just the first character: `98.6F` in
/// `convert 98.6F in C` starts numerically but is really a glued value and unit,
/// and accepting it as a bare value would hand `98.6F` to the number parser and
/// fail. Separator accuracy depends on rejecting that reading so the glued one
/// is tried instead.
fn isFullyNumeric(text: []const u8) bool {
var has_digit = false;
for (text) |c| {
if (c >= '0' and c <= '9') {
has_digit = true;
continue;
}
switch (c) {
'.', '+', '-', 'e', 'E', ',', '_' => {},
else => return false,
}
}
return has_digit;
}
/// Split a token like "100km" into its numeric prefix and unit suffix.
@ -126,26 +204,55 @@ fn splitValueAndUnit(token: []const u8) ?struct { number: []const u8, unit: []co
}
/// Format a unit conversion result by unit name.
pub fn formatConversion(buf: []u8, value: f64, from_name: []const u8, to_name: []const u8) CliResult {
const result = engine.units.convert(value, from_name, to_name) catch |err| {
return .{ .output = errorMessage(err), .is_error = true };
pub fn formatConversion(
allocator: std.mem.Allocator,
buf: []u8,
value_text: []const u8,
from_name: []const u8,
to_name: []const u8,
) CliResult {
const from = engine.units.findUnit(from_name) orelse
return .{ .output = errorMessage(engine.CalcError.UnknownUnit), .is_error = true };
const to = engine.units.findUnit(to_name) orelse
return .{ .output = errorMessage(engine.CalcError.UnknownUnit), .is_error = true };
// Parse the value exactly rather than through f64, so a decimal input like
// 2.5 enters the conversion without being rounded first.
var value = engine.Number.parse(allocator, value_text) catch {
return .{ .output = "error: invalid number\n", .is_error = true };
};
return formatConversionUnits(buf, value, result.from, result.to);
defer value.deinit();
return formatConversionUnits(allocator, buf, value, from, to);
}
/// Format a conversion between two already-resolved units.
fn formatConversionUnits(buf: []u8, value: f64, from: engine.UnitDef, to: engine.UnitDef) CliResult {
const converted = engine.units.convertUnits(value, from, to) catch |err| {
///
/// Uses the exact path so terminating conversions print exactly: `12 in to ft`
/// is `1`, not `0.9999999999999998`.
fn formatConversionUnits(
allocator: std.mem.Allocator,
buf: []u8,
value: engine.Number,
from: engine.UnitDef,
to: engine.UnitDef,
) CliResult {
var converted = engine.units.convertExactUnits(allocator, value, from, to) catch |err| {
return .{ .output = errorMessage(err), .is_error = true };
};
defer converted.deinit();
var in_buf: [64]u8 = undefined;
var out_buf: [64]u8 = undefined;
const in_str = engine.formatter.formatCompactFloat(&in_buf, value);
const out_str = engine.formatter.formatCompactFloat(&out_buf, converted);
const shown_in = engine.formatter.formatNumber(allocator, value) catch {
return .{ .output = "error: out of memory\n", .is_error = true };
};
defer shown_in.deinit(allocator);
const shown_out = engine.formatter.formatNumber(allocator, converted) catch {
return .{ .output = "error: out of memory\n", .is_error = true };
};
defer shown_out.deinit(allocator);
const output = std.fmt.bufPrint(buf, "{s} {s} = {s} {s}", .{
in_str, from.name, out_str, to.name,
shown_in.display, from.name, shown_out.display, to.name,
}) catch {
return .{ .output = "error: buffer overflow\n", .is_error = true };
};
@ -173,9 +280,7 @@ pub fn evaluate(allocator: std.mem.Allocator, expression: []const u8, mode: engi
return .{ .output = errorMessage(err), .is_error = true };
};
defer value.deinit();
// Conversion factors are still f64 (see Task 2.0e), so the value
// collapses here regardless.
return formatConversionUnits(buf, value.toFloat(allocator), request.from, request.to);
return formatConversionUnits(allocator, buf, value, request.from, request.to);
}
} else |err| {
return .{ .output = errorMessage(err), .is_error = true };
@ -363,7 +468,7 @@ pub fn main(init: std.process.Init) u8 {
},
.conversion => |conv| {
var buf: [4096]u8 = undefined;
const result = formatConversion(&buf, conv.value, conv.from, conv.to);
const result = formatConversion(allocator, &buf, conv.value_text, conv.from, conv.to);
const file = if (result.is_error) std.Io.File.stderr() else std.Io.File.stdout();
write(io, file, result.output);
if (!result.is_error) write(io, std.Io.File.stdout(), "\n");
@ -531,7 +636,7 @@ test "parseArgs: convert subcommand three-arg form" {
const parsed = parseArgs(testing.allocator, &.{ "convert", "100", "km", "mi" });
switch (parsed) {
.conversion => |c| {
try testing.expectEqual(@as(f64, 100), c.value);
try testing.expectEqualStrings("100", c.value_text);
try testing.expectEqualStrings("km", c.from);
try testing.expectEqualStrings("mi", c.to);
},
@ -543,7 +648,7 @@ test "parseArgs: convert subcommand with 'to' separator" {
const parsed = parseArgs(testing.allocator, &.{ "convert", "100", "km", "to", "mi" });
switch (parsed) {
.conversion => |c| {
try testing.expectEqual(@as(f64, 100), c.value);
try testing.expectEqualStrings("100", c.value_text);
try testing.expectEqualStrings("km", c.from);
try testing.expectEqualStrings("mi", c.to);
},
@ -555,7 +660,7 @@ test "parseArgs: convert glued value and unit" {
const parsed = parseArgs(testing.allocator, &.{ "convert", "100km", "to", "mi" });
switch (parsed) {
.conversion => |c| {
try testing.expectEqual(@as(f64, 100), c.value);
try testing.expectEqualStrings("100", c.value_text);
try testing.expectEqualStrings("km", c.from);
try testing.expectEqualStrings("mi", c.to);
},
@ -567,7 +672,7 @@ test "parseArgs: convert glued negative and decimal value" {
const parsed = parseArgs(testing.allocator, &.{ "convert", "-40.5C", "F" });
switch (parsed) {
.conversion => |c| {
try testing.expectApproxEqAbs(@as(f64, -40.5), c.value, 1e-12);
try testing.expectEqualStrings("-40.5", c.value_text);
try testing.expectEqualStrings("C", c.from);
try testing.expectEqualStrings("F", c.to);
},
@ -629,8 +734,10 @@ test "splitValueAndUnit: rejects pure number or pure unit" {
}
test "formatConversion: km to mi" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [256]u8 = undefined;
const result = formatConversion(&buf, 100, "km", "mi");
const result = formatConversion(arena.allocator(), &buf, "100", "km", "mi");
try testing.expect(!result.is_error);
try testing.expect(std.mem.indexOf(u8, result.output, "62.137") != null);
try testing.expect(std.mem.indexOf(u8, result.output, "km") != null);
@ -638,31 +745,40 @@ test "formatConversion: km to mi" {
}
test "formatConversion: temperature freezing point" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [256]u8 = undefined;
const result = formatConversion(&buf, 0, "C", "F");
const result = formatConversion(arena.allocator(), &buf, "0", "C", "F");
try testing.expect(!result.is_error);
try testing.expectEqualStrings("0 C = 32 F", result.output);
}
test "formatConversion: unknown unit is an error" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [256]u8 = undefined;
const result = formatConversion(&buf, 1, "smoots", "m");
const result = formatConversion(arena.allocator(), &buf, "1", "smoots", "m");
try testing.expect(result.is_error);
try testing.expect(std.mem.indexOf(u8, result.output, "unknown unit") != null);
}
test "formatConversion: incompatible units is an error" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [256]u8 = undefined;
const result = formatConversion(&buf, 1, "kg", "m");
const result = formatConversion(arena.allocator(), &buf, "1", "kg", "m");
try testing.expect(result.is_error);
try testing.expect(std.mem.indexOf(u8, result.output, "incompatible") != null);
}
test "formatConversion: alias resolves to canonical name in output" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [256]u8 = undefined;
const result = formatConversion(&buf, 1, "kilometer", "meters");
const result = formatConversion(arena.allocator(), &buf, "1", "kilometer", "meters");
try testing.expect(!result.is_error);
try testing.expectEqualStrings("1 km = 1000 m", result.output);
// Conversion output now uses the standard comma grouping (NFR-7).
try testing.expectEqualStrings("1 km = 1,000 m", result.output);
}
test "evaluate: bare conversion expression without subcommand" {
@ -689,7 +805,7 @@ test "evaluate: conversion value may be an expression" {
var buf: [4096]u8 = undefined;
const result = evaluate(arena.allocator(), "2*3 kg to g", .standard, &buf);
try testing.expect(!result.is_error);
try testing.expectEqualStrings("6 kg = 6000 g", result.output);
try testing.expectEqualStrings("6 kg = 6,000 g", result.output);
}
test "evaluate: bare conversion with unknown unit errors" {
@ -728,3 +844,91 @@ test "evaluate: expression containing a unit-like name still evaluates" {
try testing.expect(!result.is_error);
try testing.expect(std.mem.startsWith(u8, result.output, "2.718"));
}
// -- convert subcommand separator handling (Task 2.0e) --
fn expectConvertArgs(expected_value: []const u8, expected_from: []const u8, expected_to: []const u8, args: []const []const u8) !void {
const parsed = parseArgs(testing.allocator, args);
switch (parsed) {
.conversion => |c| {
try testing.expectEqualStrings(expected_value, c.value_text);
try testing.expectEqualStrings(expected_from, c.from);
try testing.expectEqualStrings(expected_to, c.to);
},
else => return error.ExpectedConversion,
}
}
test "parseArgs: convert accepts 'in' as a separator" {
try expectConvertArgs("1", "acre", "ft2", &.{ "convert", "1", "acre", "in", "ft2" });
try expectConvertArgs("1", "kcal", "cal", &.{ "convert", "1", "kcal", "in", "cal" });
}
test "parseArgs: convert resolves inches against the 'in' separator" {
// No separator at all: "in" is the source unit.
try expectConvertArgs("1", "in", "cm", &.{ "convert", "1", "in", "cm" });
// Separator present: the later "in" is the separator, the earlier the unit.
try expectConvertArgs("1", "in", "cm", &.{ "convert", "1", "in", "in", "cm" });
// Inches as the TARGET, with "in" also separating.
try expectConvertArgs("100", "mm", "in", &.{ "convert", "100", "mm", "in", "in" });
}
test "parseArgs: convert handles a glued value followed by a separator" {
// "98.6F" must not be accepted as a bare value: it is a glued value+unit.
try expectConvertArgs("98.6", "F", "C", &.{ "convert", "98.6F", "in", "C" });
try expectConvertArgs("-40", "C", "F", &.{ "convert", "-40C", "in", "F" });
try expectConvertArgs("100", "km", "mi", &.{ "convert", "100km", "to", "mi" });
}
test "parseArgs: convert rejects unknown units rather than guessing" {
const parsed = parseArgs(testing.allocator, &.{ "convert", "1", "smoots", "m" });
switch (parsed) {
.output => |o| try testing.expect(o.is_error),
else => return error.ExpectedOutput,
}
}
test "isFullyNumeric: whole-token validation" {
try testing.expect(isFullyNumeric("100"));
try testing.expect(isFullyNumeric("-40.5"));
try testing.expect(isFullyNumeric("1e-3"));
try testing.expect(isFullyNumeric("1,000"));
try testing.expect(isFullyNumeric("1_000"));
// A glued value and unit is NOT a bare number.
try testing.expect(!isFullyNumeric("98.6F"));
try testing.expect(!isFullyNumeric("100km"));
try testing.expect(!isFullyNumeric("abc"));
// Needs at least one digit.
try testing.expect(!isFullyNumeric("-"));
try testing.expect(!isFullyNumeric(""));
}
test "isSeparatorWord: both words, case-insensitive" {
try testing.expect(isSeparatorWord("to"));
try testing.expect(isSeparatorWord("TO"));
try testing.expect(isSeparatorWord("in"));
try testing.expect(isSeparatorWord("In"));
try testing.expect(!isSeparatorWord("km"));
try testing.expect(!isSeparatorWord("into"));
}
test "formatConversion: exact conversion prints exactly" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [256]u8 = undefined;
// The bug this task exists to fix.
const feet = formatConversion(arena.allocator(), &buf, "12", "in", "ft");
try testing.expect(!feet.is_error);
try testing.expectEqualStrings("12 in = 1 ft", feet.output);
// Exact affine conversion.
const celsius = formatConversion(arena.allocator(), &buf, "98.6", "F", "C");
try testing.expect(!celsius.is_error);
try testing.expectEqualStrings("98.6 F = 37 C", celsius.output);
// Exact fractional factor.
const mps = formatConversion(arena.allocator(), &buf, "3.6", "km/h", "m/s");
try testing.expect(!mps.is_error);
try testing.expectEqualStrings("3.6 km/h = 1 m/s", mps.output);
}

View file

@ -134,7 +134,9 @@ pub const App = struct {
conv_category: engine.UnitCategory,
conv_from_idx: usize,
conv_to_idx: usize,
conv_value: f64,
/// The value being converted. A `Number` so exact input stays exact through
/// the conversion, matching the CLI.
conv_value: engine.Number,
conv_zone: ConvZone,
// Mouse hit regions, rebuilt every frame during drawing
regions: RegionSet,
@ -220,7 +222,7 @@ pub const App = struct {
.conv_category = .length,
.conv_from_idx = default_from,
.conv_to_idx = default_to,
.conv_value = 1,
.conv_value = engine.Number.fromFloat(1),
.conv_zone = .from,
.regions = .empty,
};
@ -238,6 +240,7 @@ pub const App = struct {
}
self.history.deinit(self.allocator);
if (self.saved_input) |s| self.allocator.free(s);
self.conv_value.deinit();
}
pub fn widget(self: *App) vxfw.Widget {
@ -903,34 +906,37 @@ pub const App = struct {
}
/// In convert mode, the input line sets the value to convert. A bare number
/// is taken directly; anything else is evaluated as a standard expression so
/// is parsed exactly; anything else is evaluated as a standard expression so
/// things like "2*3.5" or "sqrt(2)" work as the input value.
fn submitConvert(self: *App, expr_text: []const u8) !void {
const value: f64 = std.fmt.parseFloat(f64, expr_text) catch blk: {
var evaluated = engine.evalString(&self.env, self.allocator, expr_text) catch |err| {
const msg = try self.allocator.dupe(u8, errorStr(err));
try self.history.append(self.allocator, .{ .expr = expr_text, .result = msg, .is_error = true });
return;
};
defer evaluated.deinit();
// Conversion factors are still f64 (Task 2.0e), so collapse here.
break :blk evaluated.toFloat(self.allocator);
};
self.conv_value = value;
const pair = self.convUnits();
const converted = engine.units.convertUnits(value, pair.from, pair.to) catch |err| {
var value = engine.Number.parse(self.allocator, expr_text) catch
engine.evalString(&self.env, self.allocator, expr_text) catch |err| {
const msg = try self.allocator.dupe(u8, errorStr(err));
try self.history.append(self.allocator, .{ .expr = expr_text, .result = msg, .is_error = true });
return;
};
errdefer value.deinit();
const pair = self.convUnits();
var converted = engine.units.convertExactUnits(self.allocator, value, pair.from, pair.to) catch |err| {
value.deinit();
const msg = try self.allocator.dupe(u8, errorStr(err));
try self.history.append(self.allocator, .{ .expr = expr_text, .result = msg, .is_error = true });
return;
};
defer converted.deinit();
// Adopt the new value only once the conversion has succeeded.
self.conv_value.deinit();
self.conv_value = value;
const shown_in = try engine.formatter.formatNumber(self.allocator, self.conv_value);
defer shown_in.deinit(self.allocator);
const shown_out = try engine.formatter.formatNumber(self.allocator, converted);
defer shown_out.deinit(self.allocator);
var in_buf: [64]u8 = undefined;
var out_buf: [64]u8 = undefined;
const in_str = engine.formatter.formatCompactFloat(&in_buf, value);
const out_str = engine.formatter.formatCompactFloat(&out_buf, converted);
const result = try std.fmt.allocPrint(self.allocator, "{s} {s} = {s} {s}", .{
in_str, pair.from.name, out_str, pair.to.name,
shown_in.display, pair.from.name, shown_out.display, pair.to.name,
});
try self.history.append(self.allocator, .{ .expr = expr_text, .result = result, .is_error = false });
}
@ -943,18 +949,17 @@ pub const App = struct {
return;
};
defer evaluated.deinit();
// Conversion factors are still f64 (Task 2.0e), so collapse here.
const value = evaluated.toFloat(self.allocator);
const converted = engine.units.convertUnits(value, request.from, request.to) catch |err| {
var converted = engine.units.convertExactUnits(self.allocator, evaluated, request.from, request.to) catch |err| {
const msg = try self.allocator.dupe(u8, errorStr(err));
try self.history.append(self.allocator, .{ .expr = expr_text, .result = msg, .is_error = true });
return;
};
defer converted.deinit();
var out_buf: [64]u8 = undefined;
const out_str = engine.formatter.formatCompactFloat(&out_buf, converted);
const result = try std.fmt.allocPrint(self.allocator, "{s} {s}", .{ out_str, request.to.name });
const shown = try engine.formatter.formatNumber(self.allocator, converted);
defer shown.deinit(self.allocator);
const result = try std.fmt.allocPrint(self.allocator, "{s} {s}", .{ shown.display, request.to.name });
try self.history.append(self.allocator, .{ .expr = expr_text, .result = result, .is_error = false });
}

View file

@ -53,20 +53,24 @@ pub fn drawConvertMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height
// -- Result --
row += 2;
const converted = units.convertUnits(app.conv_value, pair.from, pair.to) catch app.conv_value;
// Exact conversion, so a terminating case like 12 in -> ft shows 1 rather
// than 0.9999999999999998.
var converted = units.convertExactUnits(app.allocator, app.conv_value, pair.from, pair.to) catch
app.conv_value.clone() catch return;
defer converted.deinit();
var in_buf: [64]u8 = undefined;
var out_buf: [64]u8 = undefined;
const in_str = engine.formatter.formatCompactFloat(&in_buf, app.conv_value);
const out_str = engine.formatter.formatCompactFloat(&out_buf, converted);
const shown_in = engine.formatter.formatNumber(app.allocator, app.conv_value) catch return;
defer shown_in.deinit(app.allocator);
const shown_out = engine.formatter.formatNumber(app.allocator, converted) catch return;
defer shown_out.deinit(app.allocator);
var line_buf: [192]u8 = undefined;
const input_line = std.fmt.bufPrint(&line_buf, "{s} {s}", .{ in_str, pair.from.name }) catch "?";
const input_line = std.fmt.bufPrint(&line_buf, "{s} {s}", .{ shown_in.display, pair.from.name }) catch "?";
draw.writeStr(surface, row, 2, input_line, .{ .fg = C.fg });
row += 1;
var result_buf: [192]u8 = undefined;
const result_line = std.fmt.bufPrint(&result_buf, "= {s} {s}", .{ out_str, pair.to.name }) catch "?";
const result_line = std.fmt.bufPrint(&result_buf, "= {s} {s}", .{ shown_out.display, pair.to.name }) catch "?";
draw.writeStr(surface, row, 2, result_line, .{ .fg = C.green, .bold = true });
row += 1;