unit conversion using "to" or "in", e.g. "12 in in ft"
This commit is contained in:
parent
7e74278827
commit
53cf1bf827
6 changed files with 493 additions and 18 deletions
|
|
@ -406,14 +406,34 @@ A single multiplicative factor is reported alongside the result only when both
|
|||
units are linear (zero offset). Temperature conversions are affine, so no single
|
||||
factor describes them and the factor is reported as null.
|
||||
|
||||
NOT YET IMPLEMENTED: the expression-level syntax below (`<expr> <unit> to
|
||||
<unit>` and unit-qualified arithmetic like `5 kg + 3 lb`) needs unit-aware
|
||||
tokenizing and evaluation. Today conversion is reached through the CLI `convert`
|
||||
subcommand and the TUI convert mode. The planned design was:
|
||||
**Free-form conversion requests** (`units.parseRequest`) let conversion be plain
|
||||
expression input rather than a special subcommand, so `tally 32F in C` and typing
|
||||
`100 km to mi` in the TUI both work:
|
||||
|
||||
- Tokenizer identifies known unit names after a numeric expression
|
||||
- `to` keyword triggers conversion mode
|
||||
- Both units must be in the same category (error otherwise)
|
||||
- A standalone, whitespace-delimited, case-insensitive `to` or `in` is the
|
||||
trigger. This needs no unit-aware tokenizer, because neither word is otherwise
|
||||
an identifier in the grammar.
|
||||
- No separator means `parseRequest` returns null and the caller falls through
|
||||
to ordinary expression evaluation, so existing behavior is untouched.
|
||||
- **`in` is both a separator and the name for inches.** Candidate separators are
|
||||
therefore tried last-to-first, keeping the first reading that yields a valid
|
||||
conversion. In `5 in in cm` the later `in` is the separator; in `100 mm in in`
|
||||
the earlier one is, because only that reading resolves. `1 in in in` is a valid
|
||||
identity. `min` is untouched because a separator must be whitespace-delimited.
|
||||
- Backtracking also improves errors: if a candidate reading fails, the reason is
|
||||
remembered, so a committed-but-invalid request reports
|
||||
`UnknownUnit`/`IncompatibleUnits` instead of a confusing parse error.
|
||||
- The trailing unit is split off by trying the **longest** suffix first, so
|
||||
`100 km` yields `("100", km)` and not `("100 k", m)`, and no space is required
|
||||
(`32F`). A candidate must start at a **token boundary** (the preceding
|
||||
character may not be a letter), which is what stops the trailing `s` of
|
||||
`100 smoots` from being read as seconds.
|
||||
- The value portion is returned **unevaluated** as text, so `parseRequest` needs
|
||||
no dependency on the evaluator and callers can evaluate it in whatever
|
||||
environment they already have. This is why `2*3 kg to lb` works.
|
||||
|
||||
NOT YET IMPLEMENTED: unit-qualified arithmetic (`5 kg + 3 lb`), which genuinely
|
||||
does need unit-aware tokenizing and evaluation. The planned approach:
|
||||
|
||||
For expressions like `5 kg + 3 lb`, the parser would:
|
||||
1. Identify `5 kg` as a unit-qualified value
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
|
|||
|
||||
### FR-4: Unit Conversions
|
||||
|
||||
- **FR-4.1**: Support inline unit conversion syntax: `<value> <from_unit> to <to_unit>` (e.g., `100 km to miles`, `72 F to C`). NOT YET IMPLEMENTED as expression syntax; currently provided by the CLI `convert` subcommand (FR-4.15) and the TUI convert mode (FR-4.16).
|
||||
- **FR-4.2**: Support unit conversions within expressions: `5 kg + 3 lb` evaluates in the left-hand unit. NOT YET IMPLEMENTED (requires unit-aware tokenizing and evaluation).
|
||||
- **FR-4.1**: Support inline unit conversion syntax: `<value> <from_unit> to|in <to_unit>` (e.g., `100 km to mi`, `32F in C`). IMPLEMENTED as ordinary expression input, so no subcommand is needed: `tally 32F in C` works, as does typing it in the TUI standard mode. The value may itself be an expression (`2*3 kg to lb`).
|
||||
- **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).
|
||||
|
|
@ -81,11 +81,13 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
|
|||
- **FR-4.12**: Pressure: Pa, kPa, MPa, bar, mbar, atm, psi, torr, mmHg, inHg. Base unit: Pa.
|
||||
- **FR-4.13**: Data rate: bps, kbps, Mbps, Gbps, Tbps, Bps, kBps, MBps, GBps. Base unit: bps.
|
||||
- **FR-4.14**: Angle: rad, mrad, deg, grad, turn, arcmin, arcsec. Base unit: rad.
|
||||
- **FR-4.15**: In CLI, support `tally convert <value> <from> <to>`, `tally convert <value> <from> to <to>`, and the glued form `tally convert 100km to mi`.
|
||||
- **FR-4.15**: In CLI, unit conversion is plain expression input (FR-4.1): `tally 100 km to mi`, `tally 32F to C`. A `convert` subcommand is also accepted for explicitness and scripting (`tally convert 100 km mi`, with or without the `to`), but it is a convenience, not the only route.
|
||||
- **FR-4.16**: In TUI, a dedicated convert mode reachable from the mode tabs.
|
||||
- **FR-4.17**: Unit system extensible - adding a unit or category requires only a table entry, no parser changes.
|
||||
- **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-5: Financial Mode
|
||||
|
||||
|
|
|
|||
|
|
@ -146,14 +146,18 @@ function arg commas).
|
|||
- Temperature is the only affine category (F/K/R need offsets); base is Celsius
|
||||
- `findUnit`: exact case-sensitive match first, case-insensitive fallback, so
|
||||
`K`/`B`/`kB` keep their meaning while `KM` and `Celsius` still resolve
|
||||
- `parseRequest`: recognizes free-form `<value> <unit> to <unit>` text so
|
||||
conversion is plain expression input, not a special command (see Task 4.4).
|
||||
Returns null with no `to` keyword so callers fall through to normal evaluation.
|
||||
- DESIGN CHANGE from the original spec: only the to-base mapping is stored and
|
||||
the inverse is derived in `fromBase()`, instead of storing a second
|
||||
factor/offset pair. One source of truth per unit means a table entry cannot
|
||||
disagree with its own reverse (see design.md 4.1)
|
||||
- 77 unit tests: known-answer conversions per category, temperature offsets and
|
||||
the -40 crossover, alias resolution, error paths, plus invariant tests (every
|
||||
unit round-trips, every within-category pair round-trips, base units are
|
||||
identity, names unique, no zero factors)
|
||||
- 100 unit tests: known-answer conversions per category, temperature offsets and
|
||||
the -40 crossover, alias resolution, error paths, request parsing (glued
|
||||
values, expression values, longest-suffix and token-boundary rules), plus
|
||||
invariant tests (every unit round-trips, every within-category pair
|
||||
round-trips, base units are identity, names unique, no zero factors)
|
||||
- Verify: `zig build test` passes; CLI spot-checks match reference values
|
||||
(5280 ft/mi, 1024 MiB/GiB, 14.6959 psi/atm, 1 c = 1079252848.8 km/h)
|
||||
|
||||
|
|
@ -231,6 +235,27 @@ Remaining subcommands deferred until their engine modules exist.
|
|||
- Set exit codes: 0 success, 1 error
|
||||
- Verify: unit tests for error paths
|
||||
|
||||
|
||||
### Task 4.4: Bare-expression unit conversion [DONE]
|
||||
- `tally 32F in C` and `tally 100 km to mi` work without any subcommand; the
|
||||
`convert` subcommand remains as an explicit alias for scripting
|
||||
- Both `to` and `in` act as separators
|
||||
- Implemented in the engine (`units.parseRequest`) rather than the CLI, so the TUI
|
||||
standard mode gets the same behavior: typing `100 km to mi` at the prompt
|
||||
converts and records it in history
|
||||
- Value may be a full expression (`2*3 kg to lb`), evaluated by the normal
|
||||
evaluator before conversion
|
||||
- `in` doubles as the name for inches, so candidate separators are tried
|
||||
last-to-first and the first valid reading wins: `5 in in cm`, `100 mm in in`,
|
||||
`1 in in in`, and `60 min in h` all resolve correctly
|
||||
- Detection is conservative: only a standalone whitespace-delimited separator
|
||||
triggers it, unit suffixes must start at a token boundary, and no separator
|
||||
means normal evaluation. Verified no regression on `2 + 3 * 4`, `sqrt(144)`,
|
||||
`e`, `min(3, 7)`, `sin(0)`, `0o777 - 0x0f`
|
||||
- 30 request-parsing tests plus 7 CLI-level tests
|
||||
- Verify: bare conversions, expression values, inch ambiguity, error paths, and
|
||||
unchanged behavior for ordinary expressions all confirmed by running the binary
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: TUI Frontend
|
||||
|
|
|
|||
|
|
@ -370,6 +370,144 @@ pub fn convert(value: f64, from_name: []const u8, to_name: []const u8) CalcError
|
|||
};
|
||||
}
|
||||
|
||||
// -- Conversion request parsing --
|
||||
|
||||
/// A conversion parsed out of free-form text like "100 km to mi".
|
||||
///
|
||||
/// `value_text` is the part before the unit and is left unevaluated: it may be a
|
||||
/// plain number or a whole expression ("2*3 km to ft"). Keeping evaluation out of
|
||||
/// this module avoids a dependency on the evaluator, so callers evaluate it with
|
||||
/// whatever environment they already have.
|
||||
pub const ConversionRequest = struct {
|
||||
value_text: []const u8,
|
||||
from: UnitDef,
|
||||
to: UnitDef,
|
||||
};
|
||||
|
||||
const whitespace = " \t";
|
||||
|
||||
/// Byte range of a matched keyword.
|
||||
const Span = struct { start: usize, end: usize };
|
||||
|
||||
/// Words that separate the source from the target in a conversion request.
|
||||
///
|
||||
/// `in` reads more naturally than `to` for some phrasings ("32F in C"), but it is
|
||||
/// also the canonical name for inches. That collision is handled by trying
|
||||
/// candidate separators last-to-first and keeping the first one that yields a
|
||||
/// valid conversion, so "5 in in cm" and "100 mm in in" both resolve correctly.
|
||||
const separator_words = [_][]const u8{ "to", "in" };
|
||||
|
||||
/// Most inputs have one separator; a handful could have two ("5 in in cm").
|
||||
const max_separators = 8;
|
||||
|
||||
fn isBoundary(c: u8) bool {
|
||||
return std.mem.indexOfScalar(u8, whitespace, c) != null;
|
||||
}
|
||||
|
||||
/// Collect every standalone separator word in `text`, in order.
|
||||
///
|
||||
/// A separator must be surrounded by whitespace, so words that merely contain
|
||||
/// the letters are never split: `torr` and `turn` keep their `t`/`to`, and
|
||||
/// `min(1,2)` keeps its `in`.
|
||||
fn collectSeparators(text: []const u8, out: *[max_separators]Span) usize {
|
||||
var count: usize = 0;
|
||||
// Cannot start at 0: a separator needs whitespace in front of it.
|
||||
var i: usize = 1;
|
||||
while (i + 2 < text.len) : (i += 1) {
|
||||
if (!isBoundary(text[i - 1])) continue;
|
||||
if (!isBoundary(text[i + 2])) continue;
|
||||
const word = text[i .. i + 2];
|
||||
var matched = false;
|
||||
for (separator_words) |candidate| {
|
||||
if (std.ascii.eqlIgnoreCase(word, candidate)) matched = true;
|
||||
}
|
||||
if (!matched) continue;
|
||||
if (count < out.len) {
|
||||
out[count] = .{ .start = i, .end = i + 2 };
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Split trailing unit text off the end of a value expression.
|
||||
///
|
||||
/// Tries the longest suffix first, so "100 km" yields ("100", km) rather than
|
||||
/// ("100 k", m), and "32F" yields ("32", F) with no space required.
|
||||
///
|
||||
/// A candidate must begin at a token boundary: the preceding character may not
|
||||
/// be a letter. Without that rule the scan finds units buried inside words, e.g.
|
||||
/// taking the trailing "s" of "100 smoots" as seconds.
|
||||
fn splitTrailingUnit(text: []const u8) ?struct { value_text: []const u8, unit: UnitDef } {
|
||||
var i: usize = 0;
|
||||
while (i < text.len) : (i += 1) {
|
||||
// The candidate must start exactly at i, so skip whitespace positions.
|
||||
if (std.mem.indexOfScalar(u8, whitespace, text[i]) != null) continue;
|
||||
if (i > 0 and std.ascii.isAlphabetic(text[i - 1])) continue;
|
||||
|
||||
const candidate = std.mem.trim(u8, text[i..], whitespace);
|
||||
if (candidate.len == 0) break;
|
||||
const unit = findUnit(candidate) orelse continue;
|
||||
|
||||
const value_text = std.mem.trim(u8, text[0..i], whitespace);
|
||||
// A unit with nothing in front of it is not a conversion we can do.
|
||||
if (value_text.len == 0) return null;
|
||||
return .{ .value_text = value_text, .unit = unit };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Recognize free-form conversion text such as "100 km to mi", "32F in C", or
|
||||
/// "2*3 kg to lb". Both `to` and `in` act as separators.
|
||||
///
|
||||
/// Returns null when the text contains no standalone separator, which lets
|
||||
/// callers fall through to ordinary expression evaluation. When a separator IS
|
||||
/// present the text is committed to being a conversion, so unresolvable units
|
||||
/// report `UnknownUnit`/`IncompatibleUnits` rather than surfacing a confusing
|
||||
/// parse error.
|
||||
///
|
||||
/// Candidate separators are tried last-to-first, keeping the first that yields a
|
||||
/// valid conversion. That is what disambiguates `in` the separator from `in` the
|
||||
/// unit: in "5 in in cm" the later `in` is the separator, while in "100 mm in in"
|
||||
/// the earlier one is, because only that reading resolves.
|
||||
pub fn parseRequest(text: []const u8) CalcError!?ConversionRequest {
|
||||
var separators: [max_separators]Span = undefined;
|
||||
const count = collectSeparators(text, &separators);
|
||||
if (count == 0) return null;
|
||||
|
||||
// Remember why the most recent candidate failed, so a committed-but-invalid
|
||||
// request reports a useful error instead of a parse error.
|
||||
var failure: ?CalcError = null;
|
||||
|
||||
var idx = count;
|
||||
while (idx > 0) {
|
||||
idx -= 1;
|
||||
const sep = separators[idx];
|
||||
|
||||
const left = std.mem.trim(u8, text[0..sep.start], whitespace);
|
||||
const right = std.mem.trim(u8, text[sep.end..], whitespace);
|
||||
if (left.len == 0 or right.len == 0) continue;
|
||||
|
||||
const to_unit = findUnit(right) orelse {
|
||||
failure = CalcError.UnknownUnit;
|
||||
continue;
|
||||
};
|
||||
const split = splitTrailingUnit(left) orelse {
|
||||
failure = CalcError.UnknownUnit;
|
||||
continue;
|
||||
};
|
||||
if (split.unit.category != to_unit.category) {
|
||||
failure = CalcError.IncompatibleUnits;
|
||||
continue;
|
||||
}
|
||||
|
||||
return .{ .value_text = split.value_text, .from = split.unit, .to = to_unit };
|
||||
}
|
||||
|
||||
if (failure) |err| return err;
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Tests --
|
||||
|
||||
const testing = std.testing;
|
||||
|
|
@ -775,3 +913,172 @@ test "isLinear: only temperature units carry offsets" {
|
|||
try testing.expect(!findUnit("F").?.isLinear());
|
||||
try testing.expect(!findUnit("K").?.isLinear());
|
||||
}
|
||||
|
||||
// -- Conversion request parsing tests --
|
||||
|
||||
fn expectRequest(text: []const u8, value_text: []const u8, from: []const u8, to: []const u8) !void {
|
||||
const req = (try parseRequest(text)) orelse return error.ExpectedRequest;
|
||||
try testing.expectEqualStrings(value_text, req.value_text);
|
||||
try testing.expectEqualStrings(from, req.from.name);
|
||||
try testing.expectEqualStrings(to, req.to.name);
|
||||
}
|
||||
|
||||
test "parseRequest: spaced form" {
|
||||
try expectRequest("100 km to mi", "100", "km", "mi");
|
||||
}
|
||||
|
||||
test "parseRequest: glued value and unit" {
|
||||
try expectRequest("32F to C", "32", "F", "C");
|
||||
try expectRequest("100km to mi", "100", "km", "mi");
|
||||
}
|
||||
|
||||
test "parseRequest: expression as the value" {
|
||||
try expectRequest("2*3 kg to lb", "2*3", "kg", "lb");
|
||||
try expectRequest("(1+1) m to cm", "(1+1)", "m", "cm");
|
||||
}
|
||||
|
||||
test "parseRequest: long unit names and plurals" {
|
||||
try expectRequest("1 kilometer to miles", "1", "km", "mi");
|
||||
try expectRequest("5 inches to cm", "5", "in", "cm");
|
||||
}
|
||||
|
||||
test "parseRequest: keyword is case-insensitive" {
|
||||
try expectRequest("100 km TO mi", "100", "km", "mi");
|
||||
try expectRequest("100 km To mi", "100", "km", "mi");
|
||||
}
|
||||
|
||||
test "parseRequest: prefers the longest unit suffix" {
|
||||
// Must not split "km" into "k" + "m"
|
||||
try expectRequest("1 km to m", "1", "km", "m");
|
||||
// "nmi" must win over "mi"
|
||||
try expectRequest("1 nmi to m", "1", "nmi", "m");
|
||||
}
|
||||
|
||||
test "parseRequest: compound unit names with slashes and digits" {
|
||||
try expectRequest("60 km/h to mph", "60", "km/h", "mph");
|
||||
try expectRequest("1 m2 to cm2", "1", "m2", "cm2");
|
||||
try expectRequest("1 ft/s to m/s", "1", "ft/s", "m/s");
|
||||
}
|
||||
|
||||
test "parseRequest: negative and decimal values" {
|
||||
try expectRequest("-40 C to F", "-40", "C", "F");
|
||||
try expectRequest("2.5 cup to mL", "2.5", "cup", "mL");
|
||||
}
|
||||
|
||||
test "parseRequest: extra whitespace is tolerated" {
|
||||
try expectRequest(" 100 km to mi ", "100", "km", "mi");
|
||||
}
|
||||
|
||||
test "parseRequest: no keyword returns null so callers fall through" {
|
||||
try testing.expect((try parseRequest("2 + 2")) == null);
|
||||
try testing.expect((try parseRequest("100 km")) == null);
|
||||
try testing.expect((try parseRequest("sqrt(144)")) == null);
|
||||
try testing.expect((try parseRequest("")) == null);
|
||||
}
|
||||
|
||||
test "parseRequest: 'to' inside a word is not a keyword" {
|
||||
// torr, turn, atm all contain no standalone "to", and neither does this
|
||||
try testing.expect((try parseRequest("1 torr")) == null);
|
||||
try testing.expect((try parseRequest("tomorrow")) == null);
|
||||
// A unit named with "to" inside still converts via the real keyword
|
||||
try expectRequest("1 atm to torr", "1", "atm", "torr");
|
||||
}
|
||||
|
||||
test "parseRequest: unknown target unit errors" {
|
||||
try testing.expectError(CalcError.UnknownUnit, parseRequest("100 km to smoots"));
|
||||
}
|
||||
|
||||
test "parseRequest: unknown source unit errors" {
|
||||
try testing.expectError(CalcError.UnknownUnit, parseRequest("100 smoots to km"));
|
||||
}
|
||||
|
||||
test "parseRequest: missing value errors" {
|
||||
// "km to mi" has a unit with no value in front of it
|
||||
try testing.expectError(CalcError.UnknownUnit, parseRequest("km to mi"));
|
||||
}
|
||||
|
||||
test "parseRequest: mismatched categories error" {
|
||||
try testing.expectError(CalcError.IncompatibleUnits, parseRequest("1 kg to m"));
|
||||
try testing.expectError(CalcError.IncompatibleUnits, parseRequest("32F to km"));
|
||||
}
|
||||
|
||||
test "parseRequest: dangling keyword returns null" {
|
||||
try testing.expect((try parseRequest("100 km to")) == null);
|
||||
try testing.expect((try parseRequest("to mi")) == null);
|
||||
}
|
||||
|
||||
test "parseRequest: result feeds convertUnits correctly" {
|
||||
const req = (try parseRequest("32F to C")).?;
|
||||
const result = try convertUnits(32, req.from, req.to);
|
||||
try testing.expectApproxEqAbs(@as(f64, 0), result, 1e-12);
|
||||
}
|
||||
|
||||
test "parseRequest: does not find a unit buried inside a word" {
|
||||
// The trailing "s" of "smoots" must not be read as seconds.
|
||||
try testing.expectError(CalcError.UnknownUnit, parseRequest("100 smoots to km"));
|
||||
try testing.expectError(CalcError.UnknownUnit, parseRequest("5 bananas to kg"));
|
||||
}
|
||||
|
||||
test "parseRequest: glued digit boundary still works" {
|
||||
// A digit before the unit is a valid boundary, unlike a letter.
|
||||
try expectRequest("100m to ft", "100", "m", "ft");
|
||||
try expectRequest("1e3m to km", "1e3", "m", "km");
|
||||
}
|
||||
|
||||
// -- "in" as a separator synonym --
|
||||
|
||||
test "parseRequest: in works as a separator" {
|
||||
try expectRequest("32F in C", "32", "F", "C");
|
||||
try expectRequest("100 km in mi", "100", "km", "mi");
|
||||
try expectRequest("1 kg in lb", "1", "kg", "lb");
|
||||
}
|
||||
|
||||
test "parseRequest: in separator is case-insensitive" {
|
||||
try expectRequest("32F IN C", "32", "F", "C");
|
||||
try expectRequest("32F In C", "32", "F", "C");
|
||||
}
|
||||
|
||||
test "parseRequest: inches as the target unit after an in separator" {
|
||||
// The trailing "in" is the unit; the earlier one is the separator.
|
||||
try expectRequest("100 mm in in", "100", "mm", "in");
|
||||
try expectRequest("5 cm in in", "5", "cm", "in");
|
||||
}
|
||||
|
||||
test "parseRequest: inches as the source unit before an in separator" {
|
||||
// Here the LATER "in" is the separator and the earlier one is the unit.
|
||||
try expectRequest("5 in in cm", "5", "in", "cm");
|
||||
try expectRequest("12 in in ft", "12", "in", "ft");
|
||||
}
|
||||
|
||||
test "parseRequest: inches with the to separator is unambiguous" {
|
||||
try expectRequest("5 in to cm", "5", "in", "cm");
|
||||
try expectRequest("100 mm to in", "100", "mm", "in");
|
||||
}
|
||||
|
||||
test "parseRequest: inch to inch is a valid identity" {
|
||||
try expectRequest("1 in in in", "1", "in", "in");
|
||||
}
|
||||
|
||||
test "parseRequest: separator inside a word is not a separator" {
|
||||
// "min" contains "in" but is not whitespace-delimited.
|
||||
try testing.expect((try parseRequest("min(1, 2)")) == null);
|
||||
try testing.expect((try parseRequest("1 min")) == null);
|
||||
// A real separator still works alongside such a unit.
|
||||
try expectRequest("60 min in h", "60", "min", "h");
|
||||
}
|
||||
|
||||
test "parseRequest: bare unit without a separator falls through" {
|
||||
try testing.expect((try parseRequest("5 in")) == null);
|
||||
try testing.expect((try parseRequest("5 to")) == null);
|
||||
}
|
||||
|
||||
test "parseRequest: trailing whitespace after the separator still resolves" {
|
||||
// The final "in" gains trailing whitespace here, so both occurrences are
|
||||
// candidates; only the earlier one yields a valid conversion.
|
||||
try expectRequest("5 cm in in ", "5", "cm", "in");
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
|
|
|
|||
97
src/main.zig
97
src/main.zig
|
|
@ -125,19 +125,27 @@ fn splitValueAndUnit(token: []const u8) ?struct { number: []const u8, unit: []co
|
|||
return .{ .number = token[0..i], .unit = token[i..] };
|
||||
}
|
||||
|
||||
/// Format a unit conversion result.
|
||||
/// 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 };
|
||||
};
|
||||
return formatConversionUnits(buf, value, result.from, result.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| {
|
||||
return .{ .output = errorMessage(err), .is_error = true };
|
||||
};
|
||||
|
||||
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, result.value);
|
||||
const out_str = engine.formatter.formatCompactFloat(&out_buf, converted);
|
||||
|
||||
const output = std.fmt.bufPrint(buf, "{s} {s} = {s} {s}", .{
|
||||
in_str, result.from.name, out_str, result.to.name,
|
||||
in_str, from.name, out_str, to.name,
|
||||
}) catch {
|
||||
return .{ .output = "error: buffer overflow\n", .is_error = true };
|
||||
};
|
||||
|
|
@ -157,6 +165,19 @@ pub fn evaluate(allocator: std.mem.Allocator, expression: []const u8, mode: engi
|
|||
var env = engine.Environment.init(allocator, .standard);
|
||||
defer env.deinit();
|
||||
|
||||
// A standalone "to" keyword means this is a unit conversion, e.g.
|
||||
// "32F to C". Anything without it falls through to normal evaluation.
|
||||
if (engine.units.parseRequest(expression)) |maybe_request| {
|
||||
if (maybe_request) |request| {
|
||||
const value = engine.evalString(&env, allocator, request.value_text) catch |err| {
|
||||
return .{ .output = errorMessage(err), .is_error = true };
|
||||
};
|
||||
return formatConversionUnits(buf, value, request.from, request.to);
|
||||
}
|
||||
} else |err| {
|
||||
return .{ .output = errorMessage(err), .is_error = true };
|
||||
}
|
||||
|
||||
const info = engine.evalStringInfo(&env, allocator, expression) catch |err| {
|
||||
return .{ .output = errorMessage(err), .is_error = true };
|
||||
};
|
||||
|
|
@ -270,7 +291,6 @@ const help_text =
|
|||
\\tally - a cross-platform calculator
|
||||
\\
|
||||
\\Usage: tally [OPTIONS] <expression>
|
||||
\\ tally convert <value> <from-unit> [to] <to-unit>
|
||||
\\
|
||||
\\Options:
|
||||
\\ -p, --programmer Programmer mode (^ = power, xor = XOR)
|
||||
|
|
@ -280,6 +300,11 @@ const help_text =
|
|||
\\Examples:
|
||||
\\ tally '2 + 3 * 4'
|
||||
\\ tally -p '0xFF and 0x0F'
|
||||
\\ tally 100 km to mi Unit conversion ('to' or 'in')
|
||||
\\ tally 32F in C
|
||||
\\ tally '2*3 kg to lb' The value may be an expression
|
||||
\\
|
||||
\\A 'convert' subcommand is also accepted for explicitness:
|
||||
\\ tally convert 100 km to mi
|
||||
\\
|
||||
\\Run with no arguments to start the interactive TUI.
|
||||
|
|
@ -626,3 +651,67 @@ test "formatConversion: alias resolves to canonical name in output" {
|
|||
try testing.expect(!result.is_error);
|
||||
try testing.expectEqualStrings("1 km = 1000 m", result.output);
|
||||
}
|
||||
|
||||
test "evaluate: bare conversion expression without subcommand" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer _ = arena.deinit();
|
||||
var buf: [4096]u8 = undefined;
|
||||
const result = evaluate(arena.allocator(), "100 km to mi", .standard, &buf);
|
||||
try testing.expect(!result.is_error);
|
||||
try testing.expect(std.mem.indexOf(u8, result.output, "62.137") != null);
|
||||
}
|
||||
|
||||
test "evaluate: glued bare conversion" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer _ = arena.deinit();
|
||||
var buf: [4096]u8 = undefined;
|
||||
const result = evaluate(arena.allocator(), "32F to C", .standard, &buf);
|
||||
try testing.expect(!result.is_error);
|
||||
try testing.expectEqualStrings("32 F = 0 C", result.output);
|
||||
}
|
||||
|
||||
test "evaluate: conversion value may be an expression" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer _ = arena.deinit();
|
||||
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);
|
||||
}
|
||||
|
||||
test "evaluate: bare conversion with unknown unit errors" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer _ = arena.deinit();
|
||||
var buf: [4096]u8 = undefined;
|
||||
const result = evaluate(arena.allocator(), "100 km to smoots", .standard, &buf);
|
||||
try testing.expect(result.is_error);
|
||||
try testing.expect(std.mem.indexOf(u8, result.output, "unknown unit") != null);
|
||||
}
|
||||
|
||||
test "evaluate: bare conversion with incompatible units errors" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer _ = arena.deinit();
|
||||
var buf: [4096]u8 = undefined;
|
||||
const result = evaluate(arena.allocator(), "1 kg to m", .standard, &buf);
|
||||
try testing.expect(result.is_error);
|
||||
try testing.expect(std.mem.indexOf(u8, result.output, "incompatible") != null);
|
||||
}
|
||||
|
||||
test "evaluate: ordinary expressions are unaffected by conversion detection" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer _ = arena.deinit();
|
||||
var buf: [4096]u8 = undefined;
|
||||
const result = evaluate(arena.allocator(), "2 + 3 * 4", .standard, &buf);
|
||||
try testing.expect(!result.is_error);
|
||||
try testing.expectEqualStrings("14", result.output);
|
||||
}
|
||||
|
||||
test "evaluate: expression containing a unit-like name still evaluates" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer _ = arena.deinit();
|
||||
var buf: [4096]u8 = undefined;
|
||||
// "e" is Euler's number here, not a unit, because there is no "to" keyword.
|
||||
const result = evaluate(arena.allocator(), "e", .standard, &buf);
|
||||
try testing.expect(!result.is_error);
|
||||
try testing.expect(std.mem.startsWith(u8, result.output, "2.718"));
|
||||
}
|
||||
|
|
|
|||
32
src/tui.zig
32
src/tui.zig
|
|
@ -830,6 +830,19 @@ pub const App = struct {
|
|||
}
|
||||
|
||||
fn submitStandard(self: *App, expr_text: []const u8) !void {
|
||||
// A standalone "to" keyword makes this a unit conversion, e.g.
|
||||
// "100 km to mi". Anything else falls through to normal evaluation.
|
||||
if (engine.units.parseRequest(expr_text)) |maybe_request| {
|
||||
if (maybe_request) |request| {
|
||||
try self.submitStandardConversion(expr_text, request);
|
||||
return;
|
||||
}
|
||||
} else |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;
|
||||
}
|
||||
|
||||
const info = engine.evalStringInfo(&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 });
|
||||
|
|
@ -913,6 +926,25 @@ pub const App = struct {
|
|||
try self.history.append(self.allocator, .{ .expr = expr_text, .result = result, .is_error = false });
|
||||
}
|
||||
|
||||
/// Evaluate and record a standard-mode unit conversion ("100 km to mi").
|
||||
fn submitStandardConversion(self: *App, expr_text: []const u8, request: engine.units.ConversionRequest) !void {
|
||||
const value = engine.evalString(&self.env, self.allocator, request.value_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;
|
||||
};
|
||||
const converted = engine.units.convertUnits(value, 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;
|
||||
};
|
||||
|
||||
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 });
|
||||
try self.history.append(self.allocator, .{ .expr = expr_text, .result = result, .is_error = false });
|
||||
}
|
||||
|
||||
fn submitProgrammer(self: *App, expr_text: []const u8) !void {
|
||||
const is_error, const display_text = if (engine.evalProgrammerString(self.allocator, expr_text, self.prog_config)) |int| blk: {
|
||||
self.prog_value = int.unsignedValue();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue