diff --git a/.kiro/specs/calculator/design.md b/.kiro/specs/calculator/design.md index 8368b17..018a968 100644 --- a/.kiro/specs/calculator/design.md +++ b/.kiro/specs/calculator/design.md @@ -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 diff --git a/.kiro/specs/calculator/requirements.md b/.kiro/specs/calculator/requirements.md index bae04fa..d3e6718 100644 --- a/.kiro/specs/calculator/requirements.md +++ b/.kiro/specs/calculator/requirements.md @@ -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 diff --git a/.kiro/specs/calculator/tasks.md b/.kiro/specs/calculator/tasks.md index 0d20ff3..55feff0 100644 --- a/.kiro/specs/calculator/tasks.md +++ b/.kiro/specs/calculator/tasks.md @@ -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` diff --git a/engine/src/rational.zig b/engine/src/rational.zig index cbb928c..ca84605 100644 --- a/engine/src/rational.zig +++ b/engine/src/rational.zig @@ -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")); +} diff --git a/engine/src/units.zig b/engine/src/units.zig index b07a8a3..b133d39 100644 --- a/engine/src/units.zig +++ b/engine/src/units.zig @@ -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; +} diff --git a/src/main.zig b/src/main.zig index 596c672..eac9408 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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 ` ` and ` to `, and also the -/// glued form ` to ` (e.g. "100km to mi"). +/// +/// Accepts ` `, either separator word between the units +/// (` to|in `), and the glued form ` 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); +} diff --git a/src/tui.zig b/src/tui.zig index f26d628..272a1b5 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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 }); } diff --git a/src/tui/convert.zig b/src/tui/convert.zig index fea5cf7..a4f1ddf 100644 --- a/src/tui/convert.zig +++ b/src/tui/convert.zig @@ -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;