diff --git a/.kiro/specs/calculator/requirements.md b/.kiro/specs/calculator/requirements.md index 192c98d..dd5c2aa 100644 --- a/.kiro/specs/calculator/requirements.md +++ b/.kiro/specs/calculator/requirements.md @@ -108,7 +108,7 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm - **FR-6.1**: Invoke as `tally ""` for standard mode evaluation. - **FR-6.2**: Flag `--programmer` or `-p` for programmer mode: `tally -p "0xFF & 0x0F"`. -- **FR-6.3**: Flag `--bits <8|16|32|64>` to set bit width in programmer mode (default: 64). +- **FR-6.3**: Flag `--bits <8|16|32|64|128>` to set bit width in programmer mode (default: 64). Accepted as `--bits 8` or `--bits=8`. It implies `-p`, because standard mode is fixed at 64-bit signed and the flag would mean nothing there. - **FR-6.4**: Flag `--base ` to control primary output format (default: show all). - **FR-6.5**: Subcommand `tally struct ` for struct layout - accepts inline DSL string or path to a file containing the definition. - **FR-6.6**: Subcommand `tally cagr ` for quick CAGR. @@ -117,6 +117,10 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm - **FR-6.9**: Output format flags: `--json` for machine-readable output, plain text default. - **FR-6.10**: Exit code 0 on success, non-zero on parse/evaluation errors with stderr message. - **FR-6.11**: Subcommand `tally amort [payment]` prints an amortization schedule as a table. Flags: `--monthly` (the rate given is an annual nominal rate charged over monthly periods, so rate/12 applies per period), `--summary` (totals only, no per-period rows), `--exact` (skip cent rounding). A table is the one financial output that cannot be an expression function, which is why it is a subcommand. +- **FR-6.12**: Flags `--signed` (default) and `--unsigned` set how programmer-mode values are interpreted, which decides both the displayed rows and whether `>>` extends the sign. Both imply `-p`. +- **FR-6.13**: Flag `--endian ` (also `be`/`le`, case-insensitive) sets the byte order of the hex and ASCII rows, matching the TUI's Ctrl-E toggle. Default big-endian, so the hex row reads as the number itself (FR-2.8). Implies `-p`. +- **FR-6.14**: Every subcommand answers `-h`/`--help` with its own usage on stdout and exit code 0. Getting a subcommand's arguments wrong prints the same usage on stderr with a non-zero exit code. +- **FR-6.15**: An unrecognized flag is an error naming `tally --help`, not part of the expression. `tally --bit 8 '1'` used to be evaluated as the expression `--bit 8 1` and reported as an unexpected token. ### FR-7: TUI Frontend diff --git a/.kiro/specs/calculator/tasks.md b/.kiro/specs/calculator/tasks.md index 035e5bd..aeedb7d 100644 --- a/.kiro/specs/calculator/tasks.md +++ b/.kiro/specs/calculator/tasks.md @@ -756,6 +756,30 @@ Remaining subcommands deferred until their engine modules exist. - NOT DONE: mouse wheel scrolling for history - Verify: help overlay works, mouse interactions work, looks reasonable in 80x24 terminal +### Task 5.11: CLI programmer-mode flags and subcommand help + +The CLI could reach programmer mode with `-p` but not configure it, so the width, +signedness and byte order the TUI exposes were unreachable from the command line. + +- `--bits <8|16|32|64|128>` (FR-6.3), `--signed`/`--unsigned` (FR-6.12) and + `--endian ` (FR-6.13). Each accepts the separated and `=` spellings + and implies `-p`, since standard mode is fixed at 64-bit signed. +- The accepted widths come from the `BitWidth` enum rather than a hand-written list, + and a comptime check fails the build if the enum changes without the help text + being updated. +- `-h`/`--help` on every subcommand, on stdout with exit code 0 (FR-6.14). The usage + text used to be reachable only by getting the arguments wrong. +- An unrecognized long flag is now an error naming `tally --help` instead of being + joined into the expression (FR-6.15): `tally --bit 8 '1'` reported "unexpected + token", which pointed at the wrong thing. +- Verify: 938 tests pass, including width/signedness/endianness reaching the + formatted result and every rejected flag spelling. Checked by hand: + `tally --bits 8 '0xFF + 1'` is 0, `--bits 8 --signed '0xFF >> 1'` is -1 while + `--unsigned` gives 127, `--endian little '0xDEAD'` reverses the hex row. + +NOT DONE, still open from FR-6: `--base` (FR-6.4), `--json` (FR-6.9), and the +`struct`, `cagr` and `tvm` subcommands (FR-6.5 to FR-6.7). + ### Task 5.10: Act on the full-codebase review [PARTIAL] A four-part review of the whole codebase (numeric core, language layer, domain diff --git a/src/main.zig b/src/main.zig index db531cd..92bb94c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -14,6 +14,10 @@ pub const ParsedArgs = union(enum) { expression: struct { text: []const u8, mode: engine.Mode, + /// Width, signedness and byte order for programmer mode. The CLI accepts + /// the same settings the TUI has, so a session in one can be reproduced in + /// the other. + config: engine.types.ProgrammerConfig = .{}, }, conversion: struct { /// Kept as text so it can be parsed exactly rather than through f64. @@ -34,6 +38,7 @@ pub const ParsedArgs = union(enum) { pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedArgs { var mode: engine.Mode = .standard; + var config: engine.types.ProgrammerConfig = .{}; var expr_parts = std.ArrayList([]const u8).empty; defer expr_parts.deinit(allocator); @@ -49,7 +54,9 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedA return parseAmortArgs(args[1..]); } - for (args) |arg| { + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; if (std.mem.eql(u8, arg, "-p") or std.mem.eql(u8, arg, "--programmer")) { mode = .programmer; } else if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) { @@ -59,7 +66,43 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedA } }; } else if (std.mem.eql(u8, arg, "--version")) { return .{ .output = .{ .text = "tally 0.1.0\n", .is_error = false } }; + } else if (std.mem.eql(u8, arg, "--signed")) { + config.signedness = .signed; + mode = .programmer; + } else if (std.mem.eql(u8, arg, "--unsigned")) { + config.signedness = .unsigned; + mode = .programmer; } else { + // Flags that take a value, accepted as either `--bits 8` or `--bits=8`. + switch (flagValue(arg, "--bits", args, &i)) { + .absent => {}, + .missing => return .{ .output = .{ .text = bits_usage, .is_error = true } }, + .value => |text| { + config.bit_width = parseBitWidth(text) orelse + return .{ .output = .{ .text = bits_usage, .is_error = true } }; + // The width only means something in programmer mode: standard + // mode is fixed at 64-bit signed. Asking for a width is asking + // for programmer mode, so it implies -p rather than erroring. + mode = .programmer; + continue; + }, + } + switch (flagValue(arg, "--endian", args, &i)) { + .absent => {}, + .missing => return .{ .output = .{ .text = endian_usage, .is_error = true } }, + .value => |text| { + config.display_endian = parseEndian(text) orelse + return .{ .output = .{ .text = endian_usage, .is_error = true } }; + mode = .programmer; + continue; + }, + } + // An unrecognised long flag is a mistake, not part of the expression. + // `--bit 8` used to be evaluated as an expression and reported as an + // unexpected token. + if (std.mem.startsWith(u8, arg, "--")) { + return .{ .output = .{ .text = unknown_flag_usage, .is_error = true } }; + } expr_parts.append(allocator, arg) catch { return .{ .output = .{ .text = "error: out of memory\n", .is_error = true } }; }; @@ -74,9 +117,96 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedA return .{ .output = .{ .text = "error: out of memory\n", .is_error = true } }; }; - return .{ .expression = .{ .text = expression, .mode = mode } }; + return .{ .expression = .{ .text = expression, .mode = mode, .config = config } }; } +/// The value of a `--name value` or `--name=value` flag. +const FlagValue = union(enum) { + /// This argument is not that flag. + absent, + /// It is that flag, but no value followed it. + missing, + value: []const u8, +}; + +/// Read the value of a flag, consuming the following argument in the separated +/// form. `index` advances only when the value was taken from the next argument. +fn flagValue(arg: []const u8, name: []const u8, args: []const []const u8, index: *usize) FlagValue { + if (!std.mem.startsWith(u8, arg, name)) return .absent; + if (arg.len == name.len) { + if (index.* + 1 >= args.len) return .missing; + index.* += 1; + return .{ .value = args[index.*] }; + } + if (arg[name.len] == '=') { + const text = arg[name.len + 1 ..]; + if (text.len == 0) return .missing; + return .{ .value = text }; + } + // A longer flag that merely starts with the same letters, e.g. `--bitsy`. + return .absent; +} + +fn parseBitWidth(text: []const u8) ?engine.types.BitWidth { + // Driven by the enum, so a width added to `BitWidth` is accepted here without + // a second list to update. + inline for (@typeInfo(engine.types.BitWidth).@"enum".fields) |field| { + if (std.mem.eql(u8, text, comptime std.fmt.comptimePrint("{d}", .{field.value}))) { + return @enumFromInt(field.value); + } + } + return null; +} + +/// The accepted widths, as they appear in messages: "8, 16, 32, 64, 128". +const bit_width_list = blk: { + var list: []const u8 = ""; + for (@typeInfo(engine.types.BitWidth).@"enum".fields, 0..) |field, i| { + list = list ++ (if (i == 0) "" else ", ") ++ std.fmt.comptimePrint("{d}", .{field.value}); + } + break :blk list; +}; + +comptime { + // `help_text` spells the same list out, because splicing it into the middle of + // a multiline literal costs more than it saves. This makes a change to + // `BitWidth` a compile error instead of a stale help screen. + if (!std.mem.eql(u8, bit_width_list, "8, 16, 32, 64, 128")) { + @compileError("BitWidth changed: update the --bits line in help_text"); + } +} + +fn parseEndian(text: []const u8) ?engine.types.Endianness { + if (std.ascii.eqlIgnoreCase(text, "big") or std.ascii.eqlIgnoreCase(text, "be")) return .big; + if (std.ascii.eqlIgnoreCase(text, "little") or std.ascii.eqlIgnoreCase(text, "le")) return .little; + return null; +} + +const bits_usage = "error: --bits takes one of " ++ bit_width_list ++ + \\ + \\ + \\ tally -p --bits 8 '0xFF + 1' + \\ + \\The width applies to programmer mode; standard mode is fixed at 64-bit signed. + \\ +; + +const endian_usage = + \\error: --endian takes big or little + \\ + \\ tally -p --endian little '0xDEADBEEF' + \\ + \\The byte order affects the hex and ASCII rows only. + \\ +; + +const unknown_flag_usage = + \\error: unknown flag + \\ + \\Run 'tally --help' for the list of flags. + \\ +; + /// Parse the arguments following the `convert` subcommand. /// /// Accepts ` `, either separator word between the units @@ -90,6 +220,9 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedA /// bare expressions. fn parseConvertArgs(args: []const []const u8) ParsedArgs { const max_tokens = 8; + if (wantsHelp(args)) { + return .{ .output = .{ .text = convert_usage, .is_error = false } }; + } if (args.len < 2 or args.len > max_tokens) { return .{ .output = .{ .text = convert_usage, .is_error = true } }; } @@ -272,8 +405,19 @@ fn formatConversionUnits( /// Evaluate an expression and format the result as a string. pub fn evaluate(allocator: std.mem.Allocator, expression: []const u8, mode: engine.Mode, buf: []u8) CliResult { + return evaluateWith(allocator, expression, mode, .{}, buf); +} + +/// Evaluate with an explicit programmer configuration (width, signedness, byte +/// order). Standard mode ignores it: it is fixed at 64-bit signed. +pub fn evaluateWith( + allocator: std.mem.Allocator, + expression: []const u8, + mode: engine.Mode, + config: engine.types.ProgrammerConfig, + buf: []u8, +) CliResult { if (mode == .programmer) { - const config = engine.types.ProgrammerConfig{}; const result = engine.evalProgrammerString(allocator, expression, config) catch |err| { return .{ .output = errorMessage(err), .is_error = true }; }; @@ -409,6 +553,18 @@ fn decoratedError(err: engine.CalcError) []const u8 { }; } +/// True when a subcommand's arguments ask for its usage rather than run it. +/// +/// Every subcommand answers `-h`/`--help` on stdout with exit status 0. The usage +/// text was previously reachable only by getting the arguments wrong, which sent it +/// to stderr with a non-zero status. +fn wantsHelp(args: []const []const u8) bool { + for (args) |arg| { + if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) return true; + } + return false; +} + const convert_usage = \\usage: tally convert [to] \\ @@ -448,6 +604,10 @@ fn parseAmortArgs(args: []const []const u8) ParsedArgs { var monthly = false; var round_cents = true; + if (wantsHelp(args)) { + return .{ .output = .{ .text = amort_usage, .is_error = false } }; + } + for (args) |arg| { if (std.mem.eql(u8, arg, "--summary")) { summary_only = true; @@ -616,12 +776,22 @@ const help_text = \\ \\Options: \\ -p, --programmer Programmer mode (^ = power, xor = XOR) + \\ --bits Bit width for programmer mode: 8, 16, 32, 64, 128 + \\ (default 64). Implies -p. Standard mode is always + \\ 64-bit signed. + \\ --signed Treat programmer values as two's complement (default) + \\ --unsigned Treat programmer values as unsigned. Implies -p. + \\ --endian Byte order of the hex and ASCII rows: big (default) + \\ or little. Implies -p. \\ -h, --help Show this help \\ --version Show version \\ \\Examples: \\ tally '2 + 3 * 4' \\ tally -p '0xFF and 0x0F' + \\ tally --bits 8 '0xFF + 1' Wraps at 8 bits + \\ tally --bits 8 --unsigned '0xFF >> 1' + \\ tally --endian little '0xDEADBEEF' \\ 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 @@ -641,6 +811,8 @@ const help_text = \\ tally convert 100 km to mi Explicit unit conversion \\ tally amort 200000 6 360 --monthly Amortization schedule \\ + \\Each subcommand takes -h for its own usage: tally amort --help + \\ \\Run with no arguments to start the interactive TUI. \\ ; @@ -676,7 +848,7 @@ pub fn main(init: std.process.Init) u8 { }, .expression => |expr| { var buf: [4096]u8 = undefined; - const result = evaluate(allocator, expr.text, expr.mode, &buf); + const result = evaluateWith(allocator, expr.text, expr.mode, expr.config, &buf); 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"); @@ -758,6 +930,149 @@ test "parseArgs: --programmer long flag" { } } +test "parseArgs: --bits sets the width, in either spelling, and implies -p" { + for ([_][]const []const u8{ + &.{ "--bits", "8", "0xFF" }, + &.{ "--bits=8", "0xFF" }, + }) |args| { + const e = parseArgs(testing.allocator, args).expression; + defer testing.allocator.free(e.text); + try testing.expectEqual(engine.types.BitWidth.bits8, e.config.bit_width); + // Asking for a width is asking for programmer mode: standard mode is fixed + // at 64-bit signed, so the flag would mean nothing there. + try testing.expectEqual(engine.Mode.programmer, e.mode); + try testing.expectEqualStrings("0xFF", e.text); + } +} + +test "parseArgs: every documented bit width is accepted" { + for ([_]struct { text: []const u8, expected: engine.types.BitWidth }{ + .{ .text = "8", .expected = .bits8 }, + .{ .text = "16", .expected = .bits16 }, + .{ .text = "32", .expected = .bits32 }, + .{ .text = "64", .expected = .bits64 }, + .{ .text = "128", .expected = .bits128 }, + }) |case| { + try testing.expectEqual(case.expected, parseBitWidth(case.text).?); + } + // Anything else, including a width the engine does not have, is rejected. + for ([_][]const u8{ "7", "0", "24", "256", "", "eight", "8x", "-8" }) |text| { + try testing.expect(parseBitWidth(text) == null); + } +} + +test "parseArgs: --signed and --unsigned set the signedness and imply -p" { + const signed = parseArgs(testing.allocator, &.{ "--signed", "0xFF" }).expression; + defer testing.allocator.free(signed.text); + try testing.expectEqual(engine.types.Signedness.signed, signed.config.signedness); + try testing.expectEqual(engine.Mode.programmer, signed.mode); + + const unsigned = parseArgs(testing.allocator, &.{ "--unsigned", "0xFF" }).expression; + defer testing.allocator.free(unsigned.text); + try testing.expectEqual(engine.types.Signedness.unsigned, unsigned.config.signedness); + try testing.expectEqual(engine.Mode.programmer, unsigned.mode); +} + +test "parseArgs: --endian sets the byte order and accepts both spellings" { + for ([_][]const []const u8{ + &.{ "--endian", "little", "0xDEADBEEF" }, + &.{ "--endian=le", "0xDEADBEEF" }, + &.{ "--endian", "LITTLE", "0xDEADBEEF" }, + }) |args| { + const e = parseArgs(testing.allocator, args).expression; + defer testing.allocator.free(e.text); + try testing.expectEqual(engine.types.Endianness.little, e.config.display_endian); + } + try testing.expectEqual(engine.types.Endianness.big, parseEndian("big").?); + try testing.expectEqual(engine.types.Endianness.big, parseEndian("BE").?); + try testing.expect(parseEndian("middle") == null); + try testing.expect(parseEndian("") == null); +} + +test "parseArgs: the flags combine, and order does not matter" { + const e = parseArgs(testing.allocator, &.{ "--unsigned", "--bits", "16", "--endian", "little", "0xFF", "+", "1" }).expression; + defer testing.allocator.free(e.text); + try testing.expectEqual(engine.types.BitWidth.bits16, e.config.bit_width); + try testing.expectEqual(engine.types.Signedness.unsigned, e.config.signedness); + try testing.expectEqual(engine.types.Endianness.little, e.config.display_endian); + try testing.expectEqualStrings("0xFF + 1", e.text); +} + +test "parseArgs: a bad or incomplete flag value is reported, not evaluated" { + for ([_][]const []const u8{ + &.{ "--bits", "7", "1" }, + &.{ "--bits=7", "1" }, + &.{"--bits"}, + &.{"--bits="}, + &.{ "--endian", "sideways", "1" }, + &.{"--endian"}, + // A typo used to be joined into the expression and reported as an + // unexpected token, which pointed at the wrong thing. + &.{ "--bit", "8", "1" }, + &.{ "--programer", "1" }, + // `--bitsy` starts with `--bits` but is not it, so it is an unknown flag + // rather than a width of "y". + &.{ "--bitsy", "1" }, + }) |args| { + try testing.expect(parseArgs(testing.allocator, args).output.is_error); + } +} + +test "parseArgs: subcommands answer --help on stdout" { + // Reachable without getting the arguments wrong, and not an error. + for ([_][]const []const u8{ + &.{ "convert", "--help" }, + &.{ "convert", "-h" }, + &.{ "amort", "--help" }, + &.{ "amort", "-h" }, + &.{ "amortize", "-h" }, + }) |args| { + const out = parseArgs(testing.allocator, args).output; + try testing.expect(!out.is_error); + try testing.expect(std.mem.startsWith(u8, out.text, "usage: tally ")); + } + + // The same text still reports an error when the arguments are wrong. + try testing.expect(parseArgs(testing.allocator, &.{"convert"}).output.is_error); +} + +test "the programmer config reaches the result" { + var buf: [4096]u8 = undefined; + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // 8-bit: 0xFF + 1 wraps to 0. + const narrow = evaluateWith(alloc, "0xFF + 1", .programmer, .{ .bit_width = .bits8 }, &buf); + try testing.expect(!narrow.is_error); + try testing.expect(std.mem.indexOf(u8, narrow.output, "dec(unsigned): 0\n") != null); + + // Signedness decides whether >> extends the sign. + const signed = evaluateWith(alloc, "0xFF >> 1", .programmer, .{ + .bit_width = .bits8, + .signedness = .signed, + }, &buf); + try testing.expect(std.mem.indexOf(u8, signed.output, "dec(signed): -1") != null); + + const unsigned = evaluateWith(alloc, "0xFF >> 1", .programmer, .{ + .bit_width = .bits8, + .signedness = .unsigned, + }, &buf); + try testing.expect(std.mem.indexOf(u8, unsigned.output, "dec(unsigned): 127") != null); + + // Byte order reverses the hex row and nothing else. + const little = evaluateWith(alloc, "0xDEAD", .programmer, .{ + .bit_width = .bits16, + .display_endian = .little, + }, &buf); + try testing.expect(std.mem.indexOf(u8, little.output, "hex: AD DE") != null); + const big = evaluateWith(alloc, "0xDEAD", .programmer, .{ + .bit_width = .bits16, + .display_endian = .big, + }, &buf); + try testing.expect(std.mem.indexOf(u8, big.output, "hex: DE AD") != null); +} + test "parseArgs: --help" { const parsed = parseArgs(testing.allocator, &.{"--help"}); switch (parsed) {