Skip to content

Commit d5e29c6

Browse files
committed
Add support for deprecated commands #5
1 parent 328c90d commit d5e29c6

7 files changed

Lines changed: 272 additions & 7 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Priorities, in order:
4141
- `src/chilli/context.zig`: The `CommandContext` passed to each command's `exec` function for typed flag and argument access.
4242
- `src/chilli/errors.zig`: Error types produced by parsing and type coercion.
4343
- `src/chilli/styles.zig`: ANSI escape-code constants plus a TTY-gated `s()` wrapper used by the help and error output.
44+
- `src/chilli/deprecation.zig`: Warning formatter and stderr emitter for deprecated commands, flags, and positional arguments, with `CHILLI_NO_DEPRECATION_WARNINGS` suppression.
4445
- `examples/`: Self-contained example programs (`e1_simple_cli.zig` through `e8_flags_and_args.zig`) built as executables via `build.zig`.
4546
- `.github/workflows/`: CI workflows (`tests.yml` for unit tests on Linux, macOS, and Windows, `docs.yml` for API doc deployment).
4647
- `build.zig` / `build.zig.zon`: Zig build configuration and package metadata.
@@ -135,7 +136,7 @@ Good first tasks:
135136

136137
Before coding:
137138

138-
1. Modules affected by the change (`command`, `parser`, `types`, `context`, `errors`, or `styles`).
139+
1. Modules affected by the change (`command`, `parser`, `types`, `context`, `errors`, `styles`, or `deprecation`).
139140
2. Whether the change is user-visible in `--help` output, and if so, which examples will surface it.
140141
3. Public API impact, i.e. whether the change adds to or alters anything re-exported from `src/lib.zig`, and is therefore additive or breaking.
141142
4. Cross-platform implications, especially for anything that touches environment variables, the filesystem, or process-args encoding.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Zig version supported by the main releases of Chilli:
6363
| `0.16.0` | `v0.3.x` |
6464
| `0.15.x` | `v0.2.x` |
6565

66-
The `main` branch normally tracks the latest (non-developmental) Zig release.
66+
The `main` branch normally is developed and build using the latest (non-developmental) Zig release.
6767

6868
#### Adding to Build Script
6969

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,6 @@ It outlines features to be implemented and their current status.
2727
- [x] Simple, declarative API for building commands
2828
- [x] Named access for all flags and arguments
2929
- [x] Shared context data for passing application state
30-
- [ ] Deprecation notices for commands or flags
30+
- [x] Deprecation notices for commands, flags, and positional arguments
3131
- [ ] Built-in TUI components (like spinners and progress bars)
3232
- [ ] Automatic command history and completion

src/chilli/command.zig

Lines changed: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const context = @import("context.zig");
55
const styles = @import("styles.zig");
66
const types = @import("types.zig");
77
const errors = @import("errors.zig");
8+
const deprecation = @import("deprecation.zig");
89

910
/// Defines the configuration for a `Command`.
1011
///
@@ -27,6 +28,11 @@ pub const CommandOptions = struct {
2728
version: ?[]const u8 = null,
2829
/// The name of the section under which this command should be grouped in a parent's help message.
2930
section: []const u8 = "Commands",
31+
/// If set, marks the command as deprecated. The value is a free-form
32+
/// reason or replacement suggestion. The command is still dispatched,
33+
/// and its `exec` is still run, but a warning is written to stderr when
34+
/// this command resolves as the leaf of the invocation chain.
35+
deprecated: ?[]const u8 = null,
3036
};
3137

3238
/// Represents a single command in a CLI application.
@@ -273,6 +279,20 @@ pub const Command = struct {
273279

274280
try parser.validateArgs(current_cmd);
275281

282+
// Deprecation warnings for the resolved command and for any
283+
// deprecated positional slots the user actually filled. Flag-level
284+
// warnings fire inside the parser at parse time.
285+
if (current_cmd.options.deprecated) |reason| {
286+
deprecation.emit(current_cmd.allocator, "command", current_cmd.options.name, reason);
287+
}
288+
for (current_cmd.positional_args.items, 0..) |arg, i| {
289+
if (arg.deprecated) |reason| {
290+
if (i < current_cmd.parsed_positionals.items.len) {
291+
deprecation.emit(current_cmd.allocator, "positional argument", arg.name, reason);
292+
}
293+
}
294+
}
295+
276296
// Success, clear the out_failed_cmd
277297
out_failed_cmd.* = null;
278298

@@ -526,7 +546,9 @@ fn printAlignedCommands(commands: []*Command, writer: anytype) !void {
526546
}
527547

528548
for (0..max_width - current_width + 2) |_| try writer.writeByte(' ');
529-
try writer.print("{s}\n", .{cmd.options.description});
549+
try writer.print("{s}", .{cmd.options.description});
550+
if (cmd.options.deprecated != null) try writer.print(" (deprecated)", .{});
551+
try writer.print("\n", .{});
530552
}
531553
}
532554

@@ -564,6 +586,7 @@ fn printAlignedFlags(cmd: *const Command, writer: anytype) !void {
564586
.Float => |v| try writer.print(" (default: {})", .{v}),
565587
.String => |v| try writer.print(" (default: \"{s}\")", .{v}),
566588
}
589+
if (flag.deprecated != null) try writer.print(" (deprecated)", .{});
567590
try writer.print("\n", .{});
568591
}
569592
}
@@ -580,12 +603,14 @@ fn printAlignedPositionalArgs(cmd: *const Command, writer: anytype) !void {
580603
try writer.print("{s}", .{arg.description});
581604

582605
if (arg.variadic) {
583-
try writer.print(" (variadic)\n", .{});
606+
try writer.print(" (variadic)", .{});
584607
} else if (arg.is_required) {
585-
try writer.print(" (required)\n", .{});
608+
try writer.print(" (required)", .{});
586609
} else {
587-
try writer.print(" (optional)\n", .{});
610+
try writer.print(" (optional)", .{});
588611
}
612+
if (arg.deprecated != null) try writer.print(" (deprecated)", .{});
613+
try writer.print("\n", .{});
589614
}
590615
}
591616

@@ -1251,6 +1276,99 @@ test "help: printAlignedPositionalArgs produces correct padding" {
12511276
try std.testing.expect(std.mem.indexOf(u8, output, "(optional)") != null);
12521277
}
12531278

1279+
test "deprecation: printAlignedFlags marks deprecated flag" {
1280+
const allocator = std.testing.allocator;
1281+
styles.setEnabled(false);
1282+
defer styles.setEnabled(false);
1283+
1284+
var cmd = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
1285+
defer cmd.deinit();
1286+
try cmd.addFlag(.{
1287+
.name = "old",
1288+
.type = .Bool,
1289+
.default_value = .{ .Bool = false },
1290+
.description = "Old flag",
1291+
.deprecated = "use --new",
1292+
});
1293+
1294+
var buf: [2048]u8 = undefined;
1295+
var writer = TestBufWriter{ .buf = &buf };
1296+
try printAlignedFlags(cmd, &writer);
1297+
const out = writer.getWritten();
1298+
try std.testing.expect(std.mem.indexOf(u8, out, "--old") != null);
1299+
try std.testing.expect(std.mem.indexOf(u8, out, "(deprecated)") != null);
1300+
}
1301+
1302+
test "deprecation: printAlignedPositionalArgs marks deprecated arg" {
1303+
const allocator = std.testing.allocator;
1304+
styles.setEnabled(false);
1305+
defer styles.setEnabled(false);
1306+
1307+
var cmd = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
1308+
defer cmd.deinit();
1309+
try cmd.addPositional(.{
1310+
.name = "path",
1311+
.description = "Path to file",
1312+
.is_required = true,
1313+
.deprecated = "use --input flag",
1314+
});
1315+
1316+
var buf: [2048]u8 = undefined;
1317+
var writer = TestBufWriter{ .buf = &buf };
1318+
try printAlignedPositionalArgs(cmd, &writer);
1319+
const out = writer.getWritten();
1320+
try std.testing.expect(std.mem.indexOf(u8, out, "path") != null);
1321+
try std.testing.expect(std.mem.indexOf(u8, out, "(required)") != null);
1322+
try std.testing.expect(std.mem.indexOf(u8, out, "(deprecated)") != null);
1323+
}
1324+
1325+
test "deprecation: printAlignedCommands marks deprecated subcommand" {
1326+
const allocator = std.testing.allocator;
1327+
styles.setEnabled(false);
1328+
defer styles.setEnabled(false);
1329+
1330+
var root = try Command.init(allocator, .{ .name = "root", .description = "", .exec = dummyExec });
1331+
defer root.deinit();
1332+
const old_sub = try Command.init(allocator, .{
1333+
.name = "old-sub",
1334+
.description = "The old way",
1335+
.exec = dummyExec,
1336+
.deprecated = "use 'new-sub' instead",
1337+
});
1338+
try root.addSubcommand(old_sub);
1339+
1340+
var buf: [2048]u8 = undefined;
1341+
var writer = TestBufWriter{ .buf = &buf };
1342+
try printAlignedCommands(root.subcommands.items, &writer);
1343+
const out = writer.getWritten();
1344+
try std.testing.expect(std.mem.indexOf(u8, out, "old-sub") != null);
1345+
try std.testing.expect(std.mem.indexOf(u8, out, "(deprecated)") != null);
1346+
}
1347+
1348+
test "deprecation: flag is still parsed and honored when deprecated" {
1349+
// Contract: deprecation never breaks existing invocations. The flag
1350+
// must still be available via getFlagValue after parsing.
1351+
const allocator = std.testing.allocator;
1352+
deprecation.setSuppressedForTests(true); // silence the warning for this test
1353+
defer deprecation.setSuppressedForTests(false);
1354+
1355+
var cmd = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
1356+
defer cmd.deinit();
1357+
try cmd.addFlag(.{
1358+
.name = "legacy-mode",
1359+
.type = .Bool,
1360+
.default_value = .{ .Bool = false },
1361+
.description = "",
1362+
.deprecated = "removed in v0.5",
1363+
});
1364+
1365+
var failed_cmd: ?*const Command = null;
1366+
try cmd.execute(&[_][]const u8{"--legacy-mode"}, null, &failed_cmd);
1367+
try std.testing.expect(failed_cmd == null);
1368+
const v = cmd.getFlagValue("legacy-mode").?;
1369+
try std.testing.expect(v.Bool);
1370+
}
1371+
12541372
test "help: printUsageLine produces correct output" {
12551373
const allocator = std.testing.allocator;
12561374
var cmd = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });

src/chilli/deprecation.zig

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//! Emission of deprecation warnings for commands, flags, and positional args.
2+
//!
3+
//! When a caller invokes a definition that carries a non-null `deprecated`
4+
//! field, chilli prints a one-line warning to stderr (unless the
5+
//! `CHILLI_NO_DEPRECATION_WARNINGS` environment variable is set to any
6+
//! non-empty value). The command itself is still parsed and dispatched
7+
//! normally, so warnings never break existing scripts.
8+
const std = @import("std");
9+
const styles = @import("styles.zig");
10+
11+
// Module-level suppression cache: check the environment variable once,
12+
// reuse the result for every subsequent warning.
13+
var suppression_initialised: bool = false;
14+
var suppressed_cached: bool = false;
15+
16+
/// Returns true if deprecation warnings should be suppressed.
17+
/// On first call, looks up `CHILLI_NO_DEPRECATION_WARNINGS` and caches the
18+
/// result for the rest of the process.
19+
pub fn isSuppressed(allocator: std.mem.Allocator) bool {
20+
if (!suppression_initialised) {
21+
suppression_initialised = true;
22+
const environ = std.Options.debug_threaded_io.?.environ.process_environ;
23+
if (environ.getAlloc(allocator, "CHILLI_NO_DEPRECATION_WARNINGS")) |val| {
24+
defer allocator.free(val);
25+
suppressed_cached = val.len > 0;
26+
} else |_| {
27+
suppressed_cached = false;
28+
}
29+
}
30+
return suppressed_cached;
31+
}
32+
33+
/// Force suppression on or off. Used by tests to reset module state and
34+
/// to verify the suppression path without mutating the process environment.
35+
pub fn setSuppressedForTests(v: bool) void {
36+
suppression_initialised = true;
37+
suppressed_cached = v;
38+
}
39+
40+
/// Writes a deprecation-warning line to `writer`. Exposed for tests; the
41+
/// production path goes through `emit`, which writes to stderr.
42+
pub fn format(
43+
writer: anytype,
44+
kind: []const u8,
45+
name: []const u8,
46+
reason: []const u8,
47+
) !void {
48+
try writer.print(
49+
"{s}warning:{s} {s} '{s}' is deprecated: {s}\n",
50+
.{ styles.s(styles.YELLOW), styles.s(styles.RESET), kind, name, reason },
51+
);
52+
}
53+
54+
/// Emits a deprecation warning to stderr if suppression is not active.
55+
/// Failure to write is silently ignored (warnings must not disrupt the
56+
/// program).
57+
pub fn emit(
58+
allocator: std.mem.Allocator,
59+
kind: []const u8,
60+
name: []const u8,
61+
reason: []const u8,
62+
) void {
63+
if (isSuppressed(allocator)) return;
64+
const io = std.Options.debug_io;
65+
var buf: [1024]u8 = undefined;
66+
var stderr_fw = std.Io.File.stderr().writer(io, &buf);
67+
format(&stderr_fw.interface, kind, name, reason) catch return;
68+
stderr_fw.flush() catch {};
69+
}
70+
71+
// ============================================================================
72+
// Tests
73+
// ============================================================================
74+
75+
const TestBufWriter = struct {
76+
buf: []u8,
77+
pos: usize = 0,
78+
79+
fn print(self: *TestBufWriter, comptime fmt: []const u8, args: anytype) error{NoSpaceLeft}!void {
80+
const result = std.fmt.bufPrint(self.buf[self.pos..], fmt, args) catch return error.NoSpaceLeft;
81+
self.pos += result.len;
82+
}
83+
84+
fn written(self: TestBufWriter) []const u8 {
85+
return self.buf[0..self.pos];
86+
}
87+
};
88+
89+
test "deprecation: format writes a warning line with the expected shape" {
90+
// Disable ANSI styling for stable byte-level assertions.
91+
styles.setEnabled(false);
92+
defer styles.setEnabled(false);
93+
94+
var buf: [256]u8 = undefined;
95+
var writer = TestBufWriter{ .buf = &buf };
96+
try format(&writer, "flag", "--old", "use --new");
97+
try std.testing.expectEqualStrings(
98+
"warning: flag '--old' is deprecated: use --new\n",
99+
writer.written(),
100+
);
101+
}
102+
103+
test "deprecation: format includes ANSI codes when styles are enabled" {
104+
styles.setEnabled(true);
105+
defer styles.setEnabled(false);
106+
107+
var buf: [256]u8 = undefined;
108+
var writer = TestBufWriter{ .buf = &buf };
109+
try format(&writer, "command", "old-cmd", "removed in v0.5");
110+
const out = writer.written();
111+
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[33m") != null); // yellow
112+
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[0m") != null); // reset
113+
try std.testing.expect(std.mem.indexOf(u8, out, "command 'old-cmd' is deprecated: removed in v0.5") != null);
114+
}
115+
116+
test "deprecation: setSuppressedForTests overrides the env-var cache" {
117+
// Turn suppression on via the test hook; emit becomes a no-op.
118+
setSuppressedForTests(true);
119+
defer setSuppressedForTests(false);
120+
try std.testing.expect(isSuppressed(std.testing.allocator));
121+
122+
// Turn it back off; the cache stays primed so no env lookup happens here.
123+
setSuppressedForTests(false);
124+
try std.testing.expect(!isSuppressed(std.testing.allocator));
125+
}

src/chilli/parser.zig

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const std = @import("std");
33
const command = @import("command.zig");
44
const types = @import("types.zig");
55
const errors = @import("errors.zig");
6+
const deprecation = @import("deprecation.zig");
67

78
/// A simple forward-only iterator over a slice of string arguments.
89
pub const ArgIterator = struct {
@@ -82,6 +83,9 @@ fn parseSingleFlag(cmd: *command.Command, iterator: *ArgIterator) errors.Error!F
8283
.value = try types.parseValue(flag.type, val),
8384
});
8485
}
86+
if (flag.deprecated) |reason| {
87+
deprecation.emit(cmd.allocator, "flag", flag.name, reason);
88+
}
8589
return .parsed;
8690
}
8791

@@ -94,6 +98,9 @@ fn parseSingleFlag(cmd: *command.Command, iterator: *ArgIterator) errors.Error!F
9498

9599
if (flag.type == .Bool) {
96100
try cmd.parsed_flags.append(cmd.allocator, .{ .name = flag.name, .value = .{ .Bool = true } });
101+
if (flag.deprecated) |reason| {
102+
deprecation.emit(cmd.allocator, "flag", flag.name, reason);
103+
}
97104
} else {
98105
var value: []const u8 = undefined;
99106
var value_from_next_arg = false;
@@ -113,6 +120,9 @@ fn parseSingleFlag(cmd: *command.Command, iterator: *ArgIterator) errors.Error!F
113120
.name = flag.name,
114121
.value = try types.parseValue(flag.type, value),
115122
});
123+
if (flag.deprecated) |reason| {
124+
deprecation.emit(cmd.allocator, "flag", flag.name, reason);
125+
}
116126
break;
117127
}
118128
}

0 commit comments

Comments
 (0)