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

[![Tests](https://img.shields.io/github/actions/workflow/status/CogitatorTech/chilli/tests.yml?label=tests&style=flat&labelColor=282c34&logo=github)](https://github.com/CogitatorTech/chilli/actions/workflows/tests.yml) -[![Zig Version](https://img.shields.io/badge/Zig-0.16.0-orange?logo=zig&labelColor=282c34)](https://ziglang.org/download) +[![Zig](https://img.shields.io/badge/zig-0.16.0-F7A41D?style=flat&labelColor=282c34&logo=zig)](https://ziglang.org/download/) [![Docs](https://img.shields.io/badge/docs-read-blue?style=flat&labelColor=282c34&logo=read-the-docs)](https://CogitatorTech.github.io/chilli) [![Examples](https://img.shields.io/badge/examples-view-green?style=flat&labelColor=282c34&logo=zig)](https://github.com/CogitatorTech/chilli/tree/main/examples) [![Release](https://img.shields.io/github/release/CogitatorTech/chilli.svg?label=release&style=flat&labelColor=282c34&logo=github)](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