unit conversion using Number union
This commit is contained in:
parent
4eda2f62e8
commit
df69b070ef
8 changed files with 942 additions and 241 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
302
src/main.zig
302
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 `<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);
|
||||
}
|
||||
|
|
|
|||
61
src/tui.zig
61
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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue