diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 3bc80fb..e55c60c 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -1,4 +1,4 @@
-name: Publish API documentation
+name: Publish API Documentation
on:
workflow_dispatch:
@@ -6,10 +6,6 @@ on:
tags:
- 'v*'
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
permissions:
contents: write
@@ -20,12 +16,12 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- - name: Install Zig 0.16.0
- run: |
- curl -sSfL https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz | tar -xJ
- echo "$PWD/zig-x86_64-linux-0.16.0" >> "$GITHUB_PATH"
+ - name: Install Zig
+ uses: goto-bus-stop/setup-zig@v2
+ with:
+ version: '0.16.0'
- - name: Install dependencies
+ - name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y make
@@ -33,7 +29,7 @@ jobs:
- name: Generate documentation
run: make docs
- - name: Deploy to GitHub pages
+ - name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/zig-master.yml b/.github/workflows/zig-master.yml
new file mode 100644
index 0000000..3197b87
--- /dev/null
+++ b/.github/workflows/zig-master.yml
@@ -0,0 +1,32 @@
+name: Zig Master Canary
+
+on:
+ workflow_dispatch:
+ schedule: # Run every Monday at 06:00 (UTC)
+ - cron: '0 6 * * 1'
+
+permissions:
+ contents: read
+
+jobs:
+ canary:
+ runs-on: ubuntu-latest
+ continue-on-error: true
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ lfs: true
+
+ - name: Install Zig (master)
+ uses: goto-bus-stop/setup-zig@v2
+ with:
+ version: master
+
+ - name: Build
+ run: make build
+
+ - name: Run tests
+ run: make test
diff --git a/AGENTS.md b/AGENTS.md
index b40042b..a7fa334 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,7 +12,7 @@ Priorities, in order:
1. Correctness of argument parsing, flag resolution, and help output.
2. Minimal public API for defining and running command trees from other Zig projects.
3. Zero non-Zig dependencies, maintainable, and well-tested code.
-4. Cross-platform support (Linux, macOS, and Windows).
+4. Cross-platform support (Linux, macOS, Windows, and Wasm).
## Core Rules
@@ -35,14 +35,17 @@ Priorities, in order:
- `src/lib.zig`: Public API entry point. Re-exports `Command`, `CommandOptions`, `Flag`, `FlagType`, `FlagValue`, `PositionalArg`, `CommandContext`,
`styles`, and `Error`.
-- `src/chilli/command.zig`: The `Command` struct (command tree, init/deinit, `run`, subcommand and flag registration).
-- `src/chilli/types.zig`: Core types (`CommandOptions`, `Flag`, `FlagType`, `FlagValue`, `PositionalArg`) and the `parseValue` helper.
+- `src/chilli/command.zig`: The `Command` struct, `CommandOptions`, init/deinit, `run`, subcommand and flag registration, and the private help-output
+ printers.
+- `src/chilli/types.zig`: Core types (`Flag`, `FlagType`, `FlagValue`, `PositionalArg`) and the `parseBool` / `parseValue` helpers.
- `src/chilli/parser.zig`: Argument-string parser (`ArgIterator`, `ParsedFlag`, long/short/grouped flag handling, positional handling).
- `src/chilli/context.zig`: The `CommandContext` passed to each command's `exec` function for typed flag and argument access.
- `src/chilli/errors.zig`: Error types produced by parsing and type coercion.
-- `src/chilli/utils.zig`: Shared helpers (`styles` for ANSI colors, `parseBool`, and other small utilities).
+- `src/chilli/styles.zig`: ANSI escape-code constants plus a TTY-gated `s()` wrapper used by the help and error output.
+- `src/chilli/deprecation.zig`: Warning formatter and stderr emitter for deprecated commands, flags, and positional arguments, with
+ `CHILLI_NO_DEPRECATION_WARNINGS` suppression.
- `examples/`: Self-contained example programs (`e1_simple_cli.zig` through `e8_flags_and_args.zig`) built as executables via `build.zig`.
-- `.github/workflows/`: CI workflows (`tests.yml` for unit tests on Linux and Windows, `docs.yml` for API doc deployment).
+- `.github/workflows/`: CI workflows (`tests.yml` for unit tests on Linux, macOS, and Windows, `docs.yml` for API doc deployment).
- `build.zig` / `build.zig.zon`: Zig build configuration and package metadata.
- `Makefile`: GNU Make wrapper around `zig build` targets.
- `docs/`: Generated API docs land in `docs/api/` (produced by `make docs`).
@@ -135,7 +138,7 @@ Good first tasks:
Before coding:
-1. Modules affected by the change (`command`, `parser`, `types`, `context`, `errors`, or `utils`).
+1. Modules affected by the change (`command`, `parser`, `types`, `context`, `errors`, `styles`, or `deprecation`).
2. Whether the change is user-visible in `--help` output, and if so, which examples will surface it.
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.
4. Cross-platform implications, especially for anything that touches environment variables, the filesystem, or process-args encoding.
diff --git a/README.md b/README.md
index 7550a49..151f910 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
Chilli
[](https://github.com/CogitatorTech/chilli/actions/workflows/tests.yml)
-[](https://ziglang.org/download)
+[](https://ziglang.org/download/)
[](https://CogitatorTech.github.io/chilli)
[](https://github.com/CogitatorTech/chilli/tree/main/examples)
[](https://github.com/CogitatorTech/chilli/releases/latest)
@@ -56,6 +56,8 @@ Replace `` with the desired branch or tag, like `main` (for the d
(for the specified release version).
This command will download Chilli and add it to Zig's global cache and update your project's `build.zig.zon` file.
+##### Zig Version Support
+
Zig version supported by the main releases of Chilli:
| Zig | Chilli Tags |
@@ -63,7 +65,7 @@ Zig version supported by the main releases of Chilli:
| `0.16.0` | `v0.3.x` |
| `0.15.x` | `v0.2.x` |
-The `main` branch normally tracks the latest (non-developmental) Zig release.
+The `main` branch normally is developed and build using the latest (non-developmental) Zig release.
#### Adding to Build Script
diff --git a/ROADMAP.md b/ROADMAP.md
index 6314304..f457755 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -27,6 +27,6 @@ It outlines features to be implemented and their current status.
- [x] Simple, declarative API for building commands
- [x] Named access for all flags and arguments
- [x] Shared context data for passing application state
- - [ ] Deprecation notices for commands or flags
+ - [x] Deprecation notices for commands, flags, and positional arguments
- [ ] Built-in TUI components (like spinners and progress bars)
- [ ] Automatic command history and completion
diff --git a/build.zig.zon b/build.zig.zon
index ef3c44f..86eb23d 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .chilli,
- .version = "0.3.1",
+ .version = "0.3.2",
.fingerprint = 0x6c259741ae4f5f73, // Changing this has security and trust implications.
.minimum_zig_version = "0.16.0",
.paths = .{
diff --git a/src/chilli/command.zig b/src/chilli/command.zig
index 147b931..caa7d9e 100644
--- a/src/chilli/command.zig
+++ b/src/chilli/command.zig
@@ -5,6 +5,7 @@ const context = @import("context.zig");
const styles = @import("styles.zig");
const types = @import("types.zig");
const errors = @import("errors.zig");
+const deprecation = @import("deprecation.zig");
/// Defines the configuration for a `Command`.
///
@@ -27,6 +28,11 @@ pub const CommandOptions = struct {
version: ?[]const u8 = null,
/// The name of the section under which this command should be grouped in a parent's help message.
section: []const u8 = "Commands",
+ /// If set, marks the command as deprecated. The value is a free-form
+ /// reason or replacement suggestion. The command is still dispatched,
+ /// and its `exec` is still run, but a warning is written to stderr when
+ /// this command resolves as the leaf of the invocation chain.
+ deprecated: ?[]const u8 = null,
};
/// Represents a single command in a CLI application.
@@ -273,6 +279,20 @@ pub const Command = struct {
try parser.validateArgs(current_cmd);
+ // Deprecation warnings for the resolved command and for any
+ // deprecated positional slots the user actually filled. Flag-level
+ // warnings fire inside the parser at parse time.
+ if (current_cmd.options.deprecated) |reason| {
+ deprecation.emit(current_cmd.allocator, "command", current_cmd.options.name, reason);
+ }
+ for (current_cmd.positional_args.items, 0..) |arg, i| {
+ if (arg.deprecated) |reason| {
+ if (i < current_cmd.parsed_positionals.items.len) {
+ deprecation.emit(current_cmd.allocator, "positional argument", arg.name, reason);
+ }
+ }
+ }
+
// Success, clear the out_failed_cmd
out_failed_cmd.* = null;
@@ -352,10 +372,12 @@ pub const Command = struct {
// Note: the automatic --version flag is added in `init` so `run` is
// safe to call more than once on the same command.
- // Collect process arguments via iterator
+ // Collect process arguments via iterator. `initAllocator` is the
+ // cross-platform form: plain `init` is a compile error on Windows
+ // and WASI, where parsing the command line requires allocation.
var args_list: std.ArrayList([]const u8) = .empty;
defer args_list.deinit(self.allocator);
- var args_iter = std.process.Args.Iterator.init(args);
+ var args_iter = try std.process.Args.Iterator.initAllocator(args, self.allocator);
defer args_iter.deinit();
while (args_iter.next()) |arg| {
try args_list.append(self.allocator, arg);
@@ -526,7 +548,9 @@ fn printAlignedCommands(commands: []*Command, writer: anytype) !void {
}
for (0..max_width - current_width + 2) |_| try writer.writeByte(' ');
- try writer.print("{s}\n", .{cmd.options.description});
+ try writer.print("{s}", .{cmd.options.description});
+ if (cmd.options.deprecated != null) try writer.print(" (deprecated)", .{});
+ try writer.print("\n", .{});
}
}
@@ -564,6 +588,7 @@ fn printAlignedFlags(cmd: *const Command, writer: anytype) !void {
.Float => |v| try writer.print(" (default: {})", .{v}),
.String => |v| try writer.print(" (default: \"{s}\")", .{v}),
}
+ if (flag.deprecated != null) try writer.print(" (deprecated)", .{});
try writer.print("\n", .{});
}
}
@@ -580,12 +605,14 @@ fn printAlignedPositionalArgs(cmd: *const Command, writer: anytype) !void {
try writer.print("{s}", .{arg.description});
if (arg.variadic) {
- try writer.print(" (variadic)\n", .{});
+ try writer.print(" (variadic)", .{});
} else if (arg.is_required) {
- try writer.print(" (required)\n", .{});
+ try writer.print(" (required)", .{});
} else {
- try writer.print(" (optional)\n", .{});
+ try writer.print(" (optional)", .{});
}
+ if (arg.deprecated != null) try writer.print(" (deprecated)", .{});
+ try writer.print("\n", .{});
}
}
@@ -1251,6 +1278,99 @@ test "help: printAlignedPositionalArgs produces correct padding" {
try std.testing.expect(std.mem.indexOf(u8, output, "(optional)") != null);
}
+test "deprecation: printAlignedFlags marks deprecated flag" {
+ const allocator = std.testing.allocator;
+ styles.setEnabled(false);
+ defer styles.setEnabled(false);
+
+ var cmd = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
+ defer cmd.deinit();
+ try cmd.addFlag(.{
+ .name = "old",
+ .type = .Bool,
+ .default_value = .{ .Bool = false },
+ .description = "Old flag",
+ .deprecated = "use --new",
+ });
+
+ var buf: [2048]u8 = undefined;
+ var writer = TestBufWriter{ .buf = &buf };
+ try printAlignedFlags(cmd, &writer);
+ const out = writer.getWritten();
+ try std.testing.expect(std.mem.indexOf(u8, out, "--old") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "(deprecated)") != null);
+}
+
+test "deprecation: printAlignedPositionalArgs marks deprecated arg" {
+ const allocator = std.testing.allocator;
+ styles.setEnabled(false);
+ defer styles.setEnabled(false);
+
+ var cmd = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
+ defer cmd.deinit();
+ try cmd.addPositional(.{
+ .name = "path",
+ .description = "Path to file",
+ .is_required = true,
+ .deprecated = "use --input flag",
+ });
+
+ var buf: [2048]u8 = undefined;
+ var writer = TestBufWriter{ .buf = &buf };
+ try printAlignedPositionalArgs(cmd, &writer);
+ const out = writer.getWritten();
+ try std.testing.expect(std.mem.indexOf(u8, out, "path") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "(required)") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "(deprecated)") != null);
+}
+
+test "deprecation: printAlignedCommands marks deprecated subcommand" {
+ const allocator = std.testing.allocator;
+ styles.setEnabled(false);
+ defer styles.setEnabled(false);
+
+ var root = try Command.init(allocator, .{ .name = "root", .description = "", .exec = dummyExec });
+ defer root.deinit();
+ const old_sub = try Command.init(allocator, .{
+ .name = "old-sub",
+ .description = "The old way",
+ .exec = dummyExec,
+ .deprecated = "use 'new-sub' instead",
+ });
+ try root.addSubcommand(old_sub);
+
+ var buf: [2048]u8 = undefined;
+ var writer = TestBufWriter{ .buf = &buf };
+ try printAlignedCommands(root.subcommands.items, &writer);
+ const out = writer.getWritten();
+ try std.testing.expect(std.mem.indexOf(u8, out, "old-sub") != null);
+ try std.testing.expect(std.mem.indexOf(u8, out, "(deprecated)") != null);
+}
+
+test "deprecation: flag is still parsed and honored when deprecated" {
+ // Contract: deprecation never breaks existing invocations. The flag
+ // must still be available via getFlagValue after parsing.
+ const allocator = std.testing.allocator;
+ deprecation.setSuppressedForTests(true); // silence the warning for this test
+ defer deprecation.setSuppressedForTests(false);
+
+ var cmd = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
+ defer cmd.deinit();
+ try cmd.addFlag(.{
+ .name = "legacy-mode",
+ .type = .Bool,
+ .default_value = .{ .Bool = false },
+ .description = "",
+ .deprecated = "removed in v0.5",
+ });
+
+ var failed_cmd: ?*const Command = null;
+ try cmd.execute(&[_][]const u8{"--legacy-mode"}, null, &failed_cmd);
+ try std.testing.expect(failed_cmd == null);
+ const v = cmd.getFlagValue("legacy-mode").?;
+ try std.testing.expect(v.Bool);
+}
+
test "help: printUsageLine produces correct output" {
const allocator = std.testing.allocator;
var cmd = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
diff --git a/src/chilli/deprecation.zig b/src/chilli/deprecation.zig
new file mode 100644
index 0000000..b465c86
--- /dev/null
+++ b/src/chilli/deprecation.zig
@@ -0,0 +1,125 @@
+//! Emission of deprecation warnings for commands, flags, and positional args.
+//!
+//! When a caller invokes a definition that carries a non-null `deprecated`
+//! field, chilli prints a one-line warning to stderr (unless the
+//! `CHILLI_NO_DEPRECATION_WARNINGS` environment variable is set to any
+//! non-empty value). The command itself is still parsed and dispatched
+//! normally, so warnings never break existing scripts.
+const std = @import("std");
+const styles = @import("styles.zig");
+
+// Module-level suppression cache: check the environment variable once,
+// reuse the result for every subsequent warning.
+var suppression_initialised: bool = false;
+var suppressed_cached: bool = false;
+
+/// Returns true if deprecation warnings should be suppressed.
+/// On first call, looks up `CHILLI_NO_DEPRECATION_WARNINGS` and caches the
+/// result for the rest of the process.
+pub fn isSuppressed(allocator: std.mem.Allocator) bool {
+ if (!suppression_initialised) {
+ suppression_initialised = true;
+ const environ = std.Options.debug_threaded_io.?.environ.process_environ;
+ if (environ.getAlloc(allocator, "CHILLI_NO_DEPRECATION_WARNINGS")) |val| {
+ defer allocator.free(val);
+ suppressed_cached = val.len > 0;
+ } else |_| {
+ suppressed_cached = false;
+ }
+ }
+ return suppressed_cached;
+}
+
+/// Force suppression on or off. Used by tests to reset module state and
+/// to verify the suppression path without mutating the process environment.
+pub fn setSuppressedForTests(v: bool) void {
+ suppression_initialised = true;
+ suppressed_cached = v;
+}
+
+/// Writes a deprecation-warning line to `writer`. Exposed for tests; the
+/// production path goes through `emit`, which writes to stderr.
+pub fn format(
+ writer: anytype,
+ kind: []const u8,
+ name: []const u8,
+ reason: []const u8,
+) !void {
+ try writer.print(
+ "{s}warning:{s} {s} '{s}' is deprecated: {s}\n",
+ .{ styles.s(styles.YELLOW), styles.s(styles.RESET), kind, name, reason },
+ );
+}
+
+/// Emits a deprecation warning to stderr if suppression is not active.
+/// Failure to write is silently ignored (warnings must not disrupt the
+/// program).
+pub fn emit(
+ allocator: std.mem.Allocator,
+ kind: []const u8,
+ name: []const u8,
+ reason: []const u8,
+) void {
+ if (isSuppressed(allocator)) return;
+ const io = std.Options.debug_io;
+ var buf: [1024]u8 = undefined;
+ var stderr_fw = std.Io.File.stderr().writer(io, &buf);
+ format(&stderr_fw.interface, kind, name, reason) catch return;
+ stderr_fw.flush() catch {};
+}
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+const TestBufWriter = struct {
+ buf: []u8,
+ pos: usize = 0,
+
+ fn print(self: *TestBufWriter, comptime fmt: []const u8, args: anytype) error{NoSpaceLeft}!void {
+ const result = std.fmt.bufPrint(self.buf[self.pos..], fmt, args) catch return error.NoSpaceLeft;
+ self.pos += result.len;
+ }
+
+ fn written(self: TestBufWriter) []const u8 {
+ return self.buf[0..self.pos];
+ }
+};
+
+test "deprecation: format writes a warning line with the expected shape" {
+ // Disable ANSI styling for stable byte-level assertions.
+ styles.setEnabled(false);
+ defer styles.setEnabled(false);
+
+ var buf: [256]u8 = undefined;
+ var writer = TestBufWriter{ .buf = &buf };
+ try format(&writer, "flag", "--old", "use --new");
+ try std.testing.expectEqualStrings(
+ "warning: flag '--old' is deprecated: use --new\n",
+ writer.written(),
+ );
+}
+
+test "deprecation: format includes ANSI codes when styles are enabled" {
+ styles.setEnabled(true);
+ defer styles.setEnabled(false);
+
+ var buf: [256]u8 = undefined;
+ var writer = TestBufWriter{ .buf = &buf };
+ try format(&writer, "command", "old-cmd", "removed in v0.5");
+ const out = writer.written();
+ try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[33m") != null); // yellow
+ try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[0m") != null); // reset
+ try std.testing.expect(std.mem.indexOf(u8, out, "command 'old-cmd' is deprecated: removed in v0.5") != null);
+}
+
+test "deprecation: setSuppressedForTests overrides the env-var cache" {
+ // Turn suppression on via the test hook; emit becomes a no-op.
+ setSuppressedForTests(true);
+ defer setSuppressedForTests(false);
+ try std.testing.expect(isSuppressed(std.testing.allocator));
+
+ // Turn it back off; the cache stays primed so no env lookup happens here.
+ setSuppressedForTests(false);
+ try std.testing.expect(!isSuppressed(std.testing.allocator));
+}
diff --git a/src/chilli/parser.zig b/src/chilli/parser.zig
index 8a4d088..ab1b2d6 100644
--- a/src/chilli/parser.zig
+++ b/src/chilli/parser.zig
@@ -3,6 +3,7 @@ const std = @import("std");
const command = @import("command.zig");
const types = @import("types.zig");
const errors = @import("errors.zig");
+const deprecation = @import("deprecation.zig");
/// A simple forward-only iterator over a slice of string arguments.
pub const ArgIterator = struct {
@@ -82,6 +83,9 @@ fn parseSingleFlag(cmd: *command.Command, iterator: *ArgIterator) errors.Error!F
.value = try types.parseValue(flag.type, val),
});
}
+ if (flag.deprecated) |reason| {
+ deprecation.emit(cmd.allocator, "flag", flag.name, reason);
+ }
return .parsed;
}
@@ -94,6 +98,9 @@ fn parseSingleFlag(cmd: *command.Command, iterator: *ArgIterator) errors.Error!F
if (flag.type == .Bool) {
try cmd.parsed_flags.append(cmd.allocator, .{ .name = flag.name, .value = .{ .Bool = true } });
+ if (flag.deprecated) |reason| {
+ deprecation.emit(cmd.allocator, "flag", flag.name, reason);
+ }
} else {
var value: []const u8 = undefined;
var value_from_next_arg = false;
@@ -113,6 +120,9 @@ fn parseSingleFlag(cmd: *command.Command, iterator: *ArgIterator) errors.Error!F
.name = flag.name,
.value = try types.parseValue(flag.type, value),
});
+ if (flag.deprecated) |reason| {
+ deprecation.emit(cmd.allocator, "flag", flag.name, reason);
+ }
break;
}
}
diff --git a/src/chilli/types.zig b/src/chilli/types.zig
index 4b93052..a866a96 100644
--- a/src/chilli/types.zig
+++ b/src/chilli/types.zig
@@ -62,6 +62,12 @@ pub const Flag = struct {
/// If set, the framework will check this environment variable for a value
/// if the flag is not provided on the command line.
env_var: ?[]const u8 = null,
+ /// If set, marks the flag as deprecated. The value is a free-form reason
+ /// or replacement suggestion (e.g., "use --new-flag instead"). The flag
+ /// is still parsed and honored, but a one-line warning is written to
+ /// stderr when it is used. Set `CHILLI_NO_DEPRECATION_WARNINGS=1` to
+ /// silence all deprecation warnings.
+ deprecated: ?[]const u8 = null,
};
/// Defines a positional argument for a command.
@@ -79,6 +85,11 @@ pub const PositionalArg = struct {
/// If `true`, this argument will capture all remaining positional arguments.
/// Only the last positional argument for a command can be variadic.
variadic: bool = false,
+ /// If set, marks the argument as deprecated. The value is a free-form
+ /// reason or replacement suggestion. The argument is still parsed and
+ /// honored; a warning is written to stderr when the user supplies a
+ /// value for it.
+ deprecated: ?[]const u8 = null,
};
// Tests for the `types` module