├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── lints.yml │ ├── tests.yml │ └── docs.yml ├── CODE_OF_CONDUCT.md ├── pyproject.toml ├── .editorconfig ├── src ├── lib.zig └── chilli │ ├── errors.zig │ ├── types.zig │ ├── utils.zig │ ├── parser.zig │ ├── context.zig │ └── command.zig ├── LICENSE ├── .pre-commit-config.yaml ├── examples ├── README.md ├── e3_help_output.zig ├── e4_custom_sections.zig ├── e2_nested_commands.zig ├── e7_calculator.zig ├── e8_flags_and_args.zig ├── e1_simple_cli.zig ├── e5_advanced_cli.zig └── e6_file_downloader.zig ├── ROADMAP.md ├── .gitignore ├── CONTRIBUTING.md ├── Makefile ├── README.md └── logo.svg /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ habedi ] 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | 3 | We adhere to the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) version 2.1. 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discussions 4 | url: https://github.com/CogitatorTech/chilli/discussions 5 | about: Please ask and answer general questions here 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "chilli" 3 | version = "0.1.0" 4 | description = "Python environment for Chilli" 5 | 6 | requires-python = ">=3.10,<4.0" 7 | dependencies = [ 8 | "python-dotenv (>=1.1.0,<2.0.0)", 9 | "pre-commit (>=4.2.0,<5.0.0)" 10 | ] 11 | 12 | [project.optional-dependencies] 13 | dev = [ 14 | "pytest>=8.0.1", 15 | "pytest-cov>=6.0.0", 16 | "pytest-mock>=3.14.0", 17 | "pytest-asyncio (>=0.26.0,<0.27.0)", 18 | "mypy>=1.11.1", 19 | "ruff>=0.9.3", 20 | "icecream (>=2.1.4,<3.0.0)" 21 | ] 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 1. 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Logs** 22 | If applicable, add logs to help explain your problem. 23 | 24 | **Additional context** 25 | Add any other context about the problem here. 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/lints.yml: -------------------------------------------------------------------------------- 1 | name: Run Linter Checks 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | workflow_dispatch: 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | lints: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Checkout Code 23 | uses: actions/checkout@v4 24 | 25 | - name: Install Zig 26 | uses: goto-bus-stop/setup-zig@v2 27 | with: 28 | version: '0.15.1' 29 | 30 | - name: Install Dependencies 31 | run: | 32 | sudo apt-get update 33 | sudo apt-get install -y make 34 | 35 | - name: Run Linters 36 | run: make lint 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | workflow_dispatch: 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | tests: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Checkout Repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Install Zig 26 | uses: goto-bus-stop/setup-zig@v2 27 | with: 28 | version: '0.15.1' 29 | 30 | - name: Install Dependencies 31 | run: | 32 | sudo apt-get update 33 | sudo apt-get install -y make 34 | 35 | - name: Run the Tests 36 | run: make test 37 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Top-most EditorConfig file 2 | root = true 3 | 4 | # Global settings (applicable to all files unless overridden) 5 | [*] 6 | charset = utf-8 # Default character encoding 7 | end_of_line = lf # Use LF for line endings (Unix-style) 8 | indent_style = space # Use spaces for indentation 9 | indent_size = 4 # Default indentation size 10 | insert_final_newline = true # Make sure files end with a newline 11 | trim_trailing_whitespace = true # Remove trailing whitespace 12 | 13 | # Zig files 14 | [*.zig] 15 | max_line_length = 100 16 | 17 | # Markdown files 18 | [*.md] 19 | max_line_length = 120 20 | trim_trailing_whitespace = false # Don't remove trailing whitespace in Markdown files 21 | 22 | # Bash scripts 23 | [*.sh] 24 | indent_size = 2 25 | 26 | # YAML files 27 | [*.{yml,yaml}] 28 | indent_size = 2 29 | 30 | # Python files 31 | [*.py] 32 | max_line_length = 100 33 | -------------------------------------------------------------------------------- /src/lib.zig: -------------------------------------------------------------------------------- 1 | //! Chilli is a command-line interface (CLI) miniframework for Zig progamming language. 2 | //! 3 | //! It provides a structured and type-safe way to build complex command-line applications 4 | //! with support for commands, subcommands, flags, and positional arguments. 5 | //! The main entry point for creating a CLI is the `Command` struct. 6 | const std = @import("std"); 7 | 8 | pub const Command = @import("chilli/command.zig").Command; 9 | pub const CommandOptions = @import("chilli/types.zig").CommandOptions; 10 | pub const Flag = @import("chilli/types.zig").Flag; 11 | pub const FlagType = @import("chilli/types.zig").FlagType; 12 | pub const FlagValue = @import("chilli/types.zig").FlagValue; 13 | pub const PositionalArg = @import("chilli/types.zig").PositionalArg; 14 | pub const CommandContext = @import("chilli/context.zig").CommandContext; 15 | pub const styles = @import("chilli/utils.zig").styles; 16 | pub const Error = @import("chilli/errors.zig").Error; 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Hassan Abedi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish API Documentation 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | deploy-docs: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout Repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Install Zig 21 | uses: goto-bus-stop/setup-zig@v2 22 | with: 23 | version: '0.15.1' 24 | 25 | - name: Install System Dependencies 26 | run: | 27 | sudo apt-get update 28 | sudo apt-get install -y make 29 | 30 | - name: Generate Documentation 31 | run: make docs 32 | 33 | - name: Deploy to GitHub Pages 34 | uses: peaceiris/actions-gh-pages@v4 35 | with: 36 | github_token: ${{ secrets.GITHUB_TOKEN }} 37 | publish_dir: ./docs/api 38 | publish_branch: gh-pages 39 | user_name: 'github-actions[bot]' 40 | user_email: 'github-actions[bot]@users.noreply.github.com' 41 | commit_message: "docs: Deploy documentation from ${{ github.sha }}" 42 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_stages: [ pre-push ] 2 | fail_fast: false 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v5.0.0 7 | hooks: 8 | - id: trailing-whitespace 9 | args: [ --markdown-linebreak-ext=md ] 10 | - id: end-of-file-fixer 11 | - id: mixed-line-ending 12 | - id: check-merge-conflict 13 | - id: check-added-large-files 14 | - id: detect-private-key 15 | - id: check-yaml 16 | - id: check-toml 17 | - id: check-json 18 | - id: check-docstring-first 19 | - id: pretty-format-json 20 | args: [ --autofix, --no-sort-keys ] 21 | 22 | - repo: local 23 | hooks: 24 | - id: format 25 | name: Format the code 26 | entry: make format 27 | language: system 28 | pass_filenames: false 29 | stages: [ pre-commit ] 30 | 31 | - id: lint 32 | name: Check code style 33 | entry: make lint 34 | language: system 35 | pass_filenames: false 36 | stages: [ pre-commit ] 37 | 38 | - id: test 39 | name: Run the tests 40 | entry: make test 41 | language: system 42 | pass_filenames: false 43 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | ## Chilli Examples 2 | 3 | | **#** | **File** | **Description** | 4 | |-------|--------------------------------------------------|-----------------------------------------------------------------------------| 5 | | 1 | [e1_simple_cli.zig](e1_simple_cli.zig) | A simple CLI application that shows basic command and flag parsing | 6 | | 2 | [e2_nested_commands.zig](e2_nested_commands.zig) | A CLI application with nested commands and subcommands | 7 | | 3 | [e3_help_output.zig](e3_help_output.zig) | An example that demonstrates automatic help output and usage information | 8 | | 4 | [e4_custom_sections.zig](e4_custom_sections.zig) | An example that demonstrates grouping subcommands into custom sections | 9 | | 5 | [e5_advanced_cli.zig](e5_advanced_cli.zig) | An example that combines multiple features of Chilli | 10 | | 6 | [e6_file_downloader.zig](e6_file_downloader.zig) | A CLI application that downloads files from the internet | 11 | | 7 | [e7_calculator.zig](e7_calculator.zig) | A simple calculator CLI that supports basic arithmetic operations | 12 | | 8 | [e8_flags_and_args.zig](e8_flags_and_args.zig) | An example that shows how to use flags and positional arguments in commands | 13 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | ## Feature Roadmap 2 | 3 | This document includes the roadmap for the Chilli project. 4 | It outlines features to be implemented and their current status. 5 | 6 | > [!IMPORTANT] 7 | > This roadmap is a work in progress and is subject to change. 8 | 9 | - **Command Structure** 10 | - [x] Nested commands and subcommands 11 | - [x] Command aliases and single-character shortcuts 12 | - [x] Persistent flags (flags on parent commands are available to children) 13 | 14 | - **Argument & Flag Parsing** 15 | - [x] Long flags (`--verbose`), short flags (`-v`), and grouped boolean flags (`-vf`) 16 | - [x] Positional Arguments (supports required, optional, and variadic) 17 | - [x] Type-safe access for flags and arguments (e.g., `ctx.getFlag("count", i64)`) 18 | - [x] Reading flag values from environment variables 19 | 20 | - **Help & Usage Output** 21 | - [x] Automatic and context-aware `--help` flag 22 | - [x] Automatic `--version` flag 23 | - [x] Clean, aligned help output for commands, flags, and arguments 24 | - [x] Grouping subcommands into custom sections 25 | 26 | - **Developer Experience** 27 | - [x] Simple, declarative API for building commands 28 | - [x] Named access for all flags and arguments 29 | - [x] Shared context data for passing application state 30 | - [ ] Deprecation notices for commands or flags 31 | - [ ] Built-in TUI components (like spinners and progress bars) 32 | - [ ] Automatic command history and completion 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ######################## 2 | # Python Specific 3 | ######################## 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # Virtual environments 9 | .env/ 10 | .venv/ 11 | env/ 12 | venv/ 13 | 14 | # Packaging 15 | .Python 16 | *.egg 17 | *.egg-info/ 18 | dist/ 19 | build/ 20 | MANIFEST 21 | 22 | # Python Dependency tools 23 | develop-eggs/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | .installed.cfg 34 | 35 | # Python Test & Coverage 36 | .tox/ 37 | .coverage 38 | .coverage.* 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | *.cover 43 | .hypothesis/ 44 | .pytest_cache/ 45 | htmlcov/ 46 | 47 | # Jupyter 48 | .ipynb_checkpoints 49 | 50 | ######################## 51 | # Zig Specific 52 | ######################## 53 | zig-cache/ 54 | .zig-cache/ 55 | zig-out/ 56 | 57 | ######################## 58 | # IDE / Editor Junk 59 | ######################## 60 | .idea/ 61 | *.iml 62 | .vscode/ 63 | *.swp 64 | *~ 65 | *.bak 66 | *.tmp 67 | *.log 68 | tags 69 | 70 | ######################## 71 | # Miscellaneous 72 | ######################## 73 | 74 | # Distribution 75 | bin/ 76 | obj/ 77 | tmp/ 78 | temp/ 79 | 80 | # System / misc 81 | .DS_Store 82 | *.patch 83 | *.orig 84 | *.dump 85 | 86 | # Local databases 87 | *.db 88 | *.sqlite 89 | *.wal 90 | *.duckdb 91 | 92 | # Dependency lock files (optional) 93 | poetry.lock 94 | uv.lock 95 | 96 | # Additional files and directories to ignore (put below) 97 | *_output.txt 98 | public/ 99 | docs/api/ 100 | *.o 101 | *.a 102 | *.so 103 | *.dylib 104 | *.dll 105 | *.exe 106 | latest 107 | -------------------------------------------------------------------------------- /src/chilli/errors.zig: -------------------------------------------------------------------------------- 1 | //! This module defines all public errors that can be returned by the Chilli framework. 2 | //! 3 | //! Consolidating errors here provides a single source of truth for error handling 4 | //! and makes it easier for users of the library to catch specific failures. 5 | const std = @import("std"); 6 | 7 | /// The set of all possible errors returned by the Chilli framework. 8 | pub const Error = error{ 9 | /// An unknown flag was provided (like `--nonexistent`). 10 | UnknownFlag, 11 | /// A flag that requires a value was provided without one. 12 | MissingFlagValue, 13 | /// Short flags were grouped incorrectly. 14 | InvalidFlagGrouping, 15 | /// A command was invoked without one of its required positional arguments. 16 | MissingRequiredArgument, 17 | /// A command was invoked with more positional arguments than it accepts. 18 | TooManyArguments, 19 | /// An invalid string was provided for a boolean flag (must be "true" or "false"). 20 | InvalidBoolString, 21 | /// Attempted to add an argument after a variadic one. 22 | VariadicArgumentNotLastError, 23 | /// Attempted to add a subcommand that already has a parent. 24 | CommandAlreadyHasParent, 25 | /// An integer value was outside the valid range for the requested type. 26 | IntegerValueOutOfRange, 27 | /// A float value was outside the valid range for the requested type. 28 | FloatValueOutOfRange, 29 | /// A flag with the same name or shortcut was already defined on this command. 30 | DuplicateFlag, 31 | /// Attempted to add a required positional argument after an optional one. 32 | RequiredArgumentAfterOptional, 33 | /// A command was defined with an empty string as an alias. 34 | EmptyAlias, 35 | } || std.fmt.ParseIntError || std.fmt.ParseFloatError || std.mem.Allocator.Error; 36 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contribution Guidelines 2 | 3 | Thank you for considering contributing to this project! 4 | Contributions are always welcome and appreciated. 5 | 6 | ### How to Contribute 7 | 8 | Please check the [issue tracker](https://github.com/CogitatorTech/chilli/issues) to see if there is an issue you 9 | would like to work on or if it has already been resolved. 10 | 11 | #### Reporting Bugs 12 | 13 | 1. Open an issue on the [issue tracker](https://github.com/CogitatorTech/chilli/issues). 14 | 2. Include information such as steps to reproduce the observed behavior and relevant logs or screenshots. 15 | 16 | #### Suggesting Features 17 | 18 | 1. Open an issue on the [issue tracker](https://github.com/CogitatorTech/chilli/issues). 19 | 2. Provide details about the feature, its purpose, and potential implementation ideas. 20 | 21 | ### Submitting Pull Requests 22 | 23 | - Make sure all tests pass before submitting a pull request. 24 | - Write a clear description of the changes you made and the reasons behind them. 25 | 26 | > [!IMPORTANT] 27 | > It's assumed that by submitting a pull request, you agree to license your contributions under the project's license. 28 | 29 | ### Development Workflow 30 | 31 | #### Prerequisites 32 | 33 | Install GNU Make on your system if it's not already installed. 34 | 35 | ```shell 36 | # For Debian-based systems like Debian, Ubuntu, etc. 37 | sudo apt-get install make 38 | ``` 39 | 40 | - Use the `make install-deps` command to install the development dependencies. 41 | 42 | #### Code Style 43 | 44 | - Use the `make format` command to format the code. 45 | 46 | #### Running Tests 47 | 48 | - Use the `make test` command to run the tests. 49 | 50 | #### Running Linters 51 | 52 | - Use the `make lint` command to run the linters. 53 | 54 | #### See Available Commands 55 | 56 | - Run `make help` to see all available commands for managing different tasks. 57 | 58 | ### Code of Conduct 59 | 60 | We adhere to the project's [Code of Conduct](CODE_OF_CONDUCT.md). 61 | -------------------------------------------------------------------------------- /examples/e3_help_output.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const chilli = @import("chilli"); 3 | 4 | fn dummyExec(ctx: chilli.CommandContext) !void { 5 | _ = ctx; 6 | } 7 | 8 | pub fn main() anyerror!void { 9 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 10 | defer _ = gpa.deinit(); 11 | const allocator = gpa.allocator(); 12 | 13 | var root_cmd = try chilli.Command.init(allocator, .{ 14 | .name = "help-demo", 15 | .description = "A demonstration of Chilli's automatic help output.", 16 | .version = "v1.0.0", 17 | .exec = dummyExec, 18 | }); 19 | defer root_cmd.deinit(); 20 | 21 | try root_cmd.addFlag(.{ 22 | .name = "verbose", 23 | .shortcut = 'v', 24 | .description = "Enable verbose logging", 25 | .type = .Bool, 26 | .default_value = .{ .Bool = false }, 27 | }); 28 | 29 | try root_cmd.addFlag(.{ 30 | .name = "output", 31 | .shortcut = 'o', 32 | .description = "Specify an output file", 33 | .type = .String, 34 | .default_value = .{ .String = "stdout" }, 35 | }); 36 | 37 | var sub_cmd = try chilli.Command.init(allocator, .{ 38 | .name = "sub", 39 | .description = "A subcommand with its own arguments.", 40 | .exec = dummyExec, 41 | }); 42 | try root_cmd.addSubcommand(sub_cmd); 43 | 44 | try sub_cmd.addPositional(.{ 45 | .name = "input", 46 | .description = "The input file to process.", 47 | .is_required = true, 48 | }); 49 | 50 | std.debug.print("--- Help for root command ('help-demo --help') ---\n", .{}); 51 | try root_cmd.printHelp(); 52 | 53 | std.debug.print("\n--- Help for subcommand ('help-demo sub --help') ---\n", .{}); 54 | try sub_cmd.printHelp(); 55 | } 56 | 57 | // Example Invocation 58 | // 59 | // This example does not parse command-line arguments. Instead, its main function 60 | // programmatically builds the command structure and prints the help messages to 61 | // show what the output looks like. 62 | // 63 | // You can run it directly with: 64 | // zig build run-e3_help_output 65 | -------------------------------------------------------------------------------- /examples/e4_custom_sections.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const chilli = @import("chilli"); 3 | 4 | fn dummyExec(ctx: chilli.CommandContext) !void { 5 | try ctx.command.printHelp(); 6 | } 7 | 8 | pub fn main() anyerror!void { 9 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 10 | defer _ = gpa.deinit(); 11 | const allocator = gpa.allocator(); 12 | 13 | var root_cmd = try chilli.Command.init(allocator, .{ 14 | .name = "section-demo", 15 | .description = "Demonstrates custom subcommand sections.", 16 | .exec = dummyExec, 17 | }); 18 | defer root_cmd.deinit(); 19 | 20 | const cmd_get = try chilli.Command.init(allocator, .{ 21 | .name = "get", 22 | .description = "Get a resource.", 23 | .exec = dummyExec, 24 | .section = "Core Commands", 25 | }); 26 | try root_cmd.addSubcommand(cmd_get); 27 | 28 | const cmd_set = try chilli.Command.init(allocator, .{ 29 | .name = "set", 30 | .description = "Set a resource.", 31 | .exec = dummyExec, 32 | .section = "Core Commands", 33 | }); 34 | try root_cmd.addSubcommand(cmd_set); 35 | 36 | const cmd_config = try chilli.Command.init(allocator, .{ 37 | .name = "config", 38 | .description = "Configure the application.", 39 | .exec = dummyExec, 40 | .section = "Management Commands", 41 | }); 42 | try root_cmd.addSubcommand(cmd_config); 43 | 44 | const cmd_auth = try chilli.Command.init(allocator, .{ 45 | .name = "auth", 46 | .description = "Authenticate with the service.", 47 | .exec = dummyExec, 48 | .section = "Management Commands", 49 | }); 50 | try root_cmd.addSubcommand(cmd_auth); 51 | 52 | const cmd_other = try chilli.Command.init(allocator, .{ 53 | .name = "other", 54 | .description = "Another command.", 55 | .exec = dummyExec, 56 | }); 57 | try root_cmd.addSubcommand(cmd_other); 58 | 59 | try root_cmd.run(null); 60 | } 61 | 62 | // Example Invocation 63 | // 64 | // This example's main purpose is to demonstrate the custom section titles in the 65 | // help output. The root command is configured to print its help message by default. 66 | // 67 | // You can see the formatted output by running: 68 | // zig build run-e4_custom_sections 69 | // 70 | // Or, to invoke help explicitly: 71 | // ./zig-out/bin/e4_custom_sections --help 72 | -------------------------------------------------------------------------------- /examples/e2_nested_commands.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const chilli = @import("chilli"); 3 | 4 | fn rootExec(ctx: chilli.CommandContext) !void { 5 | try ctx.command.printHelp(); 6 | } 7 | 8 | fn dbMigrateExec(ctx: chilli.CommandContext) !void { 9 | _ = ctx; 10 | const stdout = std.fs.File.stdout().deprecatedWriter(); 11 | try stdout.print("Running database migrations...\n", .{}); 12 | } 13 | 14 | fn dbSeedExec(ctx: chilli.CommandContext) !void { 15 | const file = try ctx.getArg("file", []const u8); 16 | const stdout = std.fs.File.stdout().deprecatedWriter(); 17 | try stdout.print("Seeding database from file: {s}\n", .{file}); 18 | } 19 | 20 | pub fn main() anyerror!void { 21 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 22 | defer _ = gpa.deinit(); 23 | const allocator = gpa.allocator(); 24 | 25 | var root_cmd = try chilli.Command.init(allocator, .{ 26 | .name = "app", 27 | .description = "An application with nested commands.", 28 | .exec = rootExec, 29 | }); 30 | defer root_cmd.deinit(); 31 | 32 | var db_cmd = try chilli.Command.init(allocator, .{ 33 | .name = "db", 34 | .description = "Manage the application database.", 35 | .exec = rootExec, 36 | }); 37 | try root_cmd.addSubcommand(db_cmd); 38 | 39 | const db_migrate_cmd = try chilli.Command.init(allocator, .{ 40 | .name = "migrate", 41 | .description = "Run database migrations.", 42 | .exec = dbMigrateExec, 43 | }); 44 | try db_cmd.addSubcommand(db_migrate_cmd); 45 | 46 | var db_seed_cmd = try chilli.Command.init(allocator, .{ 47 | .name = "seed", 48 | .description = "Seed the database with initial data.", 49 | .exec = dbSeedExec, 50 | }); 51 | try db_cmd.addSubcommand(db_seed_cmd); 52 | 53 | try db_seed_cmd.addPositional(.{ 54 | .name = "file", 55 | .description = "The seed file to use.", 56 | .is_required = true, 57 | }); 58 | 59 | try root_cmd.run(null); 60 | } 61 | 62 | // Example Invocations 63 | // 64 | // 1. Build the example executable: 65 | // zig build e2_nested_commands 66 | // 67 | // 2. Run with different arguments: 68 | // 69 | // // Show help for the root 'app' command 70 | // ./zig-out/bin/e2_nested_commands --help 71 | // 72 | // // Show help for the 'db' subcommand 73 | // ./zig-out/bin/e2_nested_commands db --help 74 | // 75 | // // Execute the 'db migrate' command 76 | // ./zig-out/bin/e2_nested_commands db migrate 77 | // 78 | // // Execute the 'db seed' command with its required file argument 79 | // ./zig-out/bin/e2_nested_commands db seed data/seeds.sql 80 | -------------------------------------------------------------------------------- /examples/e7_calculator.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const chilli = @import("chilli"); 3 | const Command = chilli.Command; 4 | const CommandOptions = chilli.CommandOptions; 5 | const CommandContext = chilli.CommandContext; 6 | const PositionalArg = chilli.PositionalArg; 7 | 8 | fn addExec(ctx: CommandContext) !void { 9 | const a = try ctx.getArg("a", f64); 10 | const b = try ctx.getArg("b", f64); 11 | const result = a + b; 12 | const stdout = std.fs.File.stdout().deprecatedWriter(); 13 | try stdout.print("{d} + {d} = {d}\n", .{ a, b, result }); 14 | } 15 | 16 | fn subtractExec(ctx: CommandContext) !void { 17 | const a = try ctx.getArg("a", f64); 18 | const b = try ctx.getArg("b", f64); 19 | const result = a - b; 20 | const stdout = std.fs.File.stdout().deprecatedWriter(); 21 | try stdout.print("{d} - {d} = {d}\n", .{ a, b, result }); 22 | } 23 | 24 | fn multiplyExec(ctx: CommandContext) !void { 25 | const a = try ctx.getArg("a", f64); 26 | const b = try ctx.getArg("b", f64); 27 | const result = a * b; 28 | const stdout = std.fs.File.stdout().deprecatedWriter(); 29 | try stdout.print("{d} * {d} = {d}\n", .{ a, b, result }); 30 | } 31 | 32 | fn calculatorRootExec(ctx: CommandContext) !void { 33 | try ctx.command.printHelp(); 34 | } 35 | 36 | fn makeOperationCmd( 37 | allocator: std.mem.Allocator, 38 | options: CommandOptions, 39 | ) !*Command { 40 | var cmd = try Command.init(allocator, options); 41 | try cmd.addPositional(PositionalArg{ .name = "a", .description = "First number", .is_required = true, .type = .Float }); 42 | try cmd.addPositional(PositionalArg{ .name = "b", .description = "Second number", .is_required = true, .type = .Float }); 43 | return cmd; 44 | } 45 | 46 | pub fn main() !void { 47 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 48 | defer _ = gpa.deinit(); 49 | const allocator = gpa.allocator(); 50 | 51 | var root_cmd = try Command.init(allocator, CommandOptions{ 52 | .name = "calculator", 53 | .description = "A simple CLI calculator.", 54 | .exec = calculatorRootExec, 55 | }); 56 | defer root_cmd.deinit(); 57 | 58 | const add_cmd = try makeOperationCmd(allocator, .{ 59 | .name = "add", 60 | .description = "Adds two numbers.", 61 | .exec = addExec, 62 | }); 63 | 64 | const subtract_cmd = try makeOperationCmd(allocator, .{ 65 | .name = "subtract", 66 | .description = "Subtracts two numbers.", 67 | .exec = subtractExec, 68 | }); 69 | 70 | const multiply_cmd = try makeOperationCmd(allocator, .{ 71 | .name = "multiply", 72 | .description = "Multiplies two numbers.", 73 | .exec = multiplyExec, 74 | }); 75 | 76 | try root_cmd.addSubcommand(add_cmd); 77 | try root_cmd.addSubcommand(subtract_cmd); 78 | try root_cmd.addSubcommand(multiply_cmd); 79 | 80 | try root_cmd.run(null); 81 | } 82 | 83 | // Example Invocations 84 | // 85 | // 1. Build the example executable: 86 | // zig build e7_calculator 87 | // 88 | // 2. Run with different arguments: 89 | // 90 | // // Add two numbers 91 | // ./zig-out/bin/e7_calculator add 10.5 22 92 | // 93 | // // Subtract two numbers 94 | // ./zig-out/bin/e7_calculator subtract 100 42.5 95 | // 96 | // // Multiply two numbers 97 | // ./zig-out/bin/e7_calculator multiply -5.5 10 98 | -------------------------------------------------------------------------------- /examples/e8_flags_and_args.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const chilli = @import("chilli"); 3 | 4 | fn exec(ctx: chilli.CommandContext) !void { 5 | const count = try ctx.getFlag("count", i32); 6 | const message = try ctx.getFlag("message", []const u8); 7 | const force = try ctx.getFlag("force", bool); 8 | 9 | const required_arg = try ctx.getArg("required-arg", []const u8); 10 | const optional_arg = try ctx.getArg("optional-arg", []const u8); 11 | const variadic_args = ctx.getArgs("variadic-args"); 12 | 13 | const stdout = std.fs.File.stdout().deprecatedWriter(); 14 | 15 | try stdout.print("Flags:\n", .{}); 16 | try stdout.print(" --count: {d}\n", .{count}); 17 | try stdout.print(" --message: {s}\n", .{message}); 18 | try stdout.print(" --force: {}\n", .{force}); 19 | 20 | try stdout.print("\nArguments:\n", .{}); 21 | try stdout.print(" required-arg: {s}\n", .{required_arg}); 22 | try stdout.print(" optional-arg: {s}\n", .{optional_arg}); 23 | try stdout.print(" variadic-args: {any}\n", .{variadic_args}); 24 | } 25 | 26 | pub fn main() anyerror!void { 27 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 28 | defer _ = gpa.deinit(); 29 | const allocator = gpa.allocator(); 30 | 31 | var root_cmd = try chilli.Command.init(allocator, .{ 32 | .name = "flags-args-demo", 33 | .description = "Demonstrates various flags and arguments.", 34 | .exec = exec, 35 | }); 36 | defer root_cmd.deinit(); 37 | 38 | try root_cmd.addFlag(.{ 39 | .name = "count", 40 | .shortcut = 'c', 41 | .description = "A number of times to do something.", 42 | .type = .Int, 43 | .default_value = .{ .Int = 1 }, 44 | }); 45 | 46 | try root_cmd.addFlag(.{ 47 | .name = "message", 48 | .description = "A message to print.", 49 | .type = .String, 50 | .default_value = .{ .String = "default message" }, 51 | }); 52 | 53 | try root_cmd.addFlag(.{ 54 | .name = "force", 55 | .shortcut = 'f', 56 | .description = "Force an operation.", 57 | .type = .Bool, 58 | .default_value = .{ .Bool = false }, 59 | }); 60 | 61 | try root_cmd.addPositional(.{ 62 | .name = "required-arg", 63 | .description = "A truly required argument.", 64 | .is_required = true, 65 | }); 66 | 67 | try root_cmd.addPositional(.{ 68 | .name = "optional-arg", 69 | .description = "An optional argument.", 70 | .is_required = false, 71 | .default_value = .{ .String = "default value" }, 72 | }); 73 | 74 | try root_cmd.addPositional(.{ 75 | .name = "variadic-args", 76 | .description = "Any number of additional arguments.", 77 | .variadic = true, 78 | }); 79 | 80 | try root_cmd.run(null); 81 | } 82 | 83 | // Example Invocations 84 | // 85 | // 1. Build the example executable: 86 | // zig build e8_flags_and_args 87 | // 88 | // 2. Run with different arguments: 89 | // 90 | // // Show the help output 91 | // ./zig-out/bin/e8_flags_and_args --help 92 | // 93 | // // Run with only the required argument 94 | // ./zig-out/bin/e8_flags_and_args required_value 95 | // 96 | // // Run with all arguments and a mix of long and short flags 97 | // ./zig-out/bin/e8_flags_and_args req_val opt_val --count 10 -f 98 | // 99 | // // Run with variadic arguments and grouped boolean flags 100 | // ./zig-out/bin/e8_flags_and_args req_val opt_val extra1 extra2 extra3 -cf 101 | -------------------------------------------------------------------------------- /examples/e1_simple_cli.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const chilli = @import("chilli"); 3 | 4 | const AppContext = struct { 5 | config_path: []const u8, 6 | }; 7 | 8 | pub fn main() anyerror!void { 9 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 10 | defer _ = gpa.deinit(); 11 | const allocator = gpa.allocator(); 12 | 13 | var app_context = AppContext{ 14 | .config_path = "", 15 | }; 16 | 17 | defer if (app_context.config_path.len > 0) allocator.free(app_context.config_path); 18 | 19 | const root_options = chilli.CommandOptions{ 20 | .name = "chilli-app", 21 | .description = "A simple example CLI using Chilli.", 22 | .version = "v1.0.0", 23 | .exec = rootExec, 24 | }; 25 | var root_command = try chilli.Command.init(allocator, root_options); 26 | defer root_command.deinit(); 27 | 28 | const config_flag = chilli.Flag{ 29 | .name = "config", 30 | .description = "Path to the configuration file", 31 | .type = .String, 32 | .default_value = .{ .String = "/etc/chilli.conf" }, 33 | .env_var = "CHILLI_APP_CONFIG", 34 | }; 35 | try root_command.addFlag(config_flag); 36 | 37 | const run_options = chilli.CommandOptions{ 38 | .name = "run", 39 | .description = "Runs a task against a list of files.", 40 | .exec = runExec, 41 | }; 42 | var run_command = try chilli.Command.init(allocator, run_options); 43 | try root_command.addSubcommand(run_command); 44 | 45 | try run_command.addPositional(.{ 46 | .name = "task-name", 47 | .description = "The name of the task to run.", 48 | .is_required = true, 49 | .type = .String, 50 | }); 51 | try run_command.addPositional(.{ 52 | .name = "files", 53 | .description = "A list of files to process.", 54 | .variadic = true, 55 | }); 56 | 57 | try root_command.run(&app_context); 58 | } 59 | 60 | fn rootExec(ctx: chilli.CommandContext) anyerror!void { 61 | const app_ctx = ctx.getContextData(AppContext).?; 62 | const config_slice = try ctx.getFlag("config", []const u8); 63 | const stdout = std.fs.File.stdout().deprecatedWriter(); 64 | 65 | if (app_ctx.config_path.len > 0) { 66 | ctx.app_allocator.free(app_ctx.config_path); 67 | } 68 | app_ctx.config_path = try ctx.app_allocator.dupe(u8, config_slice); 69 | 70 | try stdout.print("Welcome to chilli-app!\n", .{}); 71 | try stdout.print(" Using config file: {s}\n\n", .{app_ctx.config_path}); 72 | try ctx.command.printHelp(); 73 | } 74 | 75 | fn runExec(ctx: chilli.CommandContext) anyerror!void { 76 | const task_name = try ctx.getArg("task-name", []const u8); 77 | const files = ctx.getArgs("files"); 78 | const stdout = std.fs.File.stdout().deprecatedWriter(); 79 | 80 | try stdout.print("Running task '{s}'...\n", .{task_name}); 81 | 82 | if (files.len == 0) { 83 | try stdout.print("No files provided to process.\n", .{}); 84 | } else { 85 | try stdout.print("Processing {d} files:\n", .{files.len}); 86 | for (files) |file| { 87 | try stdout.print(" - {s}\n", .{file}); 88 | } 89 | } 90 | } 91 | 92 | // Example Invocations 93 | // 94 | // 1. Build the example executable: 95 | // zig build e1_simple_cli 96 | // 97 | // 2. Run with different arguments: 98 | // 99 | // // Show the help output for the root command 100 | // ./zig-out/bin/e1_simple_cli --help 101 | // 102 | // // Run the 'run' subcommand with a task name and a list of files 103 | // ./zig-out/bin/e1_simple_cli run build-assets main.js styles.css script.js 104 | // 105 | // // Use the --config flag from the root command 106 | // ./zig-out/bin/e1_simple_cli --config ./custom.conf run process-logs 107 | // 108 | // // Use the environment variable to set the config path 109 | // CHILLI_APP_CONFIG=~/.config/chilli.conf ./zig-out/bin/e1_simple_cli run check-status 110 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # ################################################################################ 2 | # # Configuration and Variables 3 | # ################################################################################ 4 | ZIG ?= $(shell which zig || echo ~/.local/share/zig/0.15.1/zig) 5 | BUILD_TYPE ?= Debug 6 | BUILD_OPTS = -Doptimize=$(BUILD_TYPE) 7 | JOBS ?= $(shell nproc || echo 2) 8 | SRC_DIR := src 9 | EXAMPLES_DIR := examples 10 | BUILD_DIR := zig-out 11 | CACHE_DIR := .zig-cache 12 | BINARY_NAME := example 13 | RELEASE_MODE := ReleaseSmall 14 | TEST_FLAGS := --summary all #--verbose 15 | JUNK_FILES := *.o *.obj *.dSYM *.dll *.so *.dylib *.a *.lib *.pdb 16 | 17 | # Automatically find all example names 18 | EXAMPLES := $(patsubst %.zig,%,$(notdir $(wildcard examples/*.zig))) 19 | EXAMPLE ?= all 20 | 21 | SHELL := /usr/bin/env bash 22 | .SHELLFLAGS := -eu -o pipefail -c 23 | 24 | ################################################################################ 25 | # Targets 26 | ################################################################################ 27 | 28 | .PHONY: all help build rebuild run test release clean lint format docs serve-docs install-deps setup-hooks test-hooks 29 | .DEFAULT_GOAL := help 30 | 31 | help: ## Show the help messages for all targets 32 | @echo "Usage: make " 33 | @echo "" 34 | @echo "Targets:" 35 | @grep -E '^[a-zA-Z_-]+:.*## .*$$' Makefile | \ 36 | awk 'BEGIN {FS = ":.*## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' 37 | 38 | all: build test lint docs ## build, test, lint, and doc 39 | 40 | build: ## Build project (Mode=$(BUILD_TYPE)) 41 | @echo "Building project in $(BUILD_TYPE) mode with $(JOBS) concurrent jobs..." 42 | @$(ZIG) build $(BUILD_OPTS) -j$(JOBS) 43 | 44 | rebuild: clean build ## clean and build 45 | 46 | run: ## Run an example (e.g. 'make run EXAMPLE=e1_simple_cli' or 'make run' for all) 47 | @if [ "$(EXAMPLE)" = "all" ]; then \ 48 | echo "--> Running all examples..."; \ 49 | for ex in $(EXAMPLES); do \ 50 | echo ""; \ 51 | echo "--> Running example: $$ex"; \ 52 | $(ZIG) build run-$$ex $(BUILD_OPTS); \ 53 | done; \ 54 | else \ 55 | echo "--> Running example: $(EXAMPLE)"; \ 56 | $(ZIG) build run-$(EXAMPLE) $(BUILD_OPTS); \ 57 | fi 58 | 59 | test: ## Run tests 60 | @echo "Running tests..." 61 | @$(ZIG) build test $(BUILD_OPTS) -j$(JOBS) $(TEST_FLAGS) 62 | 63 | release: ## Build in Release mode 64 | @echo "Building the project in Release mode..." 65 | @$(MAKE) BUILD_TYPE=$(RELEASE_MODE) build 66 | 67 | clean: ## Remove docs, build artifacts, and cache directories 68 | @echo "Removing build artifacts, cache, generated docs, and junk files..." 69 | @rm -rf $(BUILD_DIR) $(CACHE_DIR) $(JUNK_FILES) docs/api public 70 | 71 | lint: ## Check code style and formatting of Zig files 72 | @echo "Running code style checks..." 73 | @$(ZIG) fmt --check $(SRC_DIR) $(EXAMPLES_DIR) 74 | 75 | format: ## Format Zig files 76 | @echo "Formatting Zig files..." 77 | @$(ZIG) fmt . 78 | 79 | docs: ## Generate API documentation 80 | @echo "Generating API documentation..." 81 | @$(ZIG) build docs 82 | 83 | serve-docs: ## Serve the generated documentation on a local server 84 | @echo "Serving documentation at http://localhost:8000..." 85 | @cd docs/api && python3 -m http.server 8000 86 | 87 | install-deps: ## Install system dependencies (for Debian-based systems) 88 | @echo "Installing system dependencies..." 89 | @sudo apt-get update 90 | @sudo apt-get install -y make llvm snapd 91 | @sudo snap install zig --beta --classic 92 | 93 | setup-hooks: ## Install Git hooks (pre-commit and pre-push) 94 | @echo "Setting up Git hooks..." 95 | @if ! command -v pre-commit &> /dev/null; then \ 96 | echo "pre-commit not found. Please install it using 'pip install pre-commit'"; \ 97 | exit 1; \ 98 | fi 99 | @pre-commit install --hook-type pre-commit 100 | @pre-commit install --hook-type pre-push 101 | @pre-commit install-hooks 102 | 103 | test-hooks: ## Test Git hooks on all files 104 | @echo "Testing Git hooks..." 105 | @pre-commit run --all-files --show-diff-on-failure 106 | -------------------------------------------------------------------------------- /examples/e5_advanced_cli.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const chilli = @import("chilli"); 3 | 4 | const AppContext = struct { 5 | log_level: u8, 6 | start_time: i64, 7 | }; 8 | 9 | fn rootExec(ctx: chilli.CommandContext) !void { 10 | const is_verbose = try ctx.getFlag("verbose", bool); 11 | std.debug.print("Advanced CLI Example\n", .{}); 12 | std.debug.print(" - Verbose mode: {}\n", .{is_verbose}); 13 | 14 | if (ctx.getContextData(AppContext)) |app_ctx| { 15 | app_ctx.log_level = if (is_verbose) 1 else 0; 16 | } 17 | 18 | try ctx.command.printHelp(); 19 | } 20 | 21 | fn addExec(ctx: chilli.CommandContext) !void { 22 | if (try ctx.getFlag("verbose", bool)) { 23 | const app_ctx = ctx.getContextData(AppContext).?; 24 | std.debug.print("Running 'add' command... Log Level: {d}\n", .{app_ctx.log_level}); 25 | } 26 | 27 | const a = try ctx.getArg("a", i64); 28 | const b = try ctx.getArg("b", i64); 29 | const precision = try ctx.getFlag("precision", f64); 30 | const result = @as(f64, @floatFromInt(a)) + @as(f64, @floatFromInt(b)); 31 | 32 | const stdout = std.fs.File.stdout().deprecatedWriter(); 33 | const precision_int: u32 = @intFromFloat(@max(0.0, @min(precision, 20.0))); 34 | 35 | var buf: [64]u8 = undefined; 36 | const formatted_result = try std.fmt.bufPrint(&buf, "{d:.[prec]}", .{ 37 | .num = result, 38 | .prec = precision_int, 39 | }); 40 | 41 | try stdout.print("Result: {s}\n", .{formatted_result}); 42 | } 43 | 44 | fn greetExec(ctx: chilli.CommandContext) !void { 45 | const name = try ctx.getArg("name", []const u8); 46 | const stdout = std.fs.File.stdout().deprecatedWriter(); 47 | try stdout.print("Hello, {s}!\n", .{name}); 48 | } 49 | 50 | pub fn main() anyerror!void { 51 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 52 | defer _ = gpa.deinit(); 53 | const allocator = gpa.allocator(); 54 | 55 | var app_context = AppContext{ 56 | .log_level = 0, 57 | .start_time = std.time.timestamp(), 58 | }; 59 | 60 | var root_cmd = try chilli.Command.init(allocator, .{ 61 | .name = "comp-cli", 62 | .description = "A comprehensive example of the Chilli framework.", 63 | .version = "v1.2.3", 64 | .exec = rootExec, 65 | }); 66 | defer root_cmd.deinit(); 67 | 68 | try root_cmd.addFlag(.{ 69 | .name = "verbose", 70 | .shortcut = 'v', 71 | .description = "Enable verbose output", 72 | .type = .Bool, 73 | .default_value = .{ .Bool = false }, 74 | }); 75 | 76 | var add_cmd = try chilli.Command.init(allocator, .{ 77 | .name = "add", 78 | .description = "Adds two integers", 79 | .aliases = &[_][]const u8{"sum"}, 80 | .shortcut = 'a', 81 | .exec = addExec, 82 | }); 83 | try root_cmd.addSubcommand(add_cmd); 84 | 85 | try add_cmd.addFlag(.{ 86 | .name = "precision", 87 | .type = .Float, 88 | .description = "Number of decimal places for the output", 89 | .default_value = .{ .Float = 2.0 }, 90 | }); 91 | 92 | try add_cmd.addPositional(.{ .name = "a", .description = "First number", .is_required = true, .type = .Int }); 93 | try add_cmd.addPositional(.{ .name = "b", .description = "Second number", .is_required = true, .type = .Int }); 94 | 95 | var greet_cmd = try chilli.Command.init(allocator, .{ 96 | .name = "greet", 97 | .description = "Prints a greeting", 98 | .exec = greetExec, 99 | .section = "Extra Commands", 100 | }); 101 | try root_cmd.addSubcommand(greet_cmd); 102 | 103 | try greet_cmd.addPositional(.{ 104 | .name = "name", 105 | .description = "The name to greet", 106 | .is_required = false, 107 | .default_value = .{ .String = "World" }, 108 | }); 109 | 110 | try root_cmd.run(&app_context); 111 | } 112 | 113 | // Example Invocations 114 | // 115 | // 1. Build the example executable: 116 | // zig build e5_advanced_cli 117 | // 118 | // 2. Run with different arguments: 119 | // 120 | // // Add two numbers 121 | // ./zig-out/bin/e5_advanced_cli add 15 27 122 | // 123 | // // Use the 'sum' alias, the persistent '--verbose' flag, and the local '--precision' flag 124 | // ./zig-out/bin/e5_advanced_cli --verbose sum 10 5.5 --precision=4 125 | // 126 | // // Greet the default 'World' 127 | // ./zig-out/bin/e5_advanced_cli greet 128 | // 129 | // // Greet a specific person 130 | // ./zig-out/bin/e5_advanced_cli greet Ziggy 131 | -------------------------------------------------------------------------------- /src/chilli/types.zig: -------------------------------------------------------------------------------- 1 | //! This module defines the core data structures used throughout the Chilli framework. 2 | const std = @import("std"); 3 | const context = @import("context.zig"); 4 | const utils = @import("utils.zig"); 5 | const errors = @import("errors.zig"); 6 | 7 | /// Enumerates the supported data types for a `Flag` or `PositionalArg`. 8 | pub const FlagType = enum { 9 | Bool, 10 | Int, 11 | Float, 12 | String, 13 | }; 14 | 15 | /// A tagged union that holds the value of a parsed flag or argument. 16 | pub const FlagValue = union(FlagType) { 17 | Bool: bool, 18 | Int: i64, 19 | Float: f64, 20 | String: []const u8, 21 | }; 22 | 23 | /// Parses a raw string value into the appropriate `FlagValue` type. 24 | /// 25 | /// - `value_type`: The target `FlagType` to parse into. 26 | /// - `value`: The raw string input from the command line. 27 | /// Returns a `FlagValue` union or a parsing error. 28 | pub fn parseValue(value_type: FlagType, value: []const u8) errors.Error!FlagValue { 29 | return switch (value_type) { 30 | .Bool => FlagValue{ .Bool = try utils.parseBool(value) }, 31 | .Int => FlagValue{ .Int = try std.fmt.parseInt(i64, value, 10) }, 32 | .Float => FlagValue{ .Float = try std.fmt.parseFloat(f64, value) }, 33 | .String => FlagValue{ .String = value }, 34 | }; 35 | } 36 | 37 | /// Defines the configuration for a `Command`. 38 | pub const CommandOptions = struct { 39 | /// The primary name of the command, used to invoke it. 40 | name: []const u8, 41 | /// A short description of the command's purpose, shown in help messages. 42 | description: []const u8, 43 | /// The function to execute when this command is run. 44 | exec: *const fn (ctx: context.CommandContext) anyerror!void, 45 | /// An optional list of alternative names for the command. 46 | aliases: ?[]const []const u8 = null, 47 | /// An optional single-character shortcut for the command (e.g., 'c'). 48 | shortcut: ?u8 = null, 49 | /// An optional version string for the application. If provided on the root command, 50 | /// an automatic `--version` flag will be available. 51 | version: ?[]const u8 = null, 52 | /// The name of the section under which this command should be grouped in a parent's help message. 53 | section: []const u8 = "Commands", 54 | }; 55 | 56 | /// Defines a command-line flag (e.g., `--verbose` or `-v`). 57 | pub const Flag = struct { 58 | /// The full name of the flag (e.g., "verbose"). 59 | name: []const u8, 60 | /// A short description of the flag's purpose, shown in help messages. 61 | description: []const u8, 62 | /// An optional single-character shortcut for the flag (e.g., 'v'). 63 | shortcut: ?u8 = null, 64 | /// The data type of the flag's value. 65 | type: FlagType, 66 | /// The default value for the flag if it's not provided by the user. 67 | default_value: FlagValue, 68 | /// If `true`, the flag will not be shown in help messages. 69 | hidden: bool = false, 70 | /// If set, the framework will check this environment variable for a value 71 | /// if the flag is not provided on the command line. 72 | env_var: ?[]const u8 = null, 73 | }; 74 | 75 | /// Defines a positional argument for a command. 76 | pub const PositionalArg = struct { 77 | /// The name of the argument, used in help messages and for named access. 78 | name: []const u8, 79 | /// A short description of the argument's purpose. 80 | description: []const u8, 81 | /// The data type of the argument's value. Defaults to `.String`. 82 | type: FlagType = .String, 83 | /// If `true`, the argument must be provided by the user. 84 | is_required: bool = false, 85 | /// The default value for the argument if it's optional and not provided. 86 | default_value: ?FlagValue = null, 87 | /// If `true`, this argument will capture all remaining positional arguments. 88 | /// Only the last positional argument for a command can be variadic. 89 | variadic: bool = false, 90 | }; 91 | 92 | // Tests for the `types` module 93 | 94 | test "types: parseValue" { 95 | // Bool 96 | try std.testing.expect((try parseValue(.Bool, "true")).Bool); 97 | try std.testing.expect(!(try parseValue(.Bool, "false")).Bool); 98 | try std.testing.expectError(errors.Error.InvalidBoolString, parseValue(.Bool, "notabool")); 99 | 100 | // Int 101 | try std.testing.expectEqual(@as(i64, 123), (try parseValue(.Int, "123")).Int); 102 | try std.testing.expectError(error.InvalidCharacter, parseValue(.Int, "notanint")); 103 | 104 | // Float 105 | try std.testing.expectEqual(3.14, (try parseValue(.Float, "3.14")).Float); 106 | try std.testing.expectError(error.InvalidCharacter, parseValue(.Float, "notafloat")); 107 | 108 | // String 109 | try std.testing.expectEqualStrings("hello", (try parseValue(.String, "hello")).String); 110 | } 111 | -------------------------------------------------------------------------------- /examples/e6_file_downloader.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const chilli = @import("chilli"); 3 | 4 | const DownloadContext = struct { 5 | client: std.http.Client, 6 | allocator: std.mem.Allocator, 7 | }; 8 | 9 | fn rootExec(ctx: chilli.CommandContext) !void { 10 | try ctx.command.printHelp(); 11 | } 12 | 13 | fn downloadExec(ctx: chilli.CommandContext) !void { 14 | const url = try ctx.getArg("url", []const u8); 15 | const output_path_arg = try ctx.getArg("output", []const u8); 16 | const verbose = try ctx.getFlag("verbose", bool); 17 | 18 | var download_ctx = ctx.getContextData(DownloadContext).?; 19 | 20 | const output_path = if (output_path_arg.len > 0) 21 | try download_ctx.allocator.dupe(u8, output_path_arg) 22 | else 23 | try getFilenameFromUrl(download_ctx.allocator, url); 24 | defer download_ctx.allocator.free(output_path); 25 | 26 | if (verbose) { 27 | std.debug.print("Downloading: {s}\n", .{url}); 28 | std.debug.print("Output file: {s}\n", .{output_path}); 29 | } 30 | 31 | // Parse URI and create request 32 | const uri = try std.Uri.parse(url); 33 | var req = try download_ctx.client.request(.GET, uri, .{}); 34 | defer req.deinit(); 35 | 36 | // Send request 37 | try req.sendBodiless(); 38 | 39 | // Receive response headers 40 | var redirect_buf: [1024]u8 = undefined; 41 | var response = try req.receiveHead(&redirect_buf); 42 | 43 | if (response.head.status != .ok) { 44 | std.debug.print("Error: HTTP {d} - {s}\n", .{ @intFromEnum(response.head.status), @tagName(response.head.status) }); 45 | return error.HttpRequestFailed; 46 | } 47 | 48 | // Read response body 49 | var buf: [8192]u8 = undefined; 50 | var body_reader = response.reader(&buf); 51 | 52 | var response_body: std.ArrayList(u8) = .{}; 53 | defer response_body.deinit(ctx.app_allocator); 54 | 55 | try body_reader.appendRemainingUnlimited(ctx.app_allocator, &response_body); 56 | 57 | if (verbose) { 58 | std.debug.print("Downloaded {d} bytes\n", .{response_body.items.len}); 59 | } 60 | 61 | // Write to file 62 | const file = try std.fs.cwd().createFile(output_path, .{}); 63 | defer file.close(); 64 | try file.writeAll(response_body.items); 65 | 66 | std.debug.print("Download complete: {s} ({d} bytes)\n", .{ output_path, response_body.items.len }); 67 | } 68 | 69 | fn getFilenameFromUrl(allocator: std.mem.Allocator, url: []const u8) ![]const u8 { 70 | const uri = std.Uri.parse(url) catch 71 | return allocator.dupe(u8, "downloaded_file"); 72 | 73 | const path_str = switch (uri.path) { 74 | .raw => |raw| raw, 75 | .percent_encoded => |encoded| encoded, 76 | }; 77 | 78 | const filename = if (std.mem.lastIndexOfScalar(u8, path_str, '/')) |idx| 79 | path_str[idx + 1 ..] 80 | else 81 | path_str; 82 | 83 | if (filename.len == 0) { 84 | return allocator.dupe(u8, "downloaded_file"); 85 | } 86 | 87 | return allocator.dupe(u8, filename); 88 | } 89 | 90 | pub fn main() !void { 91 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 92 | defer _ = gpa.deinit(); 93 | const allocator = gpa.allocator(); 94 | 95 | var client = std.http.Client{ .allocator = allocator }; 96 | defer client.deinit(); 97 | 98 | var download_ctx = DownloadContext{ 99 | .client = client, 100 | .allocator = allocator, 101 | }; 102 | 103 | var root_cmd = try chilli.Command.init(allocator, .{ 104 | .name = "downloader", 105 | .description = "A simple file downloader using HTTP.", 106 | .exec = rootExec, 107 | }); 108 | defer root_cmd.deinit(); 109 | 110 | try root_cmd.addFlag(.{ 111 | .name = "verbose", 112 | .shortcut = 'v', 113 | .description = "Enable verbose output", 114 | .type = .Bool, 115 | .default_value = .{ .Bool = false }, 116 | }); 117 | 118 | var download_cmd = try chilli.Command.init(allocator, .{ 119 | .name = "download", 120 | .description = "Download a file from a URL.", 121 | .exec = downloadExec, 122 | }); 123 | 124 | try download_cmd.addPositional(.{ 125 | .name = "url", 126 | .description = "The URL to download from.", 127 | .is_required = true, 128 | }); 129 | 130 | try download_cmd.addPositional(.{ 131 | .name = "output", 132 | .description = "Output filename (auto-detected if not provided).", 133 | .is_required = false, 134 | .default_value = .{ .String = "" }, 135 | }); 136 | 137 | try root_cmd.addSubcommand(download_cmd); 138 | 139 | try root_cmd.run(&download_ctx); 140 | } 141 | 142 | // Example Invocations 143 | // 144 | // 1. Build the example executable: 145 | // zig build e6_file_downloader 146 | // 147 | // 2. Run with different arguments: 148 | // 149 | // // Show the help message 150 | // ./zig-out/bin/e6_file_downloader --help 151 | // 152 | // // Download a file, letting the program determine the output filename 153 | // ./zig-out/bin/e6_file_downloader download https://ziglang.org/zig-logo.svg 154 | // 155 | // // Download a file with verbose logging and a specified output filename 156 | // ./zig-out/bin/e6_file_downloader -v download https://ziglang.org/documentation/master/std/std.zig zig_std.zig 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | Chilli Logo 4 | 5 |
6 | 7 |

Chilli

8 | 9 | [![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) 10 | [![CodeFactor](https://img.shields.io/codefactor/grade/github/CogitatorTech/chilli?label=code%20quality&style=flat&labelColor=282c34&logo=codefactor)](https://www.codefactor.io/repository/github/CogitatorTech/chilli) 11 | [![Zig Version](https://img.shields.io/badge/Zig-0.15.1-orange?logo=zig&labelColor=282c34)](https://ziglang.org/download) 12 | [![Docs](https://img.shields.io/badge/docs-read-blue?style=flat&labelColor=282c34&logo=read-the-docs)](https://CogitatorTech.github.io/chilli) 13 | [![Examples](https://img.shields.io/badge/examples-view-green?style=flat&labelColor=282c34&logo=zig)](https://github.com/CogitatorTech/chilli/tree/main/examples) 14 | [![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) 15 | [![License](https://img.shields.io/badge/license-MIT-007ec6?label=license&style=flat&labelColor=282c34&logo=open-source-initiative)](https://github.com/CogitatorTech/chilli/blob/main/LICENSE) 16 | 17 | A microframework for creating command-line applications in Zig 18 | 19 |
20 | 21 | --- 22 | 23 | Chilli is a lightweight command-line interface (CLI) framework for the Zig programming language. 24 | Its goal is to make it easy to create structured, maintainable, and user-friendly CLIs with minimal boilerplate, 25 | while being small and fast, and not getting in the way of your application logic. 26 | 27 | ### Features 28 | 29 | - Provides a simple, low-overhead, declarative API for building CLI applications 30 | - Supports nested commands, subcommands, and aliases 31 | - Provides type-safe parsing for flags, positional arguments, and environment variables 32 | - Supports generating automatic `--help` and `--version` output with custom sections 33 | - Uses a shared context to pass application state 34 | - Written in pure Zig with no external dependencies 35 | 36 | See the [ROADMAP.md](ROADMAP.md) for the list of implemented and planned features. 37 | 38 | > [!IMPORTANT] 39 | > Chilli is in early development, so bugs and breaking changes are expected. 40 | > Please use the [issues page](https://github.com/CogitatorTech/chilli/issues) to report bugs or request features. 41 | 42 | --- 43 | 44 | ### Getting Started 45 | 46 | You can add Chilli to your project and start using it by following the steps below. 47 | 48 | #### Installation 49 | 50 | Run the following command in the root directory of your project to download Chilli: 51 | 52 | ```sh 53 | zig fetch --save=chilli "https://github.com/CogitatorTech/chilli/archive/.tar.gz" 54 | ``` 55 | 56 | Replace `` with the desired branch or tag, like `main` (for the development version) or `v0.2.0` 57 | (for the latest release). 58 | This command will download Chilli and add it to Zig's global cache and update your project's `build.zig.zon` file. 59 | 60 | #### Adding to Build Script 61 | 62 | Next, modify your `build.zig` file to make Chilli available to your build target as a module. 63 | 64 | ```zig 65 | const std = @import("std"); 66 | 67 | pub fn build(b: *std.Build) void { 68 | const target = b.standardTargetOptions(.{}); 69 | const optimize = b.standardOptimizeOption(.{}); 70 | 71 | // 1. Get the dependency object from the builder 72 | const chilli_dep = b.dependency("chilli", .{}); 73 | 74 | // 2. Create a module for the dependency 75 | const chilli_module = chilli_dep.module("chilli"); 76 | 77 | // 3. Create your executable module and add chilli as import 78 | const exe_module = b.createModule(.{ 79 | .root_source_file = b.path("src/main.zig"), 80 | .target = target, 81 | .optimize = optimize, 82 | }); 83 | exe_module.addImport("chilli", chilli_module); 84 | 85 | // 4. Create executable with the module 86 | const exe = b.addExecutable(.{ 87 | .name = "your-cli-app", 88 | .root_module = exe_module, 89 | }); 90 | 91 | b.installArtifact(exe); 92 | } 93 | ``` 94 | 95 | #### Using Chilli in an Application 96 | 97 | Finally, you can `@import("chilli")` and start using it in your Zig application. 98 | 99 | ```zig 100 | const std = @import("std"); 101 | const chilli = @import("chilli"); 102 | 103 | // A function for our command to execute 104 | fn greet(ctx: chilli.CommandContext) !void { 105 | const name = try ctx.getFlag("name", []const u8); 106 | const excitement = try ctx.getFlag("excitement", u32); 107 | 108 | std.print("Hello, {s}", .{name}); 109 | var i: u32 = 0; 110 | while (i < excitement) : (i += 1) { 111 | std.print("!", .{}); 112 | } 113 | std.print("\n", .{}); 114 | } 115 | 116 | pub fn main() anyerror!void { 117 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 118 | defer _ = gpa.deinit(); 119 | const allocator = gpa.allocator(); 120 | 121 | // Create the root command for your application 122 | var root_cmd = try chilli.Command.init(allocator, .{ 123 | .name = "your-cli-app", 124 | .description = "A new CLI built with Chilli", 125 | .version = "v0.1.0", 126 | .exec = greet, // The function to run 127 | }); 128 | defer root_cmd.deinit(); 129 | 130 | // Add flags to the command 131 | try root_cmd.addFlag(.{ 132 | .name = "name", 133 | .shortcut = 'n', 134 | .description = "The name to greet", 135 | .type = .String, 136 | .default_value = .{ .String = "World" }, 137 | }); 138 | try root_cmd.addFlag(.{ 139 | .name = "excitement", 140 | .type = .Int, 141 | .description = "How excited to be", 142 | .default_value = .{ .Int = 1 }, 143 | }); 144 | 145 | // Hand control over to the framework 146 | try root_cmd.run(null); 147 | } 148 | ``` 149 | 150 | You can now run your CLI application with the `--help` flag to see the output below: 151 | 152 | ```bash 153 | $ ./your-cli-app --help 154 | your-cli-app v0.2.0 155 | A new CLI built with Chilli 156 | 157 | USAGE: 158 | your-cli-app [FLAGS] 159 | 160 | FLAGS: 161 | -n, --name The name to greet [default: World] 162 | --excitement How excited to be [default: 1] 163 | -h, --help Prints help information 164 | -V, --version Prints version information 165 | ``` 166 | 167 | --- 168 | 169 | ### Documentation 170 | 171 | You can find the full API documentation for the latest release of Chilli [here](https://CogitatorTech.github.io/chilli). 172 | 173 | Alternatively, you can use the `make docs` command to generate the API documentation for the current version of Chilli 174 | from the source code. 175 | This will generate HTML documentation in the `docs/api` directory, which you can serve locally with `make serve-docs` 176 | and view in your web browser at [http://localhost:8000](http://localhost:8000). 177 | 178 | ### Examples 179 | 180 | Check out the [examples](examples) directory for examples of how Chilli can be used to build a variety of CLI 181 | applications. 182 | 183 | --- 184 | 185 | ### Contributing 186 | 187 | See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to make a contribution. 188 | 189 | ### License 190 | 191 | Chilli is licensed under the MIT License (see [LICENSE](LICENSE)). 192 | 193 | ### Acknowledgements 194 | 195 | * The logo is from [SVG Repo](https://www.svgrepo.com/svg/45673/chili-pepper). 196 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 37 | 39 | 43 | 47 | 51 | 55 | 59 | 65 | 69 | 70 | 71 | 72 | 77 | 82 | 87 | 92 | -------------------------------------------------------------------------------- /src/chilli/utils.zig: -------------------------------------------------------------------------------- 1 | //! A collection of utility functions for formatting help messages and parsing values. 2 | const std = @import("std"); 3 | const command = @import("command.zig"); 4 | const types = @import("types.zig"); 5 | const errors = @import("errors.zig"); 6 | 7 | /// A collection of ANSI escape codes for styling terminal output. 8 | pub const styles = struct { 9 | pub const RESET = "\x1b[0m"; 10 | pub const BOLD = "\x1b[1m"; 11 | pub const DIM = "\x1b[2m"; 12 | pub const UNDERLINE = "\x1b[4m"; 13 | pub const RED = "\x1b[31m"; 14 | pub const GREEN = "\x1b[32m"; 15 | pub const YELLOW = "\x1b[33m"; 16 | pub const BLUE = "\x1b[34m"; 17 | pub const MAGENTA = "\x1b[35m"; 18 | pub const CYAN = "\x1b[36m"; 19 | pub const WHITE = "\x1b[37m"; 20 | }; 21 | 22 | /// Parses a boolean value from a string, case-insensitively. 23 | /// 24 | /// Accepts "true" or "false". Any other value will result in `Error.InvalidBoolString`. 25 | pub fn parseBool(input: []const u8) errors.Error!bool { 26 | if (std.ascii.eqlIgnoreCase(input, "true")) { 27 | return true; 28 | } 29 | if (std.ascii.eqlIgnoreCase(input, "false")) { 30 | return false; 31 | } 32 | return errors.Error.InvalidBoolString; 33 | } 34 | 35 | /// Prints a list of commands with aligned descriptions. 36 | pub fn printAlignedCommands(commands: []*command.Command, writer: anytype) !void { 37 | var max_width: usize = 0; 38 | for (commands) |cmd| { 39 | var len = cmd.options.name.len; 40 | if (cmd.options.shortcut != null) { 41 | len += 4; // " (c)" 42 | } 43 | if (len > max_width) max_width = len; 44 | } 45 | 46 | for (commands) |cmd| { 47 | try writer.print(" {s}", .{cmd.options.name}); 48 | var current_width = cmd.options.name.len; 49 | if (cmd.options.shortcut) |s| { 50 | try writer.print(" ({c})", .{s}); 51 | current_width += 4; 52 | } 53 | 54 | try writer.writeByteNTimes(' ', max_width - current_width + 2); 55 | try writer.print("{s}\n", .{cmd.options.description}); 56 | } 57 | } 58 | 59 | /// Prints a command's flags with aligned descriptions. 60 | pub fn printAlignedFlags(cmd: *const command.Command, writer: anytype) !void { 61 | var max_width: usize = 0; 62 | for (cmd.flags.items) |flag| { 63 | if (flag.hidden) continue; 64 | const len: usize = if (flag.shortcut != null) 65 | // " -c, --name" 66 | flag.name.len + 8 67 | else 68 | // " --name" 69 | flag.name.len + 8; 70 | if (len > max_width) max_width = len; 71 | } 72 | 73 | for (cmd.flags.items) |flag| { 74 | if (flag.hidden) continue; 75 | 76 | var current_width: usize = undefined; 77 | if (flag.shortcut) |s| { 78 | try writer.print(" -{c}, --{s}", .{ s, flag.name }); 79 | current_width = flag.name.len + 8; 80 | } else { 81 | try writer.print(" --{s}", .{flag.name}); 82 | current_width = flag.name.len + 8; 83 | } 84 | 85 | try writer.writeByteNTimes(' ', max_width - current_width + 2); 86 | try writer.print("{s} [{s}]", .{ flag.description, @tagName(flag.type) }); 87 | 88 | switch (flag.default_value) { 89 | .Bool => |v| try writer.print(" (default: {})", .{v}), 90 | .Int => |v| try writer.print(" (default: {})", .{v}), 91 | .Float => |v| try writer.print(" (default: {})", .{v}), 92 | .String => |v| try writer.print(" (default: \"{s}\")", .{v}), 93 | } 94 | try writer.print("\n", .{}); 95 | } 96 | } 97 | 98 | /// Prints a command's positional arguments with aligned descriptions. 99 | pub fn printAlignedPositionalArgs(cmd: *const command.Command, writer: anytype) !void { 100 | var max_width: usize = 0; 101 | for (cmd.positional_args.items) |arg| { 102 | if (arg.name.len > max_width) max_width = arg.name.len; 103 | } 104 | 105 | for (cmd.positional_args.items) |arg| { 106 | try writer.print(" {s}", .{arg.name}); 107 | try writer.writeByteNTimes(' ', max_width - arg.name.len + 2); 108 | try writer.print("{s}", .{arg.description}); 109 | 110 | if (arg.variadic) { 111 | try writer.print(" (variadic)\n", .{}); 112 | } else if (arg.is_required) { 113 | try writer.print(" (required)\n", .{}); 114 | } else { 115 | try writer.print(" (optional)\n", .{}); 116 | } 117 | } 118 | } 119 | 120 | /// Prints the full usage line for a command, including its parents. 121 | pub fn printUsageLine(cmd: *const command.Command, writer: anytype) !void { 122 | var parents: std.ArrayList(*command.Command) = .{}; 123 | defer parents.deinit(cmd.allocator); 124 | 125 | var current_parent = cmd.parent; 126 | while (current_parent) |p| { 127 | try parents.append(cmd.allocator, p); 128 | current_parent = p.parent; 129 | } 130 | std.mem.reverse(*command.Command, parents.items); 131 | 132 | if (parents.items.len > 0) { 133 | try writer.print(" {s}", .{parents.items[0].options.name}); 134 | for (parents.items[1..]) |p| { 135 | try writer.print(" {s}", .{p.options.name}); 136 | } 137 | try writer.print(" {s}", .{cmd.options.name}); 138 | } else { 139 | try writer.print(" {s}", .{cmd.options.name}); 140 | } 141 | 142 | if (cmd.flags.items.len > 0) { 143 | try writer.print(" [flags]", .{}); 144 | } 145 | 146 | for (cmd.positional_args.items) |arg| { 147 | if (arg.variadic) { 148 | try writer.print(" [{s}...]", .{arg.name}); 149 | } else if (arg.is_required) { 150 | try writer.print(" <{s}>", .{arg.name}); 151 | } else { 152 | try writer.print(" [{s}]", .{arg.name}); 153 | } 154 | } 155 | 156 | if (cmd.subcommands.items.len > 0) { 157 | try writer.print(" [command]", .{}); 158 | } 159 | 160 | try writer.print("\n\n", .{}); 161 | } 162 | 163 | const CommandSortContext = struct { 164 | pub fn lessThan(_: @This(), a: *command.Command, b: *command.Command) bool { 165 | return std.mem.order(u8, a.options.name, b.options.name) == .lt; 166 | } 167 | }; 168 | 169 | const StringSortContext = struct { 170 | pub fn lessThan(_: @This(), a: []const u8, b: []const u8) bool { 171 | return std.mem.order(u8, a, b) == .lt; 172 | } 173 | }; 174 | 175 | /// Prints subcommands grouped by section and sorted alphabetically. 176 | pub fn printSubcommands(cmd: *const command.Command, writer: anytype) !void { 177 | var section_map = std.StringHashMap(std.ArrayList(*command.Command)).init(cmd.allocator); 178 | defer { 179 | var it = section_map.iterator(); 180 | while (it.next()) |entry| entry.value_ptr.*.deinit(cmd.allocator); 181 | section_map.deinit(); 182 | } 183 | 184 | for (cmd.subcommands.items) |sub| { 185 | const list = try section_map.getOrPut(sub.options.section); 186 | if (!list.found_existing) { 187 | list.value_ptr.* = .{}; 188 | } 189 | try list.value_ptr.*.append(cmd.allocator, sub); 190 | } 191 | 192 | var sorted_sections: std.ArrayList([]const u8) = .{}; 193 | defer sorted_sections.deinit(cmd.allocator); 194 | var it = section_map.keyIterator(); 195 | while (it.next()) |key| try sorted_sections.append(cmd.allocator, key.*); 196 | std.sort.pdq([]const u8, sorted_sections.items, StringSortContext{}, StringSortContext.lessThan); 197 | 198 | for (sorted_sections.items) |section_name| { 199 | try writer.print("{s}{s}{s}:\n", .{ styles.BOLD, section_name, styles.RESET }); 200 | const cmds_list = section_map.get(section_name).?; 201 | std.sort.pdq(*command.Command, cmds_list.items, CommandSortContext{}, CommandSortContext.lessThan); 202 | try printAlignedCommands(cmds_list.items, writer); 203 | try writer.print("\n", .{}); 204 | } 205 | } 206 | 207 | // Tests for the `utils` module 208 | 209 | test "utils: parseBool" { 210 | try std.testing.expect(try parseBool("true")); 211 | try std.testing.expect(try parseBool("TRUE")); 212 | try std.testing.expect(!(try parseBool("false"))); 213 | try std.testing.expect(!(try parseBool("FALSE"))); 214 | try std.testing.expectError(errors.Error.InvalidBoolString, parseBool("")); 215 | try std.testing.expectError(errors.Error.InvalidBoolString, parseBool("t")); 216 | try std.testing.expectError(errors.Error.InvalidBoolString, parseBool("f")); 217 | try std.testing.expectError(errors.Error.InvalidBoolString, parseBool("1")); 218 | } 219 | -------------------------------------------------------------------------------- /src/chilli/parser.zig: -------------------------------------------------------------------------------- 1 | //! Handles the parsing of command-line arguments into flags and positional values. 2 | const std = @import("std"); 3 | const command = @import("command.zig"); 4 | const utils = @import("utils.zig"); 5 | const types = @import("types.zig"); 6 | const errors = @import("errors.zig"); 7 | 8 | /// A simple forward-only iterator over a slice of string arguments. 9 | pub const ArgIterator = struct { 10 | args: []const []const u8, 11 | index: usize, 12 | 13 | /// Initializes a new iterator for the given argument slice. 14 | pub fn init(args: []const []const u8) ArgIterator { 15 | return ArgIterator{ .args = args, .index = 0 }; 16 | } 17 | 18 | /// Peeks at the next argument without consuming it. 19 | pub fn peek(self: *const ArgIterator) ?[]const u8 { 20 | if (self.index >= self.args.len) return null; 21 | return self.args[self.index]; 22 | } 23 | 24 | /// Consumes the next argument, advancing the iterator. 25 | pub fn next(self: *ArgIterator) void { 26 | self.index += 1; 27 | } 28 | }; 29 | 30 | /// An internal struct to hold a parsed flag and its value. 31 | pub const ParsedFlag = struct { 32 | name: []const u8, 33 | value: types.FlagValue, 34 | }; 35 | 36 | /// Parses command-line arguments from an iterator, populating the command's 37 | /// `parsed_flags` and `parsed_positionals` fields. 38 | /// 39 | /// - `cmd`: The command to parse arguments for. 40 | /// - `iterator`: The `ArgIterator` providing the argument strings. 41 | pub fn parseArgsAndFlags(cmd: *command.Command, iterator: *ArgIterator) errors.Error!void { 42 | var parsing_flags = true; 43 | while (iterator.peek()) |arg| { 44 | if (parsing_flags) { 45 | if (std.mem.eql(u8, arg, "--")) { 46 | parsing_flags = false; 47 | iterator.next(); 48 | continue; 49 | } 50 | 51 | if (std.mem.startsWith(u8, arg, "--")) { 52 | const arg_body = arg[2..]; 53 | var flag_name: []const u8 = arg_body; 54 | var value: ?[]const u8 = null; 55 | 56 | if (std.mem.indexOfScalar(u8, arg_body, '=')) |eq_idx| { 57 | flag_name = arg_body[0..eq_idx]; 58 | value = arg_body[eq_idx + 1 ..]; 59 | } 60 | 61 | const flag = cmd.findFlag(flag_name) orelse return errors.Error.UnknownFlag; 62 | 63 | if (flag.type == .Bool) { 64 | const flag_value = if (value) |v| try utils.parseBool(v) else true; 65 | try cmd.parsed_flags.append(cmd.allocator, .{ 66 | .name = flag_name, 67 | .value = .{ .Bool = flag_value }, 68 | }); 69 | iterator.next(); 70 | } else { 71 | iterator.next(); 72 | const val = value orelse iterator.peek() orelse return errors.Error.MissingFlagValue; 73 | if (value == null) { 74 | iterator.next(); 75 | } 76 | try cmd.parsed_flags.append(cmd.allocator, .{ 77 | .name = flag_name, 78 | .value = try types.parseValue(flag.type, val), 79 | }); 80 | } 81 | continue; 82 | } 83 | 84 | if (std.mem.startsWith(u8, arg, "-") and arg.len > 1) { 85 | const shortcuts = arg[1..]; 86 | iterator.next(); 87 | 88 | for (shortcuts, 0..) |shortcut, i| { 89 | const flag = cmd.findFlagByShortcut(shortcut) orelse return errors.Error.UnknownFlag; 90 | 91 | if (flag.type == .Bool) { 92 | try cmd.parsed_flags.append(cmd.allocator, .{ .name = flag.name, .value = .{ .Bool = true } }); 93 | } else { 94 | var value: []const u8 = undefined; 95 | var value_from_next_arg = false; 96 | 97 | if (shortcuts.len > i + 1) { 98 | value = shortcuts[i + 1 ..]; 99 | } else { 100 | value = iterator.peek() orelse return errors.Error.MissingFlagValue; 101 | value_from_next_arg = true; 102 | } 103 | 104 | if (value_from_next_arg) { 105 | iterator.next(); 106 | } 107 | 108 | try cmd.parsed_flags.append(cmd.allocator, .{ 109 | .name = flag.name, 110 | .value = try types.parseValue(flag.type, value), 111 | }); 112 | break; 113 | } 114 | } 115 | continue; 116 | } 117 | } 118 | 119 | try cmd.parsed_positionals.append(cmd.allocator, arg); 120 | iterator.next(); 121 | } 122 | } 123 | 124 | /// Validates that all required positional arguments have been provided and that there are 125 | /// no excess arguments unless a variadic argument is defined. 126 | /// 127 | /// - `cmd`: The command whose parsed arguments should be validated. 128 | pub fn validateArgs(cmd: *command.Command) errors.Error!void { 129 | const num_defined = cmd.positional_args.items.len; 130 | const num_parsed = cmd.parsed_positionals.items.len; 131 | 132 | if (num_defined == 0) { 133 | if (num_parsed > 0) return errors.Error.TooManyArguments; 134 | return; 135 | } 136 | 137 | const last_arg_def = cmd.positional_args.items[num_defined - 1]; 138 | const has_variadic = last_arg_def.variadic; 139 | 140 | var required_count: usize = 0; 141 | for (cmd.positional_args.items) |arg_def| { 142 | if (arg_def.is_required) { 143 | required_count += 1; 144 | } 145 | } 146 | if (num_parsed < required_count) { 147 | return errors.Error.MissingRequiredArgument; 148 | } 149 | 150 | if (!has_variadic and num_parsed > num_defined) { 151 | return errors.Error.TooManyArguments; 152 | } 153 | } 154 | 155 | // Tests for the `parser` module 156 | 157 | const testing = std.testing; 158 | const context = @import("context.zig"); 159 | 160 | fn dummyExec(_: context.CommandContext) !void {} 161 | 162 | fn newTestCmd(allocator: std.mem.Allocator) !*command.Command { 163 | var cmd = try command.Command.init(allocator, .{ 164 | .name = "test", 165 | .description = "", 166 | .exec = dummyExec, 167 | }); 168 | errdefer cmd.deinit(); 169 | 170 | try cmd.addFlag(.{ .name = "output", .shortcut = 'o', .type = .String, .default_value = .{ .String = "" }, .description = "" }); 171 | try cmd.addFlag(.{ .name = "verbose", .shortcut = 'v', .type = .Bool, .default_value = .{ .Bool = false }, .description = "" }); 172 | try cmd.addFlag(.{ .name = "force", .shortcut = 'f', .type = .Bool, .default_value = .{ .Bool = false }, .description = "" }); 173 | 174 | return cmd; 175 | } 176 | 177 | test "parser: short flag with attached value" { 178 | const allocator = std.testing.allocator; 179 | var cmd = try command.Command.init(allocator, .{ 180 | .name = "test", 181 | .description = "", 182 | .exec = dummyExec, 183 | }); 184 | defer cmd.deinit(); 185 | 186 | try cmd.addFlag(.{ 187 | .name = "output", 188 | .shortcut = 'o', 189 | .description = "Output file", 190 | .type = .String, 191 | .default_value = .{ .String = "" }, 192 | }); 193 | 194 | var it = ArgIterator.init(&[_][]const u8{"-otest.txt"}); 195 | try parseArgsAndFlags(&cmd, &it); 196 | 197 | try std.testing.expectEqual(1, cmd.parsed_flags.items.len); 198 | try std.testing.expectEqualStrings("output", cmd.parsed_flags.items[0].name); 199 | 200 | const value = cmd.parsed_flags.items[0].value; 201 | switch (value) { 202 | .String => |s| try std.testing.expectEqualStrings("test.txt", s), 203 | else => std.testing.panic("Expected string value, got {any}", .{value}), 204 | } 205 | } 206 | 207 | test "parser: long flag formats" { 208 | const allocator = testing.allocator; 209 | var cmd = try newTestCmd(allocator); 210 | defer cmd.deinit(); 211 | 212 | // Test --flag=value 213 | var it1 = ArgIterator.init(&[_][]const u8{"--output=file.txt"}); 214 | try parseArgsAndFlags(&cmd, &it1); 215 | try testing.expectEqualStrings("output", cmd.parsed_flags.items[0].name); 216 | try testing.expectEqualStrings("file.txt", cmd.parsed_flags.items[0].value.String); 217 | cmd.parsed_flags.shrinkRetainingCapacity(0); 218 | 219 | // Test --flag value 220 | var it2 = ArgIterator.init(&[_][]const u8{ "--output", "file.txt" }); 221 | try parseArgsAndFlags(&cmd, &it2); 222 | try testing.expectEqualStrings("output", cmd.parsed_flags.items[0].name); 223 | try testing.expectEqualStrings("file.txt", cmd.parsed_flags.items[0].value.String); 224 | } 225 | 226 | test "parser: short flag formats" { 227 | const allocator = testing.allocator; 228 | var cmd = try newTestCmd(allocator); 229 | defer cmd.deinit(); 230 | 231 | // Test -f value 232 | var it1 = ArgIterator.init(&[_][]const u8{ "-o", "file.txt" }); 233 | try parseArgsAndFlags(&cmd, &it1); 234 | try testing.expectEqualStrings("output", cmd.parsed_flags.items[0].name); 235 | try testing.expectEqualStrings("file.txt", cmd.parsed_flags.items[0].value.String); 236 | cmd.parsed_flags.shrinkRetainingCapacity(0); 237 | 238 | // Test grouped booleans 239 | var it2 = ArgIterator.init(&[_][]const u8{"-vf"}); 240 | try parseArgsAndFlags(&cmd, &it2); 241 | try testing.expectEqual(2, cmd.parsed_flags.items.len); 242 | try testing.expectEqualStrings("verbose", cmd.parsed_flags.items[0].name); 243 | try testing.expect(cmd.parsed_flags.items[0].value.Bool); 244 | try testing.expectEqualStrings("force", cmd.parsed_flags.items[1].name); 245 | try testing.expect(cmd.parsed_flags.items[1].value.Bool); 246 | cmd.parsed_flags.shrinkRetainingCapacity(0); 247 | 248 | // Test grouped booleans with value-taking flag at the end 249 | var it3 = ArgIterator.init(&[_][]const u8{ "-vfo", "file.txt" }); 250 | try parseArgsAndFlags(&cmd, &it3); 251 | try testing.expectEqual(3, cmd.parsed_flags.items.len); 252 | try testing.expectEqualStrings("verbose", cmd.parsed_flags.items[0].name); 253 | try testing.expectEqualStrings("force", cmd.parsed_flags.items[1].name); 254 | try testing.expectEqualStrings("output", cmd.parsed_flags.items[2].name); 255 | try testing.expectEqualStrings("file.txt", cmd.parsed_flags.items[2].value.String); 256 | } 257 | 258 | test "parser: -- terminator" { 259 | const allocator = testing.allocator; 260 | var cmd = try newTestCmd(allocator); 261 | defer cmd.deinit(); 262 | 263 | var it = ArgIterator.init(&[_][]const u8{ "--verbose", "--", "--output", "-f" }); 264 | try parseArgsAndFlags(&cmd, &it); 265 | 266 | try testing.expectEqual(1, cmd.parsed_flags.items.len); 267 | try testing.expectEqualStrings("verbose", cmd.parsed_flags.items[0].name); 268 | 269 | try testing.expectEqual(2, cmd.parsed_positionals.items.len); 270 | try testing.expectEqualStrings("--output", cmd.parsed_positionals.items[0]); 271 | try testing.expectEqualStrings("-f", cmd.parsed_positionals.items[1]); 272 | } 273 | 274 | test "parser: error conditions" { 275 | const allocator = testing.allocator; 276 | var cmd = try newTestCmd(allocator); 277 | defer cmd.deinit(); 278 | 279 | // Unknown long flag 280 | var it1 = ArgIterator.init(&[_][]const u8{"--nonexistent"}); 281 | try testing.expectError(errors.Error.UnknownFlag, parseArgsAndFlags(&cmd, &it1)); 282 | 283 | // Unknown short flag 284 | var it2 = ArgIterator.init(&[_][]const u8{"-x"}); 285 | try testing.expectError(errors.Error.UnknownFlag, parseArgsAndFlags(&cmd, &it2)); 286 | 287 | // Missing value 288 | var it3 = ArgIterator.init(&[_][]const u8{"--output"}); 289 | try testing.expectError(errors.Error.MissingFlagValue, parseArgsAndFlags(&cmd, &it3)); 290 | } 291 | 292 | test "parser: argument validation" { 293 | const allocator = testing.allocator; 294 | var cmd = try command.Command.init(allocator, .{ .name = "test", .description = "", .exec = dummyExec }); 295 | defer cmd.deinit(); 296 | 297 | try cmd.addPositional(.{ .name = "req", .is_required = true, .description = "" }); 298 | try cmd.addPositional(.{ .name = "opt", .default_value = .{ .String = "" }, .description = "" }); 299 | 300 | // Missing required 301 | cmd.parsed_positionals.clearRetainingCapacity(); 302 | try testing.expectError(errors.Error.MissingRequiredArgument, validateArgs(&cmd)); 303 | 304 | // Too many arguments 305 | cmd.parsed_positionals.clearRetainingCapacity(); 306 | try cmd.parsed_positionals.appendSlice(&[_][]const u8{ "a", "b", "c" }); 307 | try testing.expectError(errors.Error.TooManyArguments, validateArgs(&cmd)); 308 | 309 | // Correct number 310 | cmd.parsed_positionals.clearRetainingCapacity(); 311 | try cmd.parsed_positionals.appendSlice(&[_][]const u8{ "a", "b" }); 312 | try validateArgs(&cmd); 313 | } 314 | -------------------------------------------------------------------------------- /src/chilli/context.zig: -------------------------------------------------------------------------------- 1 | //! Provides the execution context for a command, giving access to parsed arguments and flags. 2 | const std = @import("std"); 3 | const types = @import("types.zig"); 4 | const command = @import("command.zig"); 5 | const errors = @import("errors.zig"); 6 | 7 | /// (Private) Describes the source of a value for error reporting. 8 | const ValueSource = enum { 9 | parsed, 10 | environment, 11 | default, 12 | }; 13 | 14 | /// (Private) Casts a FlagValue to a specific type T at compile time. 15 | /// Panics on type mismatch, which indicates a developer error. 16 | fn castFlagValueTo( 17 | value: types.FlagValue, 18 | comptime T: type, 19 | comptime entity_kind: []const u8, 20 | entity_name: []const u8, 21 | source: ValueSource, 22 | ) errors.Error!T { 23 | const panic_msg = "Type mismatch for {s} ''{s}'' from {s} value: expected " ++ @typeName(T) ++ ", got {s}"; 24 | const source_str = @tagName(source); 25 | 26 | return switch (T) { 27 | bool => if (value == .Bool) value.Bool else std.debug.panic(panic_msg, .{ entity_kind, entity_name, source_str, @tagName(value) }), 28 | []const u8 => if (value == .String) value.String else std.debug.panic(panic_msg, .{ entity_kind, entity_name, source_str, @tagName(value) }), 29 | else => switch (@typeInfo(T)) { 30 | .int => { 31 | if (value == .Int) { 32 | if (std.math.cast(T, value.Int)) |casted_value| { 33 | return casted_value; 34 | } else { 35 | return errors.Error.IntegerValueOutOfRange; 36 | } 37 | } 38 | std.debug.panic(panic_msg, .{ entity_kind, entity_name, source_str, @tagName(value) }); 39 | }, 40 | .float => { 41 | if (value == .Float) { 42 | const float_val = value.Float; 43 | if (@abs(float_val) > std.math.floatMax(T)) { 44 | return errors.Error.FloatValueOutOfRange; 45 | } 46 | return @as(T, @floatCast(float_val)); 47 | } 48 | std.debug.panic(panic_msg, .{ entity_kind, entity_name, source_str, @tagName(value) }); 49 | }, 50 | else => @compileError("Unsupported type for getFlag/getArg: " ++ @typeName(T)), 51 | }, 52 | }; 53 | } 54 | 55 | /// Provides access to command-line data within a command's execution function (`exec`). 56 | pub const CommandContext = struct { 57 | /// A persistent allocator, typically from the root command, for allocations 58 | /// that must outlive the command's execution function. 59 | app_allocator: std.mem.Allocator, 60 | /// A temporary allocator (typically an ArenaAllocator) for short-lived allocations 61 | /// during command execution. Memory from this allocator is freed after the exec function returns. 62 | tmp_allocator: std.mem.Allocator, 63 | 64 | command: *command.Command, 65 | data: ?*anyopaque, 66 | 67 | /// Retrieves the value for a flag, searching parsed values, then environment 68 | /// variables, and finally falling back to the default value. 69 | /// 70 | /// NOTE: If the value comes from an environment variable, it is allocated using the 71 | /// temporary allocator (`tmp_allocator`) and will be invalid after the `exec` function 72 | /// returns. If you need to store the value, you must copy it using the `app_allocator`. 73 | pub fn getFlag(self: *const CommandContext, name: []const u8, comptime T: type) errors.Error!T { 74 | // 1. Check for a parsed value from the command line. 75 | if (self.command.getFlagValue(name)) |parsed_value| { 76 | return castFlagValueTo(parsed_value, T, "flag", name, .parsed); 77 | } 78 | 79 | // 2. Find the flag definition. 80 | const flag_def = self.command.findFlag(name) orelse 81 | std.debug.panic("Attempted to access an undefined flag: '{s}'", .{name}); 82 | 83 | // 3. Check for a value from an environment variable. 84 | if (flag_def.env_var) |env_name| { 85 | if (std.process.getEnvVarOwned(self.tmp_allocator, env_name) catch null) |env_val_str| { 86 | defer self.tmp_allocator.free(env_val_str); 87 | const env_value = try types.parseValue(flag_def.type, env_val_str); 88 | return castFlagValueTo(env_value, T, "flag", name, .environment); 89 | } 90 | } 91 | 92 | // 4. Fall back to the default value. 93 | return castFlagValueTo(flag_def.default_value, T, "flag", name, .default); 94 | } 95 | 96 | /// Retrieves the value for a positional argument, searching parsed values 97 | /// and then falling back to the default value if available. 98 | pub fn getArg(self: *const CommandContext, name: []const u8, comptime T: type) errors.Error!T { 99 | var arg_def: ?*const types.PositionalArg = null; 100 | var arg_idx: ?usize = null; 101 | 102 | for (self.command.positional_args.items, 0..) |*item, i| { 103 | if (std.mem.eql(u8, item.name, name)) { 104 | arg_def = item; 105 | arg_idx = i; 106 | break; 107 | } 108 | } 109 | 110 | const found_arg = arg_def orelse 111 | std.debug.panic("Attempted to access an undefined positional argument: '{s}'", .{name}); 112 | 113 | if (found_arg.variadic) { 114 | std.debug.panic("Positional argument '{s}' is variadic. Use getArgs() or getArgsAs() instead.", .{name}); 115 | } 116 | 117 | // 1. Check for a parsed value from the command line. 118 | if (arg_idx.? < self.command.parsed_positionals.items.len) { 119 | const raw_value = self.command.parsed_positionals.items[arg_idx.?]; 120 | const parsed_value = try types.parseValue(found_arg.type, raw_value); 121 | return castFlagValueTo(parsed_value, T, "argument", name, .parsed); 122 | } 123 | 124 | // 2. Fall back to the default value. 125 | if (found_arg.default_value) |default_val| { 126 | return castFlagValueTo(default_val, T, "argument", name, .default); 127 | } 128 | 129 | // This path should ideally not be reached if validation is correct. 130 | // A required argument without a parsed value would fail validation earlier. 131 | // An optional argument must have a default value (enforced in `addPositional`). 132 | std.debug.panic("No value or default value found for argument '{s}'", .{name}); 133 | } 134 | 135 | /// Retrieves all raw string values for a variadic positional argument. 136 | pub fn getArgs(self: *const CommandContext, name: []const u8) []const []const u8 { 137 | for (self.command.positional_args.items, 0..) |arg_def, i| { 138 | if (std.mem.eql(u8, arg_def.name, name)) { 139 | if (!arg_def.variadic) { 140 | std.debug.panic("Positional argument '{s}' is not variadic. Use getArg() instead.", .{name}); 141 | } 142 | const num_parsed = self.command.parsed_positionals.items.len; 143 | if (num_parsed <= i) { 144 | return &.{}; 145 | } 146 | return self.command.parsed_positionals.items[i..]; 147 | } 148 | } 149 | std.debug.panic("Attempted to access an undefined positional argument: '{s}'", .{name}); 150 | } 151 | 152 | /// Retrieves all values for a variadic positional argument, parsed into the specified type `T`. 153 | /// The returned slice is allocated using the provided `allocator` and must be freed by the caller. 154 | pub fn getArgsAs( 155 | self: *const CommandContext, 156 | comptime T: type, 157 | name: []const u8, 158 | allocator: std.mem.Allocator, 159 | ) errors.Error![]T { 160 | var arg_def: ?*const types.PositionalArg = null; 161 | var arg_idx: ?usize = null; 162 | 163 | for (self.command.positional_args.items, 0..) |*item, i| { 164 | if (std.mem.eql(u8, item.name, name)) { 165 | arg_def = item; 166 | arg_idx = i; 167 | break; 168 | } 169 | } 170 | 171 | const found_arg = arg_def orelse 172 | std.debug.panic("Attempted to access an undefined positional argument: '{s}'", .{name}); 173 | 174 | if (!found_arg.variadic) { 175 | std.debug.panic("Positional argument '{s}' is not variadic. Use getArgsAs() only for variadic arguments.", .{name}); 176 | } 177 | 178 | const num_parsed = self.command.parsed_positionals.items.len; 179 | const string_args = if (num_parsed > arg_idx.?) self.command.parsed_positionals.items[arg_idx.?..] else &.{}; 180 | 181 | if (string_args.len == 0) { 182 | return allocator.alloc(T, 0); 183 | } 184 | 185 | var results = std.ArrayList(T).init(allocator); 186 | errdefer results.deinit(); 187 | 188 | for (string_args) |raw_value| { 189 | const parsed_value = try types.parseValue(found_arg.type, raw_value); 190 | const casted_value = try castFlagValueTo(parsed_value, T, "variadic argument", name, .parsed); 191 | try results.append(casted_value); 192 | } 193 | 194 | return results.toOwnedSlice(); 195 | } 196 | 197 | /// Retrieves a pointer to the shared application context data. 198 | pub fn getContextData(self: *const CommandContext, comptime T: type) ?*T { 199 | if (self.data) |d| { 200 | return @ptrCast(@alignCast(d)); 201 | } 202 | return null; 203 | } 204 | }; 205 | 206 | // Tests for the `context` module 207 | 208 | const testing = std.testing; 209 | const process = std.process; 210 | 211 | fn dummyExec(_: CommandContext) !void {} 212 | 213 | test "context: getFlag from environment variable" { 214 | const allocator = testing.allocator; 215 | var cmd = try command.Command.init(allocator, .{ .name = "test", .description = "", .exec = dummyExec }); 216 | defer cmd.deinit(); 217 | 218 | try cmd.addFlag(.{ 219 | .name = "config", 220 | .type = .String, 221 | .default_value = .{ .String = "default.conf" }, 222 | .env_var = "TEST_APP_CONFIG", 223 | .description = "", 224 | }); 225 | 226 | const ctx = CommandContext{ .app_allocator = allocator, .tmp_allocator = allocator, .command = &cmd, .data = null }; 227 | 228 | // Set env var and check if getFlag reads it 229 | try process.setEnvVar("TEST_APP_CONFIG", "env.conf"); 230 | defer process.unsetEnvVar("TEST_APP_CONFIG") catch {}; 231 | 232 | const config_path = try ctx.getFlag("config", []const u8); 233 | try testing.expectEqualStrings("env.conf", config_path); 234 | } 235 | 236 | test "context: getFlag integer range error" { 237 | const allocator = testing.allocator; 238 | var cmd = try command.Command.init(allocator, .{ .name = "test", .description = "", .exec = dummyExec }); 239 | defer cmd.deinit(); 240 | try cmd.addFlag(.{ .name = "count", .type = .Int, .default_value = .{ .Int = 0 }, .description = "" }); 241 | 242 | // Parsed value is 70000, which does not fit in i16 243 | try cmd.parsed_flags.append(.{ .name = "count", .value = .{ .Int = 70000 } }); 244 | 245 | const ctx = CommandContext{ .app_allocator = allocator, .tmp_allocator = allocator, .command = &cmd, .data = null }; 246 | try testing.expectError(errors.Error.IntegerValueOutOfRange, ctx.getFlag("count", i16)); 247 | } 248 | 249 | test "context: getArgs for variadic" { 250 | const allocator = testing.allocator; 251 | var cmd = try command.Command.init(allocator, .{ .name = "test", .description = "", .exec = dummyExec }); 252 | defer cmd.deinit(); 253 | try cmd.addPositional(.{ .name = "command", .is_required = true, .description = "" }); 254 | try cmd.addPositional(.{ .name = "files", .variadic = true, .description = "" }); 255 | 256 | try cmd.parsed_positionals.appendSlice(&[_][]const u8{ "run", "file1.zig", "file2.zig" }); 257 | 258 | const ctx = CommandContext{ .app_allocator = allocator, .tmp_allocator = allocator, .command = &cmd, .data = null }; 259 | const files = ctx.getArgs("files"); 260 | 261 | try testing.expectEqual(@as(usize, 2), files.len); 262 | try testing.expectEqualStrings("file1.zig", files[0]); 263 | try testing.expectEqualStrings("file2.zig", files[1]); 264 | } 265 | 266 | test "context: typed getArg" { 267 | const allocator = std.testing.allocator; 268 | var cmd = try command.Command.init(allocator, .{ .name = "test", .description = "", .exec = dummyExec }); 269 | defer cmd.deinit(); 270 | try cmd.addPositional(.{ .name = "req_str", .is_required = true, .description = "" }); 271 | try cmd.addPositional(.{ .name = "opt_int", .description = "", .type = .Int, .default_value = .{ .Int = 123 } }); 272 | try cmd.parsed_positionals.append("hello"); 273 | const ctx = CommandContext{ 274 | .app_allocator = allocator, 275 | .tmp_allocator = allocator, 276 | .command = &cmd, 277 | .data = null, 278 | }; 279 | try std.testing.expectEqualStrings("hello", try ctx.getArg("req_str", []const u8)); 280 | try std.testing.expectEqual(@as(i64, 123), try ctx.getArg("opt_int", i64)); 281 | } 282 | 283 | test "context: getFlag" { 284 | const allocator = std.testing.allocator; 285 | var cmd = try command.Command.init(allocator, .{ 286 | .name = "test", 287 | .description = "", 288 | .exec = dummyExec, 289 | }); 290 | defer cmd.deinit(); 291 | try cmd.addFlag(.{ .name = "verbose", .type = .Bool, .default_value = .{ .Bool = false }, .description = "" }); 292 | try cmd.addFlag(.{ .name = "count", .type = .Int, .default_value = .{ .Int = 42 }, .description = "" }); 293 | try cmd.addFlag(.{ .name = "pi", .type = .Float, .default_value = .{ .Float = 3.14 }, .description = "" }); 294 | try cmd.parsed_flags.append(.{ .name = "verbose", .value = .{ .Bool = true } }); 295 | const ctx = CommandContext{ 296 | .app_allocator = allocator, 297 | .tmp_allocator = allocator, 298 | .command = &cmd, 299 | .data = null, 300 | }; 301 | try std.testing.expect(try ctx.getFlag("verbose", bool)); 302 | try std.testing.expectEqual(@as(i32, 42), try ctx.getFlag("count", i32)); 303 | try std.testing.expectEqual(@as(f64, 3.14), try ctx.getFlag("pi", f64)); 304 | } 305 | 306 | test "context: getArgsAs for typed variadic" { 307 | const allocator = std.testing.allocator; 308 | 309 | // Test case 1: Integers 310 | var cmd_int = try command.Command.init(allocator, .{ .name = "test-int", .description = "", .exec = dummyExec }); 311 | defer cmd_int.deinit(); 312 | try cmd_int.addPositional(.{ .name = "numbers", .type = .Int, .variadic = true, .description = "" }); 313 | try cmd_int.parsed_positionals.appendSlice(&[_][]const u8{ "10", "-20", "300" }); 314 | 315 | var ctx_int = CommandContext{ .app_allocator = allocator, .tmp_allocator = allocator, .command = &cmd_int, .data = null }; 316 | const numbers = try ctx_int.getArgsAs(i64, "numbers", allocator); 317 | defer allocator.free(numbers); 318 | 319 | try testing.expectEqualSlices(i64, &.{ 10, -20, 300 }, numbers); 320 | 321 | // Test case 2: Empty args 322 | cmd_int.parsed_positionals.clearRetainingCapacity(); 323 | const no_numbers = try ctx_int.getArgsAs(i64, "numbers", allocator); 324 | defer allocator.free(no_numbers); 325 | try testing.expectEqual(@as(usize, 0), no_numbers.len); 326 | 327 | // Test case 3: Parse error 328 | cmd_int.parsed_positionals.clearRetainingCapacity(); 329 | try cmd_int.parsed_positionals.append("not-a-number"); 330 | try testing.expectError(error.InvalidCharacter, ctx_int.getArgsAs(i64, "numbers", allocator)); 331 | } 332 | -------------------------------------------------------------------------------- /src/chilli/command.zig: -------------------------------------------------------------------------------- 1 | //! The core module for defining, managing, and executing commands. 2 | const std = @import("std"); 3 | const parser = @import("parser.zig"); 4 | const context = @import("context.zig"); 5 | const utils = @import("utils.zig"); 6 | const types = @import("types.zig"); 7 | const errors = @import("errors.zig"); 8 | 9 | /// Represents a single command in a CLI application. 10 | /// 11 | /// A `Command` can have its own flags, positional arguments, and an execution function. 12 | /// It can also contain subcommands, forming a nested command structure. Commands are 13 | /// responsible for their own memory management; `deinit` must be called on the root 14 | /// command to free all associated resources, including those of its subcommands. 15 | /// 16 | /// # Thread Safety 17 | /// This object and its methods are NOT thread-safe. The command tree should be 18 | /// fully defined in a single thread before being used. Calling `run` from multiple 19 | /// threads on the same `Command` instance concurrently will result in a data race 20 | /// and undefined behavior. 21 | pub const Command = struct { 22 | options: types.CommandOptions, 23 | subcommands: std.ArrayList(*Command), 24 | flags: std.ArrayList(types.Flag), 25 | positional_args: std.ArrayList(types.PositionalArg), 26 | parent: ?*Command, 27 | allocator: std.mem.Allocator, 28 | parsed_flags: std.ArrayList(parser.ParsedFlag), 29 | parsed_positionals: std.ArrayList([]const u8), 30 | 31 | /// Initializes a new command. 32 | /// Panics if the provided command name is empty. 33 | pub fn init(allocator: std.mem.Allocator, options: types.CommandOptions) !*Command { 34 | if (options.name.len == 0) { 35 | std.debug.panic("Command name cannot be empty.", .{}); 36 | } 37 | 38 | const command = try allocator.create(Command); 39 | command.* = Command{ 40 | .options = options, 41 | .subcommands = .{}, 42 | .flags = .{}, 43 | .positional_args = .{}, 44 | .parent = null, 45 | .allocator = allocator, 46 | .parsed_flags = .{}, 47 | .parsed_positionals = .{}, 48 | }; 49 | 50 | const help_flag = types.Flag{ 51 | .name = "help", 52 | .shortcut = 'h', 53 | .description = "Shows help information for this command", 54 | .type = .Bool, 55 | .default_value = .{ .Bool = false }, 56 | }; 57 | try command.addFlag(help_flag); 58 | 59 | return command; 60 | } 61 | 62 | /// Deinitializes the command and all its subcommands recursively. 63 | /// 64 | /// This function should ONLY be called on the root command of the application. 65 | /// It recursively deinitializes all child and grandchild commands. Calling `deinit` 66 | /// on a subcommand that has a parent will lead to a double-free when the 67 | /// root command's `deinit` is also called. 68 | pub fn deinit(self: *Command) void { 69 | for (self.subcommands.items) |sub| { 70 | sub.deinit(); 71 | } 72 | self.subcommands.deinit(self.allocator); 73 | self.flags.deinit(self.allocator); 74 | self.positional_args.deinit(self.allocator); 75 | self.parsed_flags.deinit(self.allocator); 76 | self.parsed_positionals.deinit(self.allocator); 77 | self.allocator.destroy(self); 78 | } 79 | 80 | /// Adds a subcommand to this command. 81 | /// Returns `error.CommandAlreadyHasParent` if the subcommand has already been 82 | /// added to another command. 83 | /// Returns `error.EmptyAlias` if the subcommand is defined with an empty alias. 84 | pub fn addSubcommand(self: *Command, sub: *Command) !void { 85 | if (sub.parent != null) { 86 | return errors.Error.CommandAlreadyHasParent; 87 | } 88 | if (sub.options.aliases) |aliases| { 89 | for (aliases) |alias| { 90 | if (alias.len == 0) return error.EmptyAlias; 91 | } 92 | } 93 | 94 | sub.parent = self; 95 | try self.subcommands.append(self.allocator, sub); 96 | } 97 | 98 | /// Adds a flag to the command. Panics if the flag name is empty. 99 | /// Returns `error.DuplicateFlag` if a flag with the same name or shortcut 100 | /// already exists on this command. 101 | pub fn addFlag(self: *Command, flag: types.Flag) !void { 102 | if (flag.name.len == 0) { 103 | std.debug.panic("Flag name cannot be empty.", .{}); 104 | } 105 | 106 | for (self.flags.items) |existing_flag| { 107 | if (std.mem.eql(u8, existing_flag.name, flag.name)) { 108 | return error.DuplicateFlag; 109 | } 110 | if (existing_flag.shortcut) |s_old| { 111 | if (flag.shortcut) |s_new| { 112 | if (s_old == s_new) return error.DuplicateFlag; 113 | } 114 | } 115 | } 116 | 117 | try self.flags.append(self.allocator, flag); 118 | } 119 | 120 | /// Adds a positional argument to the command's definition. 121 | /// Returns `error.VariadicArgumentNotLastError` if you attempt to add an 122 | /// argument after one that is marked as variadic. 123 | /// Returns `error.RequiredArgumentAfterOptional` if you attempt to add a 124 | /// required argument after an optional one. 125 | /// Panics if the argument name is empty or an optional arg lacks a default value. 126 | pub fn addPositional(self: *Command, arg: types.PositionalArg) !void { 127 | if (arg.name.len == 0) { 128 | std.debug.panic("Positional argument name cannot be empty.", .{}); 129 | } 130 | if (!arg.is_required and !arg.variadic and arg.default_value == null) { 131 | std.debug.panic("Optional positional argument '{s}' must have a default_value.", .{arg.name}); 132 | } 133 | 134 | if (self.positional_args.items.len > 0) { 135 | const last_arg = self.positional_args.items[self.positional_args.items.len - 1]; 136 | if (last_arg.variadic) { 137 | return errors.Error.VariadicArgumentNotLastError; 138 | } 139 | if (arg.is_required and !last_arg.is_required) { 140 | return errors.Error.RequiredArgumentAfterOptional; 141 | } 142 | } 143 | 144 | try self.positional_args.append(self.allocator, arg); 145 | } 146 | 147 | /// Parses arguments and executes the appropriate command. This is the core logic loop. 148 | pub fn execute(self: *Command, user_args: []const []const u8, data: ?*anyopaque, out_failed_cmd: *?*const Command) anyerror!void { 149 | var arena = std.heap.ArenaAllocator.init(self.allocator); 150 | defer arena.deinit(); 151 | const arena_allocator = arena.allocator(); 152 | 153 | var arg_iterator = parser.ArgIterator.init(user_args); 154 | 155 | var current_cmd: *Command = self; 156 | while (arg_iterator.peek()) |arg| { 157 | if (std.mem.startsWith(u8, arg, "-")) break; 158 | if (current_cmd.findSubcommand(arg)) |found_sub| { 159 | current_cmd = found_sub; 160 | arg_iterator.next(); 161 | } else { 162 | break; 163 | } 164 | } 165 | out_failed_cmd.* = current_cmd; 166 | 167 | // Reset state from any previous run, making the command re-entrant. 168 | current_cmd.parsed_flags.shrinkRetainingCapacity(0); 169 | current_cmd.parsed_positionals.shrinkRetainingCapacity(0); 170 | 171 | try parser.parseArgsAndFlags(current_cmd, &arg_iterator); 172 | 173 | // Check for --help and --version flags BEFORE validation 174 | // This allows users to see help even if required arguments are missing 175 | if (current_cmd.getFlagValue("help")) |flag_val| { 176 | if (flag_val.Bool) { 177 | try current_cmd.printHelp(); 178 | return; 179 | } 180 | } 181 | 182 | if (self.options.version != null) { 183 | if (current_cmd.getFlagValue("version")) |flag_val| { 184 | if (flag_val.Bool) { 185 | const stdout = std.fs.File.stdout().deprecatedWriter(); 186 | try stdout.print("{s}\n", .{self.options.version.?}); 187 | return; 188 | } 189 | } 190 | } 191 | 192 | try parser.validateArgs(current_cmd); 193 | 194 | // Success, clear the out_failed_cmd 195 | out_failed_cmd.* = null; 196 | 197 | const ctx = context.CommandContext{ 198 | .app_allocator = self.allocator, 199 | .tmp_allocator = arena_allocator, 200 | .command = current_cmd, 201 | .data = data, 202 | }; 203 | 204 | try current_cmd.options.exec(ctx); 205 | } 206 | 207 | /// (private) Handles printing formatted errors to a writer. 208 | /// This function is separated for testability. 209 | fn handleExecutionError( 210 | allocator: std.mem.Allocator, 211 | err: anyerror, 212 | failed_cmd: ?*const Command, 213 | writer: anytype, 214 | ) void { 215 | const red = utils.styles.RED; 216 | const reset = utils.styles.RESET; 217 | 218 | switch (err) { 219 | error.BrokenPipe => return, // Exit silently on broken pipe 220 | else => {}, 221 | } 222 | 223 | writer.print("{s}Error:{s} ", .{ red, reset }) catch return; 224 | 225 | switch (err) { 226 | error.MissingRequiredArgument => { 227 | if (failed_cmd) |cmd| { 228 | const path = cmd.getCommandPath(allocator) catch "unknown command"; 229 | defer if (std.mem.eql(u8, path, "unknown command")) {} else { 230 | allocator.free(path); 231 | }; 232 | writer.print("Missing a required argument for command '{s}'.\n", .{path}) catch return; 233 | } else { 234 | writer.print("Missing a required argument.\n", .{}) catch return; 235 | } 236 | }, 237 | error.TooManyArguments => { 238 | if (failed_cmd) |cmd| { 239 | const path = cmd.getCommandPath(allocator) catch "unknown command"; 240 | defer if (std.mem.eql(u8, path, "unknown command")) {} else { 241 | allocator.free(path); 242 | }; 243 | writer.print("Too many arguments provided for command '{s}'.\n", .{path}) catch return; 244 | } else { 245 | writer.print("Too many arguments provided.\n", .{}) catch return; 246 | } 247 | }, 248 | error.DuplicateFlag => writer.print("A flag with the same name or shortcut was defined more than once.\n", .{}) catch return, 249 | error.RequiredArgumentAfterOptional => writer.print("A required positional argument cannot be defined after an optional one.\n", .{}) catch return, 250 | error.EmptyAlias => writer.print("A command cannot be defined with an empty string as an alias.\n", .{}) catch return, 251 | error.UnknownFlag => writer.print("Unknown flag provided.\n", .{}) catch return, 252 | error.MissingFlagValue => writer.print("Flag requires a value but none was provided.\n", .{}) catch return, 253 | error.InvalidFlagGrouping => writer.print("Invalid short flag grouping.\n", .{}) catch return, 254 | error.InvalidBoolString => writer.print("Invalid value for boolean flag, expected 'true' or 'false'.\n", .{}) catch return, 255 | error.VariadicArgumentNotLastError => writer.print("Internal Error: Cannot add another positional argument after a variadic one.\n", .{}) catch return, 256 | error.CommandAlreadyHasParent => writer.print("Internal Error: A command was added to multiple parents.\n", .{}) catch return, 257 | error.IntegerValueOutOfRange => writer.print("An integer flag value was provided out of the allowed range.\n", .{}) catch return, 258 | error.InvalidCharacter => writer.print("Invalid character in numeric value.\n", .{}) catch return, 259 | error.Overflow => writer.print("Numeric value is too large or too small.\n", .{}) catch return, 260 | error.OutOfMemory => writer.print("Out of memory.\n", .{}) catch return, 261 | else => writer.print("An unexpected error occurred: {any}\n", .{err}) catch return, 262 | } 263 | } 264 | 265 | /// The main entry point for running the CLI application. 266 | /// This function handles process arguments, invokes `execute`, and prints formatted errors. 267 | pub fn run(self: *Command, data: ?*anyopaque) !void { 268 | if (self.options.version != null) { 269 | try self.addFlag(.{ 270 | .name = "version", 271 | .description = "Print version information and exit", 272 | .type = .Bool, 273 | .default_value = .{ .Bool = false }, 274 | }); 275 | } 276 | 277 | var args = try std.process.argsAlloc(self.allocator); 278 | defer std.process.argsFree(self.allocator, args); 279 | 280 | var failed_cmd: ?*const Command = null; 281 | self.execute(args[1..], data, &failed_cmd) catch |err| { 282 | const stderr = std.fs.File.stderr().deprecatedWriter(); 283 | handleExecutionError(self.allocator, err, failed_cmd, stderr); 284 | std.process.exit(1); 285 | }; 286 | } 287 | 288 | /// (private) Constructs the full command path (e.g., "root sub") for use in help and error messages. 289 | /// The returned slice is allocated using the provided allocator and must be freed by the caller. 290 | fn getCommandPath(self: *const Command, allocator: std.mem.Allocator) ![]const u8 { 291 | var path_parts: std.ArrayList([]const u8) = .{}; 292 | defer path_parts.deinit(allocator); 293 | 294 | var current: ?*const Command = self; 295 | while (current) |cmd| { 296 | try path_parts.append(allocator, cmd.options.name); 297 | current = cmd.parent; 298 | } 299 | std.mem.reverse([]const u8, path_parts.items); 300 | 301 | return std.mem.join(allocator, " ", path_parts.items); 302 | } 303 | 304 | // ... other functions from findSubcommand to printHelp remain unchanged ... 305 | /// Finds a direct subcommand by its name, alias, or shortcut. 306 | pub fn findSubcommand(self: *Command, name: []const u8) ?*Command { 307 | for (self.subcommands.items) |sub| { 308 | if (std.mem.eql(u8, sub.options.name, name)) return sub; 309 | if (sub.options.shortcut) |s| { 310 | if (name.len == 1 and s == name[0]) return sub; 311 | } 312 | if (sub.options.aliases) |a| { 313 | for (a) |alias| { 314 | if (std.mem.eql(u8, alias, name)) return sub; 315 | } 316 | } 317 | } 318 | return null; 319 | } 320 | 321 | /// Finds a flag definition by its full name (e.g., "verbose"), searching upwards through parent commands. 322 | pub fn findFlag(self: *Command, name: []const u8) ?*types.Flag { 323 | var current: ?*Command = self; 324 | while (current) |cmd| { 325 | for (cmd.flags.items) |*flag| { 326 | if (std.mem.eql(u8, flag.name, name)) return flag; 327 | } 328 | current = cmd.parent; 329 | } 330 | return null; 331 | } 332 | 333 | /// Finds a flag definition by its shortcut (e.g., 'v'), searching upwards through parent commands. 334 | pub fn findFlagByShortcut(self: *Command, shortcut: u8) ?*types.Flag { 335 | var current: ?*Command = self; 336 | while (current) |cmd| { 337 | for (cmd.flags.items) |*flag| { 338 | if (flag.shortcut) |s| { 339 | if (s == shortcut) return flag; 340 | } 341 | } 342 | current = cmd.parent; 343 | } 344 | return null; 345 | } 346 | 347 | /// (Internal) Retrieves the parsed value of a flag for the current command. 348 | pub fn getFlagValue(self: *const Command, name: []const u8) ?types.FlagValue { 349 | for (self.parsed_flags.items) |flag| { 350 | if (std.mem.eql(u8, flag.name, name)) return flag.value; 351 | } 352 | return null; 353 | } 354 | 355 | /// (Internal) Retrieves the parsed value of a positional argument by its index. 356 | pub fn getPositionalValue(self: *const Command, index: usize) ?[]const u8 { 357 | if (index < self.parsed_positionals.items.len) return self.parsed_positionals.items[index]; 358 | return null; 359 | } 360 | 361 | /// Prints a formatted help message for the command to standard output. 362 | pub fn printHelp(self: *const Command) !void { 363 | const stdout = std.fs.File.stdout().deprecatedWriter(); 364 | try stdout.print("{s}{s}{s}\n", .{ utils.styles.BOLD, self.options.description, utils.styles.RESET }); 365 | 366 | if (self.options.version) |version| { 367 | try stdout.print("{s}Version: {s}{s}\n", .{ utils.styles.DIM, version, utils.styles.RESET }); 368 | } 369 | try stdout.print("\n", .{}); 370 | 371 | try stdout.print("{s}Usage:{s}\n", .{ utils.styles.BOLD, utils.styles.RESET }); 372 | try utils.printUsageLine(self, stdout); 373 | 374 | if (self.positional_args.items.len > 0) { 375 | try stdout.print("{s}Arguments:{s}\n", .{ utils.styles.BOLD, utils.styles.RESET }); 376 | try utils.printAlignedPositionalArgs(self, stdout); 377 | try stdout.print("\n", .{}); 378 | } 379 | 380 | if (self.flags.items.len > 0) { 381 | try stdout.print("{s}Flags:{s}\n", .{ utils.styles.BOLD, utils.styles.RESET }); 382 | try utils.printAlignedFlags(self, stdout); 383 | try stdout.print("\n", .{}); 384 | } 385 | 386 | if (self.subcommands.items.len > 0) { 387 | try utils.printSubcommands(self, stdout); 388 | } 389 | } 390 | }; 391 | 392 | // Tests for the `command` module 393 | 394 | const testing = std.testing; 395 | 396 | fn dummyExec(_: context.CommandContext) !void {} 397 | 398 | test "command: findSubcommand by alias and shortcut" { 399 | const allocator = testing.allocator; 400 | var root = try Command.init(allocator, .{ .name = "root", .description = "", .exec = dummyExec }); 401 | defer root.deinit(); 402 | 403 | const sub = try Command.init(allocator, .{ 404 | .name = "sub", 405 | .description = "", 406 | .aliases = &[_][]const u8{ "alias1", "alias2" }, 407 | .shortcut = 's', 408 | .exec = dummyExec, 409 | }); 410 | 411 | try root.addSubcommand(sub); 412 | try testing.expect(root.findSubcommand("sub").? == sub); 413 | try testing.expect(root.findSubcommand("alias1").? == sub); 414 | try testing.expect(root.findSubcommand("alias2").? == sub); 415 | try testing.expect(root.findSubcommand("s").? == sub); 416 | try testing.expect(root.findSubcommand("nonexistent") == null); 417 | } 418 | 419 | var integration_flag_val: bool = false; 420 | var integration_arg_val: []const u8 = ""; 421 | 422 | fn integrationExec(ctx: context.CommandContext) !void { 423 | integration_flag_val = try ctx.getFlag("verbose", bool); 424 | integration_arg_val = try ctx.getArg("file", []const u8); 425 | } 426 | 427 | test "command: execute with args and flags" { 428 | const allocator = testing.allocator; 429 | var root = try Command.init(allocator, .{ .name = "root", .description = "", .exec = dummyExec }); 430 | defer root.deinit(); 431 | var sub = try Command.init(allocator, .{ .name = "sub", .description = "", .exec = integrationExec }); 432 | try root.addSubcommand(sub); 433 | 434 | try sub.addFlag(.{ .name = "verbose", .shortcut = 'v', .type = .Bool, .default_value = .{ .Bool = false }, .description = "" }); 435 | try sub.addPositional(.{ .name = "file", .is_required = true, .description = "" }); 436 | 437 | var failed_cmd: ?*const Command = null; 438 | const args = &[_][]const u8{ "sub", "--verbose", "input.txt" }; 439 | try root.execute(args, null, &failed_cmd); 440 | 441 | try testing.expect(failed_cmd == null); 442 | try testing.expect(integration_flag_val); 443 | try testing.expectEqualStrings("input.txt", integration_arg_val); 444 | } 445 | 446 | test "command: addSubcommand detects empty alias" { 447 | const allocator = std.testing.allocator; 448 | var root = try Command.init(allocator, .{ .name = "root", .description = "", .exec = dummyExec }); 449 | defer root.deinit(); 450 | 451 | var sub_bad = try Command.init(allocator, .{ 452 | .name = "bad", 453 | .description = "", 454 | .aliases = &.{ "", "b" }, 455 | .exec = dummyExec, 456 | }); 457 | defer sub_bad.deinit(); 458 | 459 | try std.testing.expectError(error.EmptyAlias, root.addSubcommand(sub_bad)); 460 | } 461 | 462 | test "command: addFlag detects duplicates" { 463 | const allocator = std.testing.allocator; 464 | var cmd = try Command.init(allocator, .{ .name = "test", .description = "", .exec = dummyExec }); 465 | defer cmd.deinit(); 466 | 467 | try cmd.addFlag(.{ .name = "output", .description = "", .type = .String, .default_value = .{ .String = "" } }); 468 | try cmd.addFlag(.{ .name = "verbose", .shortcut = 'v', .description = "", .type = .Bool, .default_value = .{ .Bool = false } }); 469 | 470 | // Expect error for duplicate name 471 | try std.testing.expectError(error.DuplicateFlag, cmd.addFlag(.{ 472 | .name = "output", 473 | .description = "", 474 | .type = .Int, 475 | .default_value = .{ .Int = 0 }, 476 | })); 477 | 478 | // Expect error for duplicate shortcut 479 | try std.testing.expectError(error.DuplicateFlag, cmd.addFlag(.{ 480 | .name = "volume", 481 | .shortcut = 'v', 482 | .description = "", 483 | .type = .Int, 484 | .default_value = .{ .Int = 0 }, 485 | })); 486 | } 487 | 488 | test "command: addPositional argument order" { 489 | const allocator = std.testing.allocator; 490 | var cmd = try Command.init(allocator, .{ .name = "test", .description = "", .exec = dummyExec }); 491 | defer cmd.deinit(); 492 | 493 | try cmd.addPositional(.{ .name = "optional", .is_required = false, .default_value = .{ .String = "" } }); 494 | try std.testing.expectError(error.RequiredArgumentAfterOptional, cmd.addPositional(.{ 495 | .name = "required", 496 | .is_required = true, 497 | })); 498 | } 499 | 500 | // ... other tests from `addPositional validation` to `getCommandPath` remain unchanged ... 501 | test "command: addPositional validation" { 502 | const allocator = std.testing.allocator; 503 | var cmd = try Command.init(allocator, .{ .name = "test", .description = "", .exec = dummyExec }); 504 | defer cmd.deinit(); 505 | 506 | try cmd.addPositional(.{ .name = "a", .is_required = true }); 507 | try cmd.addPositional(.{ .name = "b", .variadic = true }); 508 | 509 | try std.testing.expectError( 510 | error.VariadicArgumentNotLastError, 511 | cmd.addPositional(.{ .name = "c", .is_required = true }), 512 | ); 513 | } 514 | 515 | test "command: init and deinit" { 516 | const allocator = std.testing.allocator; 517 | var cmd = try Command.init(allocator, .{ 518 | .name = "test", 519 | .description = "", 520 | .exec = dummyExec, 521 | }); 522 | defer cmd.deinit(); 523 | try std.testing.expectEqualStrings("test", cmd.options.name); 524 | try std.testing.expect(cmd.findFlag("help") != null); 525 | } 526 | 527 | test "command: subcommands" { 528 | const allocator = std.testing.allocator; 529 | var root = try Command.init(allocator, .{ .name = "root", .description = "", .exec = dummyExec }); 530 | defer root.deinit(); 531 | const sub = try Command.init(allocator, .{ .name = "sub", .description = "", .exec = dummyExec }); 532 | 533 | try root.addSubcommand(sub); 534 | try std.testing.expect(root.findSubcommand("sub").? == sub); 535 | try std.testing.expect(sub.parent.? == root); 536 | } 537 | 538 | test "command: findFlag traverses parents" { 539 | const allocator = std.testing.allocator; 540 | var root = try Command.init(allocator, .{ .name = "root", .description = "", .exec = dummyExec }); 541 | defer root.deinit(); 542 | var sub = try Command.init(allocator, .{ .name = "sub", .description = "", .exec = dummyExec }); 543 | try root.addSubcommand(sub); 544 | 545 | try root.addFlag(.{ .name = "global", .shortcut = 'g', .type = .Bool, .default_value = .{ .Bool = false }, .description = "" }); 546 | 547 | try std.testing.expect(sub.findFlag("global") != null); 548 | try std.testing.expect(sub.findFlagByShortcut('g') != null); 549 | } 550 | 551 | var exec_called_on: ?[]const u8 = null; 552 | fn trackingExec(ctx: context.CommandContext) !void { 553 | exec_called_on = ctx.command.options.name; 554 | } 555 | 556 | test "command: execute" { 557 | const allocator = std.testing.allocator; 558 | var root = try Command.init(allocator, .{ .name = "root", .description = "", .exec = trackingExec }); 559 | defer root.deinit(); 560 | const sub = try Command.init(allocator, .{ .name = "sub", .description = "", .exec = trackingExec }); 561 | try root.addSubcommand(sub); 562 | 563 | exec_called_on = null; 564 | var failed_cmd: ?*const Command = null; 565 | try root.execute(&[_][]const u8{}, null, &failed_cmd); 566 | try std.testing.expectEqualStrings("root", exec_called_on.?); 567 | 568 | exec_called_on = null; 569 | try root.execute(&[_][]const u8{"sub"}, null, &failed_cmd); 570 | try std.testing.expectEqualStrings("sub", exec_called_on.?); 571 | } 572 | 573 | test "command: getCommandPath" { 574 | const allocator = std.testing.allocator; 575 | var root = try Command.init(allocator, .{ .name = "root", .description = "", .exec = dummyExec }); 576 | defer root.deinit(); 577 | var sub1 = try Command.init(allocator, .{ .name = "sub1", .description = "", .exec = dummyExec }); 578 | try root.addSubcommand(sub1); 579 | var sub2 = try Command.init(allocator, .{ .name = "sub2", .description = "", .exec = dummyExec }); 580 | try sub1.addSubcommand(sub2); 581 | 582 | var path = try root.getCommandPath(allocator); 583 | defer allocator.free(path); 584 | try std.testing.expectEqualStrings("root", path); 585 | 586 | path = try sub1.getCommandPath(allocator); 587 | defer allocator.free(path); 588 | try std.testing.expectEqualStrings("root sub1", path); 589 | 590 | path = try sub2.getCommandPath(allocator); 591 | defer allocator.free(path); 592 | try std.testing.expectEqualStrings("root sub1 sub2", path); 593 | } 594 | 595 | test "command: handleExecutionError provides context" { 596 | const allocator = std.testing.allocator; 597 | var buf: [1024]u8 = undefined; 598 | var fbs = std.io.fixedBufferStream(&buf); 599 | const writer = fbs.writer(); 600 | 601 | var root_cmd = try Command.init(allocator, .{ .name = "test-cmd", .description = "", .exec = dummyExec }); 602 | defer root_cmd.deinit(); 603 | 604 | // Test with context 605 | fbs.pos = 0; 606 | Command.handleExecutionError(allocator, error.TooManyArguments, root_cmd, writer); 607 | var written = fbs.getWritten(); 608 | try std.testing.expect(std.mem.endsWith(u8, written, "Error: Too many arguments provided for command 'test-cmd'.\n")); 609 | 610 | // Test without context 611 | fbs.pos = 0; 612 | Command.handleExecutionError(allocator, error.TooManyArguments, null, writer); 613 | written = fbs.getWritten(); 614 | try std.testing.expect(std.mem.endsWith(u8, written, "Error: Too many arguments provided.\n")); 615 | } 616 | 617 | test "command: handleExecutionError silent on broken pipe" { 618 | const allocator = std.testing.allocator; 619 | var buf: [1024]u8 = undefined; 620 | var fbs = std.io.fixedBufferStream(&buf); 621 | const writer = fbs.writer(); 622 | 623 | Command.handleExecutionError(allocator, error.BrokenPipe, null, writer); 624 | try std.testing.expectEqualStrings("", fbs.getWritten()); 625 | } 626 | --------------------------------------------------------------------------------