Skip to content

Commit 45b7752

Browse files
committed
Improve decoupling between components
1 parent 102d77a commit 45b7752

6 files changed

Lines changed: 384 additions & 411 deletions

File tree

src/chilli/command.zig

Lines changed: 275 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,33 @@
22
const std = @import("std");
33
const parser = @import("parser.zig");
44
const context = @import("context.zig");
5-
const utils = @import("utils.zig");
5+
const styles = @import("styles.zig");
66
const types = @import("types.zig");
77
const errors = @import("errors.zig");
88

9+
/// Defines the configuration for a `Command`.
10+
///
11+
/// All string-slice fields (`name`, `description`, `version`, `section`, and
12+
/// the entries of `aliases`) are borrowed by the `Command`, not copied. They
13+
/// must remain valid for the lifetime of the command tree.
14+
pub const CommandOptions = struct {
15+
/// The primary name of the command, used to invoke it.
16+
name: []const u8,
17+
/// A short description of the command's purpose, shown in help messages.
18+
description: []const u8,
19+
/// The function to execute when this command is run.
20+
exec: *const fn (ctx: context.CommandContext) anyerror!void,
21+
/// An optional list of alternative names for the command.
22+
aliases: ?[]const []const u8 = null,
23+
/// An optional single-character shortcut for the command (e.g., 'c').
24+
shortcut: ?u8 = null,
25+
/// An optional version string for the application. If provided on the root command,
26+
/// an automatic `--version` flag will be available.
27+
version: ?[]const u8 = null,
28+
/// The name of the section under which this command should be grouped in a parent's help message.
29+
section: []const u8 = "Commands",
30+
};
31+
932
/// Represents a single command in a CLI application.
1033
///
1134
/// A `Command` can have its own flags, positional arguments, and an execution function.
@@ -19,7 +42,7 @@ const errors = @import("errors.zig");
1942
/// threads on the same `Command` instance concurrently will result in a data race
2043
/// and undefined behavior.
2144
pub const Command = struct {
22-
options: types.CommandOptions,
45+
options: CommandOptions,
2346
subcommands: std.ArrayList(*Command),
2447
flags: std.ArrayList(types.Flag),
2548
positional_args: std.ArrayList(types.PositionalArg),
@@ -30,7 +53,7 @@ pub const Command = struct {
3053

3154
/// Initializes a new command.
3255
/// Panics if the provided command name is empty.
33-
pub fn init(allocator: std.mem.Allocator, options: types.CommandOptions) !*Command {
56+
pub fn init(allocator: std.mem.Allocator, options: CommandOptions) !*Command {
3457
if (options.name.len == 0) {
3558
std.debug.panic("Command name cannot be empty.", .{});
3659
}
@@ -271,8 +294,8 @@ pub const Command = struct {
271294
failed_cmd: ?*const Command,
272295
writer: anytype,
273296
) void {
274-
const red = utils.styles.s(utils.styles.RED);
275-
const reset = utils.styles.s(utils.styles.RESET);
297+
const red = styles.s(styles.RED);
298+
const reset = styles.s(styles.RESET);
276299

277300
switch (err) {
278301
error.BrokenPipe => return, // Exit silently on broken pipe
@@ -442,9 +465,9 @@ pub const Command = struct {
442465
var buf: [4096]u8 = undefined;
443466
var file_writer = std.Io.File.stdout().writer(io, &buf);
444467
const stdout = &file_writer.interface;
445-
const bold = utils.styles.s(utils.styles.BOLD);
446-
const dim = utils.styles.s(utils.styles.DIM);
447-
const reset = utils.styles.s(utils.styles.RESET);
468+
const bold = styles.s(styles.BOLD);
469+
const dim = styles.s(styles.DIM);
470+
const reset = styles.s(styles.RESET);
448471

449472
try stdout.print("{s}{s}{s}\n", .{ bold, self.options.description, reset });
450473

@@ -454,27 +477,203 @@ pub const Command = struct {
454477
try stdout.print("\n", .{});
455478

456479
try stdout.print("{s}Usage:{s}\n", .{ bold, reset });
457-
try utils.printUsageLine(self, stdout);
480+
try printUsageLine(self, stdout);
458481

459482
if (self.positional_args.items.len > 0) {
460483
try stdout.print("{s}Arguments:{s}\n", .{ bold, reset });
461-
try utils.printAlignedPositionalArgs(self, stdout);
484+
try printAlignedPositionalArgs(self, stdout);
462485
try stdout.print("\n", .{});
463486
}
464487

465488
if (self.flags.items.len > 0) {
466489
try stdout.print("{s}Flags:{s}\n", .{ bold, reset });
467-
try utils.printAlignedFlags(self, stdout);
490+
try printAlignedFlags(self, stdout);
468491
try stdout.print("\n", .{});
469492
}
470493

471494
if (self.subcommands.items.len > 0) {
472-
try utils.printSubcommands(self, stdout);
495+
try printSubcommands(self, stdout);
473496
}
474497
try file_writer.flush();
475498
}
476499
};
477500

501+
// ============================================================================
502+
// Help-output printers (private)
503+
// ============================================================================
504+
// These used to live in utils.zig. They were moved here because they depend
505+
// on Command's internals (flags, positional_args, subcommands, parent chain),
506+
// and keeping them in a separate module created a utils <-> command import
507+
// cycle that the refactor set out to eliminate. They are intentionally not
508+
// `pub` — help output is a Command concern, not a public extension point.
509+
510+
fn printAlignedCommands(commands: []*Command, writer: anytype) !void {
511+
var max_width: usize = 0;
512+
for (commands) |cmd| {
513+
var len = cmd.options.name.len;
514+
if (cmd.options.shortcut != null) {
515+
len += 4; // " (c)"
516+
}
517+
if (len > max_width) max_width = len;
518+
}
519+
520+
for (commands) |cmd| {
521+
try writer.print(" {s}", .{cmd.options.name});
522+
var current_width = cmd.options.name.len;
523+
if (cmd.options.shortcut) |sc| {
524+
try writer.print(" ({c})", .{sc});
525+
current_width += 4;
526+
}
527+
528+
for (0..max_width - current_width + 2) |_| try writer.writeByte(' ');
529+
try writer.print("{s}\n", .{cmd.options.description});
530+
}
531+
}
532+
533+
fn printAlignedFlags(cmd: *const Command, writer: anytype) !void {
534+
var max_width: usize = 0;
535+
for (cmd.flags.items) |flag| {
536+
if (flag.hidden) continue;
537+
const len: usize = if (flag.shortcut != null)
538+
// " -c, --name"
539+
flag.name.len + 8
540+
else
541+
// " --name"
542+
flag.name.len + 8;
543+
if (len > max_width) max_width = len;
544+
}
545+
546+
for (cmd.flags.items) |flag| {
547+
if (flag.hidden) continue;
548+
549+
var current_width: usize = undefined;
550+
if (flag.shortcut) |sc| {
551+
try writer.print(" -{c}, --{s}", .{ sc, flag.name });
552+
current_width = flag.name.len + 8;
553+
} else {
554+
try writer.print(" --{s}", .{flag.name});
555+
current_width = flag.name.len + 8;
556+
}
557+
558+
for (0..max_width - current_width + 2) |_| try writer.writeByte(' ');
559+
try writer.print("{s} [{s}]", .{ flag.description, @tagName(flag.type) });
560+
561+
switch (flag.default_value) {
562+
.Bool => |v| try writer.print(" (default: {})", .{v}),
563+
.Int => |v| try writer.print(" (default: {})", .{v}),
564+
.Float => |v| try writer.print(" (default: {})", .{v}),
565+
.String => |v| try writer.print(" (default: \"{s}\")", .{v}),
566+
}
567+
try writer.print("\n", .{});
568+
}
569+
}
570+
571+
fn printAlignedPositionalArgs(cmd: *const Command, writer: anytype) !void {
572+
var max_width: usize = 0;
573+
for (cmd.positional_args.items) |arg| {
574+
if (arg.name.len > max_width) max_width = arg.name.len;
575+
}
576+
577+
for (cmd.positional_args.items) |arg| {
578+
try writer.print(" {s}", .{arg.name});
579+
for (0..max_width - arg.name.len + 2) |_| try writer.writeByte(' ');
580+
try writer.print("{s}", .{arg.description});
581+
582+
if (arg.variadic) {
583+
try writer.print(" (variadic)\n", .{});
584+
} else if (arg.is_required) {
585+
try writer.print(" (required)\n", .{});
586+
} else {
587+
try writer.print(" (optional)\n", .{});
588+
}
589+
}
590+
}
591+
592+
fn printUsageLine(cmd: *const Command, writer: anytype) !void {
593+
var parents: std.ArrayList(*Command) = .empty;
594+
defer parents.deinit(cmd.allocator);
595+
596+
var current_parent = cmd.parent;
597+
while (current_parent) |p| {
598+
try parents.append(cmd.allocator, p);
599+
current_parent = p.parent;
600+
}
601+
std.mem.reverse(*Command, parents.items);
602+
603+
if (parents.items.len > 0) {
604+
try writer.print(" {s}", .{parents.items[0].options.name});
605+
for (parents.items[1..]) |p| {
606+
try writer.print(" {s}", .{p.options.name});
607+
}
608+
try writer.print(" {s}", .{cmd.options.name});
609+
} else {
610+
try writer.print(" {s}", .{cmd.options.name});
611+
}
612+
613+
if (cmd.flags.items.len > 0) {
614+
try writer.print(" [flags]", .{});
615+
}
616+
617+
for (cmd.positional_args.items) |arg| {
618+
if (arg.variadic) {
619+
try writer.print(" [{s}...]", .{arg.name});
620+
} else if (arg.is_required) {
621+
try writer.print(" <{s}>", .{arg.name});
622+
} else {
623+
try writer.print(" [{s}]", .{arg.name});
624+
}
625+
}
626+
627+
if (cmd.subcommands.items.len > 0) {
628+
try writer.print(" [command]", .{});
629+
}
630+
631+
try writer.print("\n\n", .{});
632+
}
633+
634+
const CommandSortContext = struct {
635+
pub fn lessThan(_: @This(), a: *Command, b: *Command) bool {
636+
return std.mem.order(u8, a.options.name, b.options.name) == .lt;
637+
}
638+
};
639+
640+
const StringSortContext = struct {
641+
pub fn lessThan(_: @This(), a: []const u8, b: []const u8) bool {
642+
return std.mem.order(u8, a, b) == .lt;
643+
}
644+
};
645+
646+
fn printSubcommands(cmd: *const Command, writer: anytype) !void {
647+
var section_map = std.StringHashMap(std.ArrayList(*Command)).init(cmd.allocator);
648+
defer {
649+
var it = section_map.iterator();
650+
while (it.next()) |entry| entry.value_ptr.*.deinit(cmd.allocator);
651+
section_map.deinit();
652+
}
653+
654+
for (cmd.subcommands.items) |sub| {
655+
const list = try section_map.getOrPut(sub.options.section);
656+
if (!list.found_existing) {
657+
list.value_ptr.* = .empty;
658+
}
659+
try list.value_ptr.*.append(cmd.allocator, sub);
660+
}
661+
662+
var sorted_sections: std.ArrayList([]const u8) = .empty;
663+
defer sorted_sections.deinit(cmd.allocator);
664+
var it = section_map.keyIterator();
665+
while (it.next()) |key| try sorted_sections.append(cmd.allocator, key.*);
666+
std.sort.pdq([]const u8, sorted_sections.items, StringSortContext{}, StringSortContext.lessThan);
667+
668+
for (sorted_sections.items) |section_name| {
669+
try writer.print("{s}{s}{s}:\n", .{ styles.s(styles.BOLD), section_name, styles.s(styles.RESET) });
670+
const cmds_list = section_map.get(section_name).?;
671+
std.sort.pdq(*Command, cmds_list.items, CommandSortContext{}, CommandSortContext.lessThan);
672+
try printAlignedCommands(cmds_list.items, writer);
673+
try writer.print("\n", .{});
674+
}
675+
}
676+
478677
// Tests for the `command` module
479678

480679
const testing = std.testing;
@@ -1004,3 +1203,67 @@ test "command: Args.Iterator with only argv0 produces no user args" {
10041203
const user_args = if (args_list.items.len > 1) args_list.items[1..] else args_list.items[0..0];
10051204
try std.testing.expectEqual(@as(usize, 0), user_args.len);
10061205
}
1206+
1207+
// ============================================================================
1208+
// Tests for the help-output printers (moved from utils.zig)
1209+
// ============================================================================
1210+
1211+
test "help: printAlignedFlags produces correct padding" {
1212+
const allocator = std.testing.allocator;
1213+
var cmd = try Command.init(allocator, .{ .name = "test", .description = "", .exec = dummyExec });
1214+
defer cmd.deinit();
1215+
1216+
try cmd.addFlag(.{
1217+
.name = "verbose",
1218+
.shortcut = 'v',
1219+
.description = "Enable verbose output",
1220+
.type = .Bool,
1221+
.default_value = .{ .Bool = false },
1222+
});
1223+
1224+
var buf: [2048]u8 = undefined;
1225+
var writer = TestBufWriter{ .buf = &buf };
1226+
try printAlignedFlags(cmd, &writer);
1227+
1228+
const output = writer.getWritten();
1229+
try std.testing.expect(std.mem.indexOf(u8, output, "--help") != null);
1230+
try std.testing.expect(std.mem.indexOf(u8, output, "--verbose") != null);
1231+
try std.testing.expect(std.mem.indexOf(u8, output, "Enable verbose output") != null);
1232+
try std.testing.expect(std.mem.indexOf(u8, output, " ") != null);
1233+
}
1234+
1235+
test "help: printAlignedPositionalArgs produces correct padding" {
1236+
const allocator = std.testing.allocator;
1237+
var cmd = try Command.init(allocator, .{ .name = "test", .description = "", .exec = dummyExec });
1238+
defer cmd.deinit();
1239+
1240+
try cmd.addPositional(.{ .name = "input", .description = "Input file", .is_required = true });
1241+
try cmd.addPositional(.{ .name = "output-file", .description = "Output file", .is_required = false, .default_value = .{ .String = "out.txt" } });
1242+
1243+
var buf: [2048]u8 = undefined;
1244+
var writer = TestBufWriter{ .buf = &buf };
1245+
try printAlignedPositionalArgs(cmd, &writer);
1246+
1247+
const output = writer.getWritten();
1248+
try std.testing.expect(std.mem.indexOf(u8, output, "input") != null);
1249+
try std.testing.expect(std.mem.indexOf(u8, output, "output-file") != null);
1250+
try std.testing.expect(std.mem.indexOf(u8, output, "(required)") != null);
1251+
try std.testing.expect(std.mem.indexOf(u8, output, "(optional)") != null);
1252+
}
1253+
1254+
test "help: printUsageLine produces correct output" {
1255+
const allocator = std.testing.allocator;
1256+
var cmd = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
1257+
defer cmd.deinit();
1258+
1259+
try cmd.addPositional(.{ .name = "file", .description = "A file", .is_required = true });
1260+
1261+
var buf: [2048]u8 = undefined;
1262+
var writer = TestBufWriter{ .buf = &buf };
1263+
try printUsageLine(cmd, &writer);
1264+
1265+
const output = writer.getWritten();
1266+
try std.testing.expect(std.mem.indexOf(u8, output, "app") != null);
1267+
try std.testing.expect(std.mem.indexOf(u8, output, "<file>") != null);
1268+
try std.testing.expect(std.mem.indexOf(u8, output, "[flags]") != null);
1269+
}

src/chilli/parser.zig

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
//! Handles the parsing of command-line arguments into flags and positional values.
22
const std = @import("std");
33
const command = @import("command.zig");
4-
const utils = @import("utils.zig");
54
const types = @import("types.zig");
65
const errors = @import("errors.zig");
76

@@ -66,7 +65,7 @@ fn parseSingleFlag(cmd: *command.Command, iterator: *ArgIterator) errors.Error!F
6665
const flag = cmd.findFlag(flag_name) orelse return errors.Error.UnknownFlag;
6766

6867
if (flag.type == .Bool) {
69-
const flag_value = if (value) |v| try utils.parseBool(v) else true;
68+
const flag_value = if (value) |v| try types.parseBool(v) else true;
7069
try cmd.parsed_flags.append(cmd.allocator, .{
7170
.name = flag_name,
7271
.value = .{ .Bool = flag_value },

0 commit comments

Comments
 (0)