From 53cf1bf827da386ff8dd5474b310e1716cfa563b Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 27 Jul 2026 17:20:28 -0700 Subject: [PATCH] unit conversion using "to" or "in", e.g. "12 in in ft" --- .kiro/specs/calculator/design.md | 34 ++- .kiro/specs/calculator/requirements.md | 8 +- .kiro/specs/calculator/tasks.md | 33 ++- engine/src/units.zig | 307 +++++++++++++++++++++++++ src/main.zig | 97 +++++++- src/tui.zig | 32 +++ 6 files changed, 493 insertions(+), 18 deletions(-) diff --git a/.kiro/specs/calculator/design.md b/.kiro/specs/calculator/design.md index 7910719..78a8baf 100644 --- a/.kiro/specs/calculator/design.md +++ b/.kiro/specs/calculator/design.md @@ -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 (` to -` 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 diff --git a/.kiro/specs/calculator/requirements.md b/.kiro/specs/calculator/requirements.md index ebd69dc..0dab1e9 100644 --- a/.kiro/specs/calculator/requirements.md +++ b/.kiro/specs/calculator/requirements.md @@ -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: ` to ` (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: ` to|in ` (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 `, `tally convert 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 diff --git a/.kiro/specs/calculator/tasks.md b/.kiro/specs/calculator/tasks.md index 4b18ba5..6cffd85 100644 --- a/.kiro/specs/calculator/tasks.md +++ b/.kiro/specs/calculator/tasks.md @@ -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 ` to ` 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 diff --git a/engine/src/units.zig b/engine/src/units.zig index db27395..b07a8a3 100644 --- a/engine/src/units.zig +++ b/engine/src/units.zig @@ -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")); +} diff --git a/src/main.zig b/src/main.zig index 6b902f3..6e60824 100644 --- a/src/main.zig +++ b/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] - \\ tally convert [to] \\ \\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")); +} diff --git a/src/tui.zig b/src/tui.zig index 8200104..49b6224 100644 --- a/src/tui.zig +++ b/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();