cli flags

This commit is contained in:
Emil Lerch 2026-07-29 16:17:07 -07:00
parent f40aafa501
commit 7121dd9dea
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 348 additions and 5 deletions

View file

@ -108,7 +108,7 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
- **FR-6.1**: Invoke as `tally "<expression>"` 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 <dec|hex|oct|bin>` to control primary output format (default: show all).
- **FR-6.5**: Subcommand `tally struct <definition_or_file>` for struct layout - accepts inline DSL string or path to a file containing the definition.
- **FR-6.6**: Subcommand `tally cagr <start> <end> <periods>` 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 <principal> <rate> <periods> [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 <big|little>` (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

View file

@ -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 <big|little>` (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

View file

@ -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 `<value> <from> <to>`, 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 <value> <from-unit> [to] <to-unit>
\\
@ -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 <N> 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 <order> 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) {