diff --git a/README.md b/README.md index 7f3465f..8a5b960 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ ArgMojo currently supports: - **Partial parsing**: `parse_known_arguments()` collects unrecognised options instead of erroring - **Compile-time validation**: builder parameters validated at `mojo build` time via `comptime assert` - **Registration-time validation**: group constraint typos caught when the program starts, not when the user runs it +- **Auto-dispatch**: `set_run_function(handler)` + `execute()` for Cobra-style automatic subcommand dispatch — no manual `if/elif` chains --- diff --git a/docs/argmojo_overall_planning.md b/docs/argmojo_overall_planning.md index 0572c58..ba64f5b 100644 --- a/docs/argmojo_overall_planning.md +++ b/docs/argmojo_overall_planning.md @@ -85,7 +85,8 @@ These features appear across multiple libraries and depend only on string operat | Typed retrieval (`get_int()` etc.) | ✓ | ✓ | ✓ | ✓ | ✓ | | **Done** | | Comptime `StringLiteral` params | — | — | — | ✓ | ✓ | clap derive macros | **Done** | | Registration-time name validation | — | — | — | ✓ | ✓ | clap panic on unknown ID | **Done** | -| Struct-based schema (reflection) | — | — | — | — | ✓ | swift-argument-parser | Phase 7a | +| Struct-based schema (reflection) | — | — | — | — | ✓ | swift-argument-parser | **Done** | +| Auto-dispatch (run functions) | — | — | ✓ | — | ✓ | Cobra / swift | **Done** | | Pre/Post run hooks | — | — | ✓ | — | — | Cobra | Phase 7a | | Derive (macro/decorator-based) | — | — | — | ✓ | — | clap `#[derive(Parser)]` | Phase 7b | | Enum → type mapping (real enums) | — | — | — | ✓ | ✓ | Requires reflection | Phase 7b | @@ -289,14 +290,14 @@ Positional arguments and named options are validated **independently** — a com #### Per-Dimension Behavior -**Positional arguments:** +##### Positional arguments | Command config ↓ \ User input → | Enough positionals provided | Not enough positionals provided | | ------------------------------- | --------------------------- | ------------------------------- | | **Has required positional(s)** | ✓ Proceed | ✗ Error + usage | | **No required positional(s)** | ✓ Proceed | N/A — always "enough" | -**Named options:** +##### Named options | Command config ↓ \ User input → | Enough options provided | Not enough options provided | | ------------------------------- | ----------------------- | --------------------------- | @@ -569,7 +570,7 @@ Before adding Phase 5 features, further decompose `parse_arguments()` for readab - [x] **Interactive prompting** — prompt user for missing required args instead of erroring (Click `prompt=True`) (PR #23) - [x] **Password / masked input** — hide typed characters for sensitive values (Click `hide_input=True`) - [x] **Confirmation option** — `confirmation_option()` or `confirmation_option["prompt"]()` auto-registers `--yes`/`-y` flag; prompts user for confirmation after parsing; aborts on decline or non-interactive stdin (Click `confirmation_option`) (PR #26) -- [ ] **Pre/Post run hooks** — callbacks before/after main logic (cobra `PreRun`/`PostRun`) +- [x] **Auto-dispatch** — `set_run_function(handler)` registers a `def (ParseResult) raises` function pointer on a `Command`; `execute()` parses and walks the subcommand chain to invoke the matching handler; `_execute_with_arguments(args)` provides the same dispatch for testing. **Lifecycle hooks** (PersistentPreRun/PreRun/PostRun/PersistentPostRun) are not yet implemented — they depend on auto-dispatch and will be added in a future release. - [x] **Remainder positional** — `.remainder()` consumes ALL remaining tokens (including `-` prefixed); at most one per command, must be last positional (argparse `nargs=REMAINDER`, clap `trailing_var_arg`) (PR #13) - [x] **Allow hyphen values** — `.allow_hyphen_values()` on positional accepts dash-prefixed tokens as values without `--`; remainder enables this automatically (clap `allow_hyphen_values`) (PR #13) - [ ] **Regex validation** — `.pattern(r"^\d{4}-\d{2}-\d{2}$")` validates value format (no major library has this) @@ -580,6 +581,7 @@ Before adding Phase 5 features, further decompose `parse_arguments()` for readab - [x] **`NO_COLOR` env variable** — honour the [no-color.org](https://no-color.org/) standard: if env `NO_COLOR` is set (any value, including empty), suppress all ANSI colour output; lower priority than explicit `.color(False)` API call (PR #9) - [x] **Value-name wrapping control** — `.value_name[wrapped: Bool = True]("NAME")` displays custom value names in `` by default (matching clap/cargo/pixi/git convention); pass `False` for bare display (PR #17) - [ ] **Extend `implies()`** - support value-taking options with a default value, e.g., `cmd.implies("debug", "output", "debug.log")` — when `--debug` is set, auto-set `--output` to `"debug.log"`. Currently `implies()` only supports flag/count targets (same as cobra in Go). Revisit when there is a concrete use case. +- [ ] **80-character help formatting** — wrap help descriptions at 80 columns with proper indentation (no major library does this by default; users typically pipe through `less` or rely on terminal wrapping) #### Explicitly Out of Scope in This Phase @@ -606,7 +608,7 @@ ArgMojo's differentiating features — no other CLI library addresses CJK-specif --ling 使用宇浩靈明編碼 ← CJK chars each take 2 columns, misaligned ``` -**Implementation:** +##### Implementation (CJK alignment) - [x] Implement `_display_width(s: String) -> Int` in `utils.mojo`, traversing each code point: - CJK Unified Ideographs, CJK Ext-A/B/C/D/E/F/G/H/I/J, fullwidth forms → width 2 @@ -623,7 +625,7 @@ ArgMojo's differentiating features — no other CLI library addresses CJK-specif - `--verbose` instead of `--verbose` - `=` instead of `=` -**Implementation:** +##### Implementation (fullwidth correction) - [x] Implement `_fullwidth_to_halfwidth(token: String) -> String` in `utils.mojo`: - Full-width ASCII range: `U+FF01`–`U+FF5E` → subtract `0xFEE0` to get half-width @@ -652,7 +654,7 @@ Note that the following punctuation characters are already handled by the full-w - `——verbose` (em-dash `U+2014` × 2) instead of `--verbose` - `--key:value` (full-width colon `U+FF1A`) instead of `--key=value` -**Implementation:** +##### Implementation (CJK punctuation) - [x] Integrate with typo suggestion system — when a token fails to match any known option, check for common CJK punctuation patterns before running Levenshtein: - `——` (`U+2014 U+2014`, 破折號) → `--` (note that `U+FF0D` full-width hyphen-minus is already handled by the full-width correction step) @@ -679,10 +681,11 @@ The features below are **not part of the core builder API**. They are split into These features use capabilities already available in Mojo 0.26.2 and can be experimented with immediately. -| Feature | Inspiration | Status | Planning doc | -| ------------------------------ | -------------------------- | ------------- | ---------------------------------------------------------- | -| Declarative / struct-based API | swift-argument-parser | Investigating | [declarative_api_planning.md](declarative_api_planning.md) | -| Pre/Post run hooks | cobra `PreRun` / `PostRun` | Investigating | TBD | +| Feature | Inspiration | Status | Planning doc | +| ------------------------------ | --------------------------- | -------- | ---------------------------------------------------------- | +| Declarative / struct-based API | swift-argument-parser | **Done** | [declarative_api_planning.md](declarative_api_planning.md) | +| Auto-dispatch (run functions) | cobra `Run` / swift `run()` | **Done** | Implemented in command.mojo | +| Pre/Post lifecycle hooks | cobra `PreRun` / `PostRun` | Planned | Depends on auto-dispatch (now available) | **Declarative API summary** (see [full design doc](declarative_api_planning.md)): @@ -691,7 +694,9 @@ These features use capabilities already available in Mojo 0.26.2 and can be expe - **Two innovations beyond Swift**: (1) `to_command()` exposes the underlying `Command` for builder-level tweaks (groups, implications, coloured help); (2) `parse_full()` returns both typed struct + `ParseResult` for hybrid workflows. - **Optional** — Users who prefer the builder API are completely unaffected. Zero change to existing code. -**Pre/Post run hooks** — Straightforward callback mechanism (`def(ParseResult) raises`). No special language features needed; just needs API design and a decision on execution order with subcommands. +**Auto-dispatch** — Implemented via `set_run_function()` + `execute()`. Registers a non-capturing function pointer (`def (ParseResult) raises`) on each `Command`. `execute()` parses `sys.argv()`, walks the subcommand chain, and invokes the leaf handler. `_execute_with_arguments(args)` provides the same dispatch for testing with explicit argument lists. Works with aliases, nested subcommands, and persistent flags. Closures cannot be stored as struct fields in Mojo 0.26.2 (only non-capturing function pointers via the `def (...) raises` type), so handlers must be free functions. + +**Pre/Post lifecycle hooks** — Now unblocked by auto-dispatch. Straightforward extension: add `_pre_run_function` / `_post_run_function` fields with the same function pointer type. Execution order: PersistentPreRun → PreRun → Run → PostRun → PersistentPostRun. Will be implemented in a future release. #### Phase 7b: Blocked on Mojo Language Features diff --git a/docs/changelog.md b/docs/changelog.md index b905920..0e10db3 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,7 @@ This document tracks all notable changes to ArgMojo, including new features, API Unreleased changes should be commented out from here. This file will be edited just before each release to reflect the final changelog for that version. Otherwise, the users would be confused. - Add `allow_negative_expressions()` on `Command` — treats single-hyphen tokens as positional arguments when they don't conflict with registered short options. Handles mathematical expressions like `-1/3*pi`, `-sin(2)`, `-e^2`. Superset of `allow_negative_numbers()`. +- Add **auto-dispatch** — `set_run_function(handler)` registers a `def (ParseResult) raises` handler on a `Command`; `execute()` parses and auto-dispatches to the matching handler, eliminating manual `if/elif` subcommand chains. `_execute_with_arguments(args)` provides the same dispatch for testing with explicit argument lists. Works with nested subcommands, aliases, and persistent flags. --> ## 20260404 (v0.5.0) diff --git a/docs/user_manual.md b/docs/user_manual.md index dda4f83..9e7e3d9 100644 --- a/docs/user_manual.md +++ b/docs/user_manual.md @@ -48,6 +48,7 @@ from argmojo import Argument, Command - [Subcommands](#subcommands) - [Defining Subcommands](#defining-subcommands) - [Parsing Subcommand Results](#parsing-subcommand-results) + - [Auto-Dispatch with `set_run_function` / `execute`](#auto-dispatch-with-set_run_function--execute) - [Persistent (Global) Flags](#persistent-global-flags) - [The help Subcommand](#the-help-subcommand) - [Subcommand Aliases](#subcommand-aliases) @@ -77,12 +78,6 @@ from argmojo import Argument, Command - [Setup Example](#setup-example) - [Enabling Prompting](#enabling-prompting) - [Interactive Session Examples](#interactive-session-examples) - - [All arguments missing — full prompting](#all-arguments-missing--full-prompting) - - [Partial arguments — only missing ones are prompted](#partial-arguments--only-missing-ones-are-prompted) - - [All arguments provided — no prompting at all](#all-arguments-provided--no-prompting-at-all) - - [Empty input with a default — default value is used](#empty-input-with-a-default--default-value-is-used) - - [Flag argument — y/n prompt](#flag-argument--yn-prompt) - - [Argument with choices — choices are shown](#argument-with-choices--choices-are-shown) - [Prompt Format](#prompt-format) - [Interaction with Other Features](#interaction-with-other-features) - [Non-Interactive Use (CI / Piped Input)](#non-interactive-use-ci--piped-input) @@ -98,10 +93,10 @@ from argmojo import Argument, Command - [Restrictions](#restrictions) - [Non-Interactive Use](#non-interactive-use) - [Confirmation Option](#confirmation-option) - - [Basic Usage](#basic-usage-1) - - [Custom Prompt Text](#custom-prompt-text-1) - - [Using with Subcommands](#using-with-subcommands-1) - - [Non-Interactive Use](#non-interactive-use-1) + - [Basic Usage (Confirmation Option)](#basic-usage-confirmation-option) + - [Custom Prompt Text (Confirmation Option)](#custom-prompt-text-confirmation-option) + - [Using with Subcommands (Confirmation Option)](#using-with-subcommands-confirmation-option) + - [Non-Interactive Use (Confirmation Option)](#non-interactive-use-confirmation-option) - [Usage Line Customisation](#usage-line-customisation) - [Shell Completion](#shell-completion) - [Built-in `--completions` Flag](#built-in---completions-flag) @@ -121,21 +116,15 @@ from argmojo import Argument, Command - [Pure Declarative — One-Line Parse](#pure-declarative--one-line-parse) - [Hybrid — Declarative + Builder Customisation](#hybrid--declarative--builder-customisation) - [Full Parse — Declarative + Extra Builder Fields](#full-parse--declarative--extra-builder-fields) - - [Subcommands](#subcommands-1) + - [Subcommands in Declarative API](#subcommands-in-declarative-api) - [Auto-Naming Convention](#auto-naming-convention) - [API Summary](#api-summary) - [Cross-Library Method Name Reference](#cross-library-method-name-reference) - [Argument-Level Builder Methods](#argument-level-builder-methods) - - [Command-Level Constraint Methods](#command-level-constraint-methods) - - [Notes](#notes-1) - + - [Command-Level Methods](#command-level-methods) + - [Notes (Cross-Library Method Name Reference)](#notes-cross-library-method-name-reference) + + ## Getting Started @@ -158,7 +147,7 @@ def main() raises: --- -**`parse()` vs `parse_arguments()`** +#### `parse()` vs `parse_arguments()` - **`command.parse()`** reads the real command-line via `sys.argv()`. - **`command.parse_arguments(args)`** accepts a `List[String]` — useful for testing without a real binary. Note that `args[0]` is expected to be the program name and will be skipped, so the actual arguments should start from index 1. @@ -223,7 +212,7 @@ myapp "hello" ./src Positional arguments are assigned in the order they are registered with `add_argument()`. If fewer values are provided than defined arguments, the remaining ones use their default values (if any). If more are provided, an error is raised (see [Positional Argument Count Validation](#positional-argument-count-validation)). -**Retrieving:** +#### Retrieving: ```mojo var pattern = result.get_string("pattern") # "hello" @@ -294,7 +283,7 @@ myapp -v # verbose = True myapp # verbose = False (default) ``` -**Retrieving:** +#### Retrieving: (Flags) ```mojo var verbose = result.get_flag("verbose") # Bool @@ -584,7 +573,7 @@ myapp -v --verbose # verbose = 2 (short + long both increment) myapp # verbose = 0 (default) ``` -**Retrieving:** +#### Retrieving: (Count) ```mojo var level = result.get_count("verbose") # Int @@ -630,7 +619,7 @@ This replaces the manual pattern of defining two separate flags (`--color` and ` --- -**Defining a negatable flag** +#### Defining a negatable flag ```mojo command.add_argument( @@ -651,7 +640,7 @@ Use `result.has("color")` to distinguish between "user explicitly disabled colou --- -**Help output** +#### Help output Negatable flags are displayed as a paired form: @@ -661,9 +650,9 @@ Negatable flags are displayed as a paired form: --- -**Comparison with manual approach** +#### Comparison with manual approach -**Before (two flags + mutually exclusive):** +#### Before (two flags + mutually exclusive): ```mojo command.add_argument(Argument("color", help="Force colored output").long["color"]().flag()) @@ -672,7 +661,7 @@ var group: List[String] = ["color", "no-color"] command.mutually_exclusive(group^) ``` -**After (single negatable flag):** +#### After (single negatable flag): ```mojo command.add_argument( @@ -704,7 +693,7 @@ This is a common pattern for options like `--include`, `--tag`, or `--define` wh --- -**Defining an append option** +#### Defining an append option ```mojo command.add_argument( @@ -736,7 +725,7 @@ All value syntaxes (space-separated, equals, attached short) work with append op --- -**Retrieving** +#### Retrieving ```mojo var tags = result.get_list("tag") # List[String] @@ -748,7 +737,7 @@ for i in range(len(tags)): --- -**Help output** +#### Help output (Append) Append options show a `...` suffix to indicate they are repeatable: @@ -770,7 +759,7 @@ command.add_argument( --- -**Combining with choices** +#### Combining with choices Choices validation is applied to each individual value: @@ -794,7 +783,7 @@ This is similar to Go cobra's `StringSliceVar` and Rust clap's `value_delimiter` --- -**Defining a delimiter option** +#### Defining a delimiter option ```mojo command.add_argument( @@ -831,7 +820,7 @@ Trailing delimiters are ignored — `--env a,b,` produces `["a", "b"]`, not `["a --- -**Retrieving** +#### Retrieving (Delimiter) ```mojo var envs = result.get_list("env") # List[String] @@ -841,7 +830,7 @@ for i in range(len(envs)): --- -**Combining with choices** +#### Combining with choices (Delimiter) Choices are validated per piece after splitting: @@ -859,7 +848,7 @@ myapp --env dev,local # Error: Invalid value 'local' for argument 'env' --- -**Other delimiters** +#### Other delimiters The allowed delimiters are `,` `;` `:` `|`. When fullwidth correction is enabled (the default), fullwidth equivalents in user input (e.g. `,` `;` `:` `|`) are auto-corrected before splitting: @@ -877,7 +866,7 @@ myapp --path "/usr/lib;/opt/lib;/home/lib" --- -**Combining with append** +#### Combining with append When a delimiter option is used multiple times, all split values accumulate: @@ -902,7 +891,7 @@ This is similar to Python argparse's `nargs=N` and Rust clap's `num_args`. --- -**Defining a multi-value option** +#### Defining a multi-value option Use `.number_of_values[N]()` to specify how many values the option consumes: @@ -926,7 +915,7 @@ myapp --rgb 255 128 0 --- -**Repeated occurrences** +#### Repeated occurrences Each occurrence consumes N more values, all accumulating in the same list: @@ -937,7 +926,7 @@ myapp --point 1 2 --point 3 4 --- -**Short options** +#### Short options nargs works with short options too: @@ -948,7 +937,7 @@ myapp -c 255 128 0 --- -**Retrieving values** +#### Retrieving values ```mojo var result = command.parse() @@ -958,7 +947,7 @@ var coords = result.get_list("point") --- -**Choices validation** +#### Choices validation Choices are validated for **each** value individually: @@ -976,7 +965,7 @@ myapp --route north up # ✗ 'up' is not a valid choice --- -**Help output** +#### Help output (Multi-Value) nargs options show the placeholder repeated N times: @@ -991,7 +980,7 @@ options show exactly N placeholders — making the expected arity clear. --- -**Limitations** +#### Limitations - **Equals syntax is not supported**: `--point=10 20` will raise an error. Use space-separated values: `--point 10 20`. @@ -1072,7 +1061,7 @@ dictionary just like `get_list()` returns an empty list. **Help placeholder** — map options automatically show `` instead of the default `` placeholder: -``` +```console Options: -D, --define ... Define a variable ``` @@ -1102,7 +1091,7 @@ myapp --log-level trace # Error: Invalid value 'trace' for argument 'log-leve --log-level {debug,info,warn,error} Log level ``` -**Combining with short options and attached values:** +#### Combining with short options and attached values: ```shell myapp -ldebug # (if short name is "l") OK @@ -1244,7 +1233,7 @@ This is useful when two options are logically contradictory, such as `--json` vs --- -**Defining a group** +#### Defining a group ```mojo command.add_argument(Argument("json", help="Output as JSON").long["json"]().flag()) @@ -1267,7 +1256,7 @@ myapp --json --csv # Error: Arguments are mutually exclusive: '--json', '--c --- -**Works with value-taking options too** +#### Works with value-taking options too The group members don't have to be flags — they can be any kind of argument: @@ -1287,7 +1276,7 @@ myapp --input data.csv --stdin # Error: mutually exclusive --- -**Multiple groups** +#### Multiple groups You can register more than one exclusive group on the same command: @@ -1320,7 +1309,7 @@ This mirrors Go cobra's `MarkFlagsOneRequired` and Rust clap's `ArgGroup::requir --- -**Defining a one-required group** +#### Defining a one-required group ```mojo command.add_argument(Argument("json", help="Output as JSON").long["json"]().flag()) @@ -1342,7 +1331,7 @@ Note that `one_required` only checks that **at least one** is present. It does n --- -**Exactly-one pattern (one-required + mutually exclusive)** +#### Exactly-one pattern (one-required + mutually exclusive) ```mojo command.add_argument(Argument("json", help="Output as JSON").long["json"]().flag()) @@ -1363,7 +1352,7 @@ myapp --json --yaml # Error: Arguments are mutually exclusive: '--json', --- -**Works with value-taking options** +#### Works with value-taking options ```mojo command.add_argument(Argument("input", help="Input file").long["input"]().short["i"]()) @@ -1379,7 +1368,7 @@ myapp # Error: At least one of the following arguments is r --- -**Multiple one-required groups** +#### Multiple one-required groups You can declare multiple groups. Each is validated independently: @@ -1410,7 +1399,7 @@ This is useful for sets of arguments that only make sense as a group — for exa --- -**Defining a group** +#### Defining a group (Required-Together) ```mojo command.add_argument(Argument("username", help="Auth username").long["username"]().short["u"]()) @@ -1433,7 +1422,7 @@ myapp --password secret # Error: Arguments required together: --- -**Three or more arguments** +#### Three or more arguments Groups can contain any number of arguments: @@ -1453,7 +1442,7 @@ myapp --host localhost # Error: '--port', '--proto' --- -**Combining with mutually exclusive groups** +#### Combining with mutually exclusive groups Required-together and mutually exclusive can coexist on the same command: @@ -1501,7 +1490,7 @@ myapp # OK — neither present --- -**Multiple conditional rules** +#### Multiple conditional rules You can declare multiple conditional requirements on the same command: @@ -1514,7 +1503,7 @@ Each rule is checked independently after parsing. --- -**Error messages** +#### Error messages Error messages use `--long` display names when available: @@ -1558,7 +1547,7 @@ myapp # OK — neither set --- -**Chained implications** +#### Chained implications Implications can be chained. If A implies B and B implies C, then setting A will also set C: @@ -1570,7 +1559,7 @@ command.implies("verbose", "log") --- -**Multiple implications from one trigger** +#### Multiple implications from one trigger A single argument can imply multiple targets: @@ -1582,7 +1571,7 @@ command.implies("debug", "log") --- -**Works with count arguments** +#### Works with count arguments When the implied argument is a count (`.count()`), it is set to 1 if not already present. Explicit counts are preserved: @@ -1595,7 +1584,7 @@ command.implies("debug", "verbose") --- -**Cycle detection** +#### Cycle detection Circular implications are detected at registration time and raise an error: @@ -1608,7 +1597,7 @@ This also catches indirect cycles (A → B → C → A). --- -**Integration with other constraints** +#### Integration with other constraints Implications are applied *after* defaults and *before* validation, so implied arguments participate in all subsequent constraint checks: @@ -1753,6 +1742,176 @@ elif result.subcommand == "init": All standard `ParseResult` methods (`get_flag()`, `get_string()`, `get_int()`, `get_list()`, `get_map()`, `get_count()`, `has()`) work on the subcommand result. +### Auto-Dispatch with `set_run_function` / `execute` + +#### The problem: manual subcommand routing + +When a CLI has subcommands (like `git clone`, `git push`, `git remote add`), the `ParseResult` tells you *which* subcommand was selected — but you must route to the right handler yourself: + +```mojo +var result = app.parse() + +if result.subcommand == "clone": + handle_clone(result.get_subcommand_result()) +elif result.subcommand == "push": + handle_push(result.get_subcommand_result()) +elif result.subcommand == "remote": + var sub = result.get_subcommand_result() + if sub.subcommand == "add": + handle_remote_add(sub.get_subcommand_result()) + elif sub.subcommand == "remove": + handle_remote_remove(sub.get_subcommand_result()) +``` + +This is tedious. Every time you add a subcommand, you update the router. With nested subcommands the `if/elif` tree gets deeper. A typo in `"clone"` vs `"cloen"` silently breaks routing. + +#### The solution: auto-dispatch + +With auto-dispatch, you register a handler *on the command itself*. ArgMojo then parses the arguments **and** routes to the correct handler in one call: + +```mojo +clone.set_run_function(handle_clone) +push.set_run_function(handle_push) +remote_add.set_run_function(handle_remote_add) +app.execute() # parse sys.argv() → walk the subcommand tree → call the right handler +``` + +No `if/elif` boilerplate. This is the pattern [Cobra](https://github.com/spf13/cobra) (Go's most popular CLI framework) pioneered: every command carries its own handler, and `Execute()` walks the tree automatically. + +--- + +#### Step 1 — Define handler functions + +Each handler is a free function with signature `def (ParseResult) raises`. It receives the parsed arguments for the command it is attached to. It is a procedure — it does work but returns nothing. + +```mojo +from argmojo import Command, Argument, ParseResult + +def handle_search(result: ParseResult) raises: + print("Searching for: " + result.get_string("pattern")) + +def handle_init(result: ParseResult) raises: + print("Initialising project: " + result.get_string("name")) +``` + +> **Why free functions?** Mojo does not yet support storing closures (functions that capture variables from their enclosing scope) as struct fields. Handlers must be module-level `def` functions. If you need to pass state to a handler, encode it in the parsed arguments or use module-level constants. + +#### Step 2 — Register handlers with `set_run_function()` + +```mojo +def main() raises: + var app = Command("app", "My CLI tool", version="1.0.0") + + var search = Command("search", "Search for patterns") + search.add_argument(Argument("pattern", help="Search pattern").positional().required()) + search.set_run_function(handle_search) # ← "when 'search' is selected, call handle_search" + app.add_subcommand(search^) + + var init = Command("init", "Initialise a new project") + init.add_argument(Argument("name", help="Project name").positional().required()) + init.set_run_function(handle_init) # ← "when 'init' is selected, call handle_init" + app.add_subcommand(init^) + + app.execute() # Parse sys.argv() → find matching subcommand → call its handler +``` + +Running `myapp search hello` calls `handle_search` with `pattern="hello"`. +Running `myapp init myproject` calls `handle_init` with `name="myproject"`. + +--- + +#### Method reference + +| Method | Description | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `cmd.set_run_function(handler)` | Registers a `def (ParseResult) raises` handler on `cmd`. | +| `cmd.execute()` | Parses `sys.argv()`, walks the subcommand chain, and calls the matching handler. | +| `cmd._execute_with_arguments(args)` | Testing helper. Like `execute()` but takes an explicit `List[String]` and raises on parse errors (instead of exiting). | + +**`set_run_function(handler)`** — Stores the handler function pointer on the command. You can call it again to replace a previous handler. Think of it as labelling a mailbox: "when mail arrives here, this person handles it." + +**`execute()`** — The main entry point for production code. It does three things in one call: + +1. Reads the command line from `sys.argv()`. +2. Parses it (calling `self.parse()` internally). +3. Walks the subcommand chain and invokes the matching handler. + +If no handler is registered on the resolved command, an `Error` is raised. + +**`_execute_with_arguments(args)`** — A **testing helper** (prefixed with `_` to indicate internal use). Like `execute()`, but accepts an explicit `List[String]` instead of reading `sys.argv()`. Unlike `execute()`, parse errors **raise** instead of calling `exit(2)`, making it suitable for tests that need to catch and inspect errors. Production code should always call `execute()`. + +--- + +#### How dispatch walks the tree + +Given `app remote add origin`, the internal dispatch chain is: + +```console +app._dispatch({subcommand: "remote", ...}) + → find child "remote" + → remote._dispatch({subcommand: "add", ...}) + → find child "add" + → add._dispatch({subcommand: "", name: "origin"}) + → no subcommand → call add's handler → handle_remote_add({name: "origin"}) +``` + +At each level: + +- **Has subcommand?** Find the matching child command and recurse. +- **No subcommand?** This is the leaf — call its registered handler. +- **Grouping command (has subcommands, no handler)?** Show help instead of raising — matching Cobra's behaviour. +- **No handler, no subcommands?** Raise `Error("No run function registered for command '…'")`. + +--- + +#### Nested subcommands + +The dispatch recursion handles arbitrary nesting depth: + +```mojo +def handle_remote_add(result: ParseResult) raises: + print("Adding remote: " + result.get_string("name")) + +def main() raises: + var app = Command("app", "My CLI") + + var remote = Command("remote", "Manage remotes") + # Note: 'remote' itself has no set_run_function — it is just a grouping command. + # Only its children have handlers. + + var remote_add = Command("add", "Add a remote") + remote_add.add_argument(Argument("name", help="Remote name").positional().required()) + remote_add.set_run_function(handle_remote_add) + remote.add_subcommand(remote_add^) + + app.add_subcommand(remote^) + + app.execute() # "app remote add origin" → handle_remote_add() +``` + +The `remote` command does not need a handler — it exists only to group `add`, `remove`, and other child commands. If a user types just `app remote` with no child subcommand, the help for `remote` is shown automatically (matching Cobra's behaviour for grouping commands). + +--- + +#### Testing with `_execute_with_arguments()` + +In tests, pass explicit argument lists instead of relying on `sys.argv()`: + +```mojo +# Test that "app search hello" calls the search handler correctly +var args: List[String] = ["app", "search", "hello"] +app._execute_with_arguments(args) # Calls handle_search with pattern="hello" +``` + +#### When to use auto-dispatch vs manual parsing + +| Scenario | Recommended approach | +| ----------------------------------- | ---------------------------------------------- | +| CLI with 2+ subcommands | Auto-dispatch (`set_run_function` + `execute`) | +| Simple CLI, no subcommands | Either works; `parse()` is fine too | +| Need `ParseResult` for custom logic | Manual `parse()` + your own routing | +| Integration testing | `_execute_with_arguments(args)` | + ### Persistent (Global) Flags A **persistent** flag is declared on the parent command but is automatically available in every subcommand. The user can place it either **before** or **after** the subcommand token — both work identically. @@ -1761,7 +1920,7 @@ This is inspired by Go cobra's `PersistentFlags()` and is useful for cross-cutti --- -**Defining persistent flags** +#### Defining persistent flags ```mojo var app = Command("app", "My app") @@ -1786,7 +1945,7 @@ app.add_subcommand(search^) --- -**Both positions work** +#### Both positions work ```shell app --verbose search "fn main" # flag BEFORE subcommand @@ -1874,7 +2033,7 @@ The auto-registered `help` subcommand is excluded from the **Commands** section --- -**Disabling the help subcommand** +#### Disabling the help subcommand If you don't want the auto-registered `help` subcommand (e.g., you want to use `help` as a real subcommand name), call `disable_help_subcommand()`: @@ -1907,7 +2066,7 @@ print(result.subcommand) # always "clone", even if user typed "cl" Aliases appear in help output alongside the primary name: -``` +```console Commands: clone, cl Clone a repository commit, ci Record changes to the repository @@ -2030,14 +2189,14 @@ command.add_argument( ) ``` -**Help output (before):** +#### Help output (before): ```console -o, --output Output file path -d, --max-depth Maximum directory depth ``` -**Help output (after `.value_name()` — wrapped by default):** +#### Help output (after `.value_name()` — wrapped by default): ```console -o, --output Output file path @@ -2075,7 +2234,7 @@ myapp --debug-index # Works — flag is set to True myapp --help # --debug-index does NOT appear in the help text ``` -**Typical use cases:** +#### Typical use cases: - Internal debugging flags that end users shouldn't need. - Features that are experimental or not yet stable. @@ -2143,7 +2302,7 @@ command.add_argument( ) ``` -**Behaviour:** +#### Behaviour: | Syntax | Value | | ------------------ | -------------------------------------------------- | @@ -2153,7 +2312,7 @@ command.add_argument( | `-c` | `"gzip"` (default-if-no-value) | | `-cbzip2` | `"bzip2"` (attached) | -**Combined with `.default()`:** +#### Combined with `.default()`: ```mojo command.add_argument( @@ -2194,7 +2353,7 @@ command.add_argument( ) ``` -**Behaviour:** +#### Behaviour: (Require-Equals) | Syntax | Result | | ------------------- | ------------------------------------------------ | @@ -2235,7 +2394,7 @@ command.add_argument( ) ``` -**Help output:** +#### Help output: ```console Options: @@ -2250,7 +2409,7 @@ Output: -o, --output Output file path ``` -**Key behaviours:** +#### Key behaviours: - **Ungrouped arguments** remain under "Options:". - **Group headings** appear in first-appearance order after "Options:". @@ -2269,7 +2428,7 @@ myapp -h myapp '-?' # quote needed: ? is a shell glob wildcard ``` -**Example output:** +#### Example output: ```console A CJK-aware text search tool @@ -2295,7 +2454,7 @@ Help text columns are **dynamically aligned**: the padding between the option na --- -**Coloured Output** +#### Coloured Output Help output uses **ANSI colour codes** by default to enhance readability. @@ -2316,7 +2475,7 @@ var help_plain = command._generate_help(color=False) # no ANSI codes --- -**Custom Colours** +#### Custom Colours The **header colour**, **argument-name colour**, **deprecation warning colour**, and **parse error colour** are all customisable. Section headers always keep the **bold + underline** style; only the colour changes. @@ -2346,7 +2505,7 @@ An unrecognised colour name is caught at **compile time** — the program will n Padding calculation is always based on the **plain-text width** (without escape codes), so columns remain correctly aligned regardless of whether colour is enabled. -**What controls the output:** +#### What controls the output: | Builder method | Effect on help | | -------------------- | ----------------------------------------------------- | @@ -2360,7 +2519,7 @@ After printing help, the program exits cleanly with exit code 0. --- -**`NO_COLOR` Environment Variable** +#### `NO_COLOR` Environment Variable ArgMojo respects the [`NO_COLOR`](https://no-color.org/) convention. When the `NO_COLOR` environment variable is **set** (any value, including an empty string), all ANSI colour codes are suppressed in: @@ -2378,7 +2537,7 @@ This takes priority over the `color=True` default but does **not** override an e --- -**Show Help When No Arguments Provided** +#### Show Help When No Arguments Provided Use `help_on_no_arguments()` to automatically display help when the user invokes the command with no arguments (like `git`, `docker`, or `cargo`): @@ -2443,7 +2602,7 @@ myapp --version myapp -V ``` -**Output:** +#### Output: ```console myapp 1.0.0 @@ -2465,7 +2624,7 @@ ArgMojo's help formatter uses **display width** (East Asian Width) to compute pa See the [Unicode East Asian Width specification](https://www.unicode.org/reports/tr11/) for details on CJK character ranges and properties. -**Example — mixed ASCII and CJK options:** +#### Example — mixed ASCII and CJK options: ```mojo var command = Command("工具", "一個命令行工具") @@ -2483,7 +2642,7 @@ Options: --編碼 <編碼> 設定編碼 ``` -**Example — CJK subcommands:** +#### Example — CJK subcommands: ```mojo var app = Command("工具", "一個命令行工具") @@ -2515,13 +2674,13 @@ ArgMojo automatically detects and corrects these characters **before parsing**, warning: detected full-width characters in '--verbose', auto-corrected to '--verbose' ``` -**What gets corrected:** +#### What gets corrected: - Fullwidth ASCII characters (`U+FF01`–`U+FF5E`) are converted to their halfwidth equivalents (`U+0021`–`U+007E`) by subtracting `0xFEE0`. - Fullwidth spaces (`U+3000`) are converted to regular spaces (`U+0020`). When a single token contains embedded fullwidth spaces (e.g., `--name\u3000yuhao\u3000--verbose` as one argv token), it is split into multiple arguments. - All tokens containing fullwidth ASCII are normalized (converted to halfwidth). Only tokens that start with `-` after correction are treated as options and trigger a warning. Positional values are also converted but no warning is emitted. -**Example — fullwidth flag:** +#### Example — fullwidth flag: ```mojo var app = Command("myapp", "My CLI") @@ -2531,7 +2690,7 @@ var result = app.parse_arguments(["myapp", "--verbose"]) # stderr: warning: detected full-width characters in '--verbose', auto-corrected to '--verbose' ``` -**Example — fullwidth equals syntax:** +#### Example — fullwidth equals syntax: ```mojo var app = Command("myapp", "My CLI") @@ -2540,7 +2699,7 @@ var result = app.parse_arguments(["myapp", "--output=fil # result.get_string("output") == "file.txt" ``` -**Disabling auto-correction:** +#### Disabling auto-correction: Call `disable_fullwidth_correction()` if you prefer strict parsing: @@ -2550,7 +2709,7 @@ app.disable_fullwidth_correction() # Now: fullwidth characters are NOT corrected ``` -**Whitespace handling:** +#### Whitespace handling: By default, only fullwidth space (`U+3000`) triggers token splitting. Other Unicode whitespace characters (for example, EM SPACE `U+2003`) are treated as regular characters and do **not** cause tokens to be split. @@ -2566,7 +2725,7 @@ ArgMojo provides three complementary approaches to handle this, inspired by Pyth --- -**Approach 1: Auto-detect (zero configuration)** +#### Approach 1: Auto-detect (zero configuration) When no registered short option uses a **digit character** as its name, ArgMojo automatically recognises numeric-looking tokens and treats them as positional arguments instead of options. @@ -2589,7 +2748,7 @@ Recognised patterns: `-N`, `-N.N`, `-.N`, `-NeX`, `-N.NeX`, `-Ne+X`, `-Ne-X` (wh --- -**Approach 2: The `--` separator (always works)** +#### Approach 2: The `--` separator (always works) The `--` stop marker forces everything after it to be treated as positional. This is the most universal approach and works regardless of any configuration. @@ -2606,7 +2765,7 @@ Tip: Use '--' to pass values that start with '-' (e.g., negative numbers): calc --- -**Approach 3: `allow_negative_numbers()` (explicit opt-in)** +#### Approach 3: `allow_negative_numbers()` (explicit opt-in) If you have a registered short option that uses a digit character (e.g., `-3` for `--triple`), the auto-detect is suppressed to avoid ambiguity. In this case, call `allow_negative_numbers()` to force all numeric-looking tokens to be treated as positionals. @@ -2628,7 +2787,7 @@ calc -3 # operand = "-3" (NOT the -3 flag!) --- -**Approach 4: `allow_negative_expressions()` (expressions and arbitrary tokens)** +#### Approach 4: `allow_negative_expressions()` (expressions and arbitrary tokens) When your CLI accepts mathematical expressions that start with `-` (e.g. `-1/3*pi`, `-sin(2)`, `-e^2`), `allow_negative_numbers()` is not enough because those tokens are not pure numeric literals. Call `allow_negative_expressions()` to treat any single-hyphen token as a positional argument, provided it doesn't conflict with a registered short option. @@ -2657,7 +2816,7 @@ Rules: --- -**When to use which approach** +#### When to use which approach | Scenario | Recommended approach | | ------------------------------------------------------------------------- | ---------------------------------------- | @@ -2670,7 +2829,7 @@ Rules: --- -**`allow_negative_expressions()` vs `allow_hyphen_values()` — how do they relate?** +#### `allow_negative_expressions()` vs `allow_hyphen_values()` — how do they relate? These two features partially overlap, especially when the command has a single positional argument. Here is a quick comparison: @@ -2697,7 +2856,7 @@ Choose `allow_hyphen_values()` when: --- -**What is NOT a number** +#### What is NOT a number Tokens like `-1abc`, `-e5`, or `-1-2` are not valid numeric patterns. Without `allow_negative_expressions()`, they will still be parsed as short-option strings and may raise "Unknown option" errors if unregistered. With `allow_negative_expressions()`, they are consumed as positional arguments. @@ -2722,7 +2881,7 @@ myapp --out=file.txt # resolves to --output=file.txt --- -**Ambiguous prefixes** +#### Ambiguous prefixes If the prefix matches more than one option, an error is raised: @@ -2738,7 +2897,7 @@ myapp --ver --- -**Exact match always wins** +#### Exact match always wins If the user's input is an **exact match** for one option, it is chosen even if it is also a prefix of another option: @@ -2754,7 +2913,7 @@ myapp --col # ambiguous → error --- -**Works with negatable flags** +#### Works with negatable flags Prefix matching also applies to `--no-X` negation: @@ -2830,7 +2989,7 @@ runner myapp --verbose -x --output=foo.txt The remainder positional automatically implies `.positional()` and `.append()`. In help output, it is displayed as `args...` (with trailing ellipsis). -**Rules:** +#### Rules: - `.remainder()` must not have `.long()` or `.short()` — it is positional-only. - At most **one** remainder positional is allowed per command. @@ -3060,7 +3219,7 @@ Argument("token", help="API token").long["token"]().prompt["Enter your API token ### Interactive Session Examples -#### All arguments missing — full prompting +#### All arguments missing — full prompting When none of the prompt-enabled arguments are provided, the user is prompted for each one in order: @@ -3073,7 +3232,7 @@ Server region [us/eu/ap] (us): eu The parsed result contains `user="alice"`, `token="secret-123"`, `region="eu"`. -#### Partial arguments — only missing ones are prompted +#### Partial arguments — only missing ones are prompted When some arguments are already provided on the command line, only the missing ones trigger a prompt: @@ -3085,7 +3244,7 @@ Server region [us/eu/ap] (us): ap `--user` was given on the CLI, so `Username:` is **not** asked. -#### All arguments provided — no prompting at all +#### All arguments provided — no prompting at all ```shell ./login --user alice --token secret-123 --region eu @@ -3093,7 +3252,7 @@ Server region [us/eu/ap] (us): ap No prompts appear. The CLI values are used directly. -#### Empty input with a default — default value is used +#### Empty input with a default — default value is used When the user presses Enter without typing anything and the argument has a `.default[]()`, the default is applied: @@ -3106,7 +3265,7 @@ Server region [us/eu/ap] (us): The user pressed Enter at `Server region`, so `region` gets the default value `"us"`. -#### Flag argument — y/n prompt +#### Flag argument — y/n prompt Flag arguments accept `y`/`n`/`yes`/`no` (case-insensitive): @@ -3124,7 +3283,7 @@ Enable verbose output [y/n]: y Answering `y` or `yes` sets the flag to `True`. Answering `n` or `no` sets it to `False`. -#### Argument with choices — choices are shown +#### Argument with choices — choices are shown When a prompt-enabled argument has `.choice[]()` values, they are displayed in brackets. If a default exists, it is shown in parentheses: @@ -3364,7 +3523,7 @@ To bypass the prompt entirely, provide the value on the command line: Some commands are destructive or irreversible — dropping databases, deleting files, deploying to production. The **confirmation option** adds a built-in `--yes` / `-y` flag that lets users skip an interactive confirmation prompt. This is equivalent to Click's `confirmation_option` decorator. -### Basic Usage +### Basic Usage (Confirmation Option) ```mojo from argmojo import Command, Argument @@ -3397,7 +3556,7 @@ $ ./drop mydb --yes Dropping database: mydb ``` -### Custom Prompt Text +### Custom Prompt Text (Confirmation Option) Use the compile-time parameter overload to set a custom prompt: @@ -3411,7 +3570,7 @@ This changes the prompt to: Drop the database? This cannot be undone. [y/N]: ``` -### Using with Subcommands +### Using with Subcommands (Confirmation Option) Confirmation works naturally with subcommands. The `--yes` flag is registered on the command that calls `confirmation_option()`: @@ -3427,7 +3586,7 @@ var result = app.parse() # app --yes deploy prod → skips confirmation ``` -### Non-Interactive Use +### Non-Interactive Use (Confirmation Option) When stdin is not available (piped input, CI environments, `/dev/null`), the confirmation prompt cannot be displayed. In this case, the command **aborts with an error** unless `--yes` is passed. This ensures that destructive commands never run silently without explicit opt-in: @@ -3580,7 +3739,7 @@ After generating a script, users `source` it or place it in a shell-specific dir --- -**Bash:** +#### Bash: ```bash # One-shot (current session only) @@ -3593,7 +3752,7 @@ myapp --completions bash > ~/.bash_completion.d/myapp --- -**Zsh:** +#### Zsh: ```zsh # Place in your fpath (file must be named _myapp) @@ -3606,7 +3765,7 @@ myapp --completions zsh > ~/.zsh/completions/_myapp --- -**Fish:** +#### Fish: ```shell # Fish auto-loads from this directory @@ -3810,7 +3969,7 @@ def main() raises: See [`examples/declarative/convert.mojo`](../examples/declarative/convert.mojo) for a complete example. -### Subcommands +### Subcommands in Declarative API Every level in the command tree is a `Parsable` struct. Register children via the `subcommands()` hook: @@ -3925,22 +4084,82 @@ The table below maps every ArgMojo builder method / command-level method to its | `.prompt()` | — | `prompt=True` | — | — | | `.prompt["msg"]()` | — | `prompt="msg"` | — | — | | `.password()` | — | `hide_input=True` | — | — | +| `.group["name"]()` | — (manual section formatting) | — | `.help_heading("name")` | — (manual grouping) | + +### Command-Level Methods + +#### Registration & Structure -### Command-Level Constraint Methods +| ArgMojo method | argparse | click | clap (Rust) | cobra / pflag (Go) | +| ------------------------------------- | -------------------------------- | ------------------------------------- | ---------------------------------- | -------------------------- | +| `add_argument(arg)` | `add_argument(…)` | `@click.option` / `@click.argument` | `Arg::new(…)` via derive / builder | `cmd.Flags().…` | +| `add_subcommand(sub)` | `add_subparsers().add_parser(…)` | `@group.command()` / `.add_command()` | `.subcommand(Command::new(…))` | `parent.AddCommand(child)` | +| `add_parent(parent)` | `parents=[parent]` | — | — | — | +| `command_aliases(names)` | — (use multiple names) | — | `.visible_aliases([…])` | `Aliases: []string{…}` | +| `hidden()` (command) | — | `hidden=True` | `.hide(true)` | `Hidden: true` | +| `disable_help_subcommand()` | — | — | `.disable_help_subcommand(true)` | — | +| `allow_positional_with_subcommands()` | — | — | — | `TraverseChildren` | + +#### Group Constraints | ArgMojo method | argparse | click | clap (Rust) | cobra / pflag (Go) | | --------------------------- | -------------------------------- | ------------------------------- | ------------------------------ | ------------------------------- | | `mutually_exclusive(…)` | `add_mutually_exclusive_group()` | `cls=MutuallyExclusiveOption` ⁹ | `.conflicts_with("x")` per arg | — ⁴ | -| `one_required(…)` | group + `required=True` | — | `.group["G"]().required(true)` | — ⁴ | +| `one_required(…)` | group + `required=True` | — | `.group("G").required(true)` | — ⁴ | | `required_together(…)` | — | — | `.requires("x")` per arg | `MarkFlagsRequiredTogether()` ¹ | | `required_if(target, cond)` | — | — | `.required_if_eq("x","v")` | `MarkFlagRequired…` ¹ | | `implies(trigger, implied)` | — | — | `.requires_if("v","x")` ¹⁰ | — | -| `parse_known_arguments()` | `parse_known_args()` | — | — ¹¹ | `FParseErrWhitelist` ¹² | -| `response_file_prefix()` | `fromfile_prefix_chars="@"` | — | — | — | -| `add_parent(parent)` | `parents=[parent]` | — | — | — | -| `confirmation_option()` | — | `confirmation_option` | — | — | -### Notes +#### Parser Behaviour + +| ArgMojo method | argparse | click | clap (Rust) | cobra / pflag (Go) | +| ---------------------------------- | --------------------------- | ------------------------------ | ------------------------------- | ----------------------- | +| `help_on_no_arguments()` | — | `invoke_without_command=False` | `.arg_required_else_help(true)` | auto (shows usage) | +| `allow_negative_numbers()` | — | — | `.allow_negative_numbers(true)` | — | +| `allow_negative_expressions()` | — | — | — | — | +| `parse_known_arguments()` | `parse_known_args()` | — | — ¹¹ | `FParseErrWhitelist` ¹² | +| `response_file_prefix()` | `fromfile_prefix_chars="@"` | — | — | — | +| `response_file_max_depth[N]()` | — | — | — | — | +| `confirmation_option()` | — | `confirmation_option` | — | — | +| `disable_fullwidth_correction()` | — | — | — | — | +| `disable_punctuation_correction()` | — | — | — | — | + +#### Help & Display + +| ArgMojo method | argparse | click | clap (Rust) | cobra / pflag (Go) | +| ---------------------- | ------------ | ------------ | ---------------------- | ------------------ | +| `usage(text)` | `usage="…"` | — | `.override_usage("…")` | `Use: "…"` | +| `add_tip(tip)` | `epilog="…"` | `epilog="…"` | `.after_help("…")` | `Example: "…"` | +| `header_color[name]()` | — | `style()` ¹³ | `Styles::styled()` ¹⁴ | — | +| `arg_color[name]()` | — | `style()` ¹³ | `Styles::styled()` ¹⁴ | — | +| `warn_color[name]()` | — | `style()` ¹³ | `Styles::styled()` ¹⁴ | — | +| `error_color[name]()` | — | `style()` ¹³ | `Styles::styled()` ¹⁴ | — | + +#### Shell Completions + +| ArgMojo method | argparse | click | clap (Rust) | cobra / pflag (Go) | +| ------------------------------- | -------- | ---------------- | ---------------------------------- | -------------------------- | +| `disable_default_completions()` | — | — | — | `DisableAutoGenTag` field | +| `completions_name(name)` | — | — | — | — | +| `completions_as_subcommand()` | — | — | `clap_complete` subcommand pattern | default (subcommand) | +| `generate_completion(shell)` | — | `shell_complete` | `clap_complete::generate()` | `GenBashCompletion()` etc. | + +#### Auto-Dispatch + +| ArgMojo method | argparse | click | clap (Rust) | cobra / pflag (Go) | +| ------------------------------- | -------- | ----------------------------- | ----------- | ------------------ | +| `set_run_function(handler)` | — | callback param on `@command` | — | `Run: func(…)` ¹⁵ | +| `execute()` | — | implicit via `cli()` | — | `cmd.Execute()` | +| `_execute_with_arguments(args)` | — | `runner.invoke(cli, args)` ¹⁶ | — | — | + +#### Parse + +| ArgMojo method | argparse | click | clap (Rust) | cobra / pflag (Go) | +| ----------------------- | ------------------ | --------------- | ------------------------- | ------------------ | +| `parse()` | `parse_args()` | implicit | `.get_matches()` | implicit | +| `parse_arguments(args)` | `parse_args(args)` | `.main(args=…)` | `.get_matches_from(args)` | — | + +### Notes (Cross-Library Method Name Reference) 1. Cobra / pflag uses imperative `cmd.MarkFlag…()` calls on the command, not builder-chaining on the flag definition. 2. clap positional args are defined by `.index(1)`, `.index(2)`, etc., or by omitting `.long()` / `.short()`. @@ -3954,3 +4173,7 @@ The table below maps every ArgMojo builder method / command-level method to its 10. clap's `.requires_if("val", "other_arg")` means "if this arg has value `val`, then `other_arg` is also required", which is a superset of ArgMojo's `implies`. 11. clap uses `.trailing_var_arg(true)` on the command (not the argument) for remainder-like behaviour. For `parse_known_arguments`, clap has no direct equivalent; use `allow_external_subcommands`. 12. Cobra uses `TraverseChildren` for remainder-like behaviour. For partial parsing, Cobra's `FParseErrWhitelist{UnknownFlags: true}` ignores unknown flags. +13. click uses `click.style()` and `click.echo()` for coloured output, not per-section colour configuration. Colour is applied manually per string, not as a command-level setting. +14. clap uses `Styles::styled()` with `AnsiColor` enum values for header, literal, placeholder, and error colours. Applied at the `Command` level. +15. Cobra's `Run` is a struct field of type `func(cmd *Command, args []string)`. Unlike ArgMojo, the handler receives both the command and positional args (not a `ParseResult`). +16. click's test runner (`CliRunner.invoke()`) captures stdout and returns a `Result` object, rather than dispatching through the normal execution path. diff --git a/examples/build.sh b/examples/build.sh new file mode 100755 index 0000000..1f97477 --- /dev/null +++ b/examples/build.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# examples/build.sh — Build (with timing) and run argmojo examples. +# +# Called via pixi: +# pixi run build Build ALL examples, time each, run with --help +# pixi run build Build one example, then run it with --help +# +# Can also be called directly: +# ./examples/build.sh Build all +# ./examples/build.sh mgit Build & run mgit only +# ./examples/build.sh --list List available examples + +set -eo pipefail +cd "$(dirname "$0")/.." + +# ── Example registry (name source helparg) ──────────────────────────── +# helparg: argument(s) passed when running the binary after build. +# Uses --help so examples with required positional args exit cleanly. +NAMES=( mgrep mgit demo yu search deploy convert jomo ) +SOURCES=( + "examples/mgrep.mojo" + "examples/mgit.mojo" + "examples/demo.mojo" + "examples/yu.mojo" + "examples/declarative/search.mojo" + "examples/declarative/deploy.mojo" + "examples/declarative/convert.mojo" + "examples/declarative/jomo.mojo" +) + +resolve() { + local i + for i in "${!NAMES[@]}"; do + if [ "${NAMES[$i]}" = "$1" ]; then + echo "${SOURCES[$i]}" + return 0 + fi + done + return 1 +} + +list_examples() { + echo "Available examples:" + local i + for i in "${!NAMES[@]}"; do + printf " %-10s %s\n" "${NAMES[$i]}" "${SOURCES[$i]}" + done +} + +# ── Build + time one example ───────────────────────────────────────────── +# build_one +# Sets TIMES[name]=seconds +build_one() { + local name="$1" src="$2" + local t0 t1 elapsed + t0=$(date +%s) + mojo build -I src "$src" -o "$name" + t1=$(date +%s) + elapsed=$((t1 - t0)) + TIMES+=("$name:${elapsed}s") +} + +# ── Run one example (--help) ───────────────────────────────────────────── +run_one() { + local name="$1" + echo "" + echo "── ./$name --help ──" + ./"$name" --help +} + +# ── Print timing summary ───────────────────────────────────────────────── +print_summary() { + echo "" + echo "┌──────────────────────────────────────────┐" + echo "│ Build time summary │" + echo "├────────────┬─────────────────────────────┤" + printf "│ %-10s │ %-27s │\n" "Example" "Compile time" + echo "├────────────┼─────────────────────────────┤" + local entry + for entry in "${TIMES[@]}"; do + local n="${entry%%:*}" + local t="${entry#*:}" + printf "│ %-10s │ %27s │\n" "$n" "$t" + done + echo "└────────────┴─────────────────────────────┘" +} + +# ═════════════════════════════════════════════════════════════════════════ + +TIMES=() + +# ── --list ─────────────────────────────────────────────────────────────── +if [ "${1:-}" = "--list" ]; then + list_examples + exit 0 +fi + +# ── Package first ──────────────────────────────────────────────────────── +echo "Packaging argmojo..." +pixi run package +echo "" + +# ── Single example ─────────────────────────────────────────────────────── +if [ $# -ge 1 ]; then + name="$1" + src=$(resolve "$name" 2>/dev/null) || { + echo "Error: unknown example '$name'" >&2 + list_examples >&2 + exit 1 + } + echo "Building $name ($src)..." + build_one "$name" "$src" + run_one "$name" + print_summary + exit 0 +fi + +# ── All examples ───────────────────────────────────────────────────────── +echo "Building all examples..." +echo "" +for i in "${!NAMES[@]}"; do + name="${NAMES[$i]}" + src="${SOURCES[$i]}" + echo " [$((i+1))/${#NAMES[@]}] $name ($src)" + build_one "$name" "$src" +done + +echo "" +echo "Running all examples (--help)..." +for name in "${NAMES[@]}"; do + run_one "$name" +done + +print_summary \ No newline at end of file diff --git a/examples/mgit.mojo b/examples/mgit.mojo index 9cfbc06..cc11f60 100644 --- a/examples/mgit.mojo +++ b/examples/mgit.mojo @@ -3,28 +3,453 @@ Simulates the interface of git. Only argument parsing is performed; no actual version-control operations are implemented. -Showcases: subcommands, persistent (global) flags, per-command positional -args, boolean flags, count flags, negatable flags, choices, default values, -required arguments, value_name, hidden arguments, append/collect, value -delimiter, mutually exclusive groups, required-together groups, conditional -requirements, numeric range validation, aliases, deprecated arguments, -Commands section in help, Global Options heading, full command path in -child help/errors, unknown subcommand error, custom tips, and shell -completion script generation. +Showcases: auto-dispatch (set_run_function / execute), subcommands, +sub-subcommands (remote, stash, config), persistent (global) flags, +per-command positional args, boolean flags, count flags, negatable flags, +choices, default values, required arguments, value_name, hidden arguments, +append/collect, value delimiter, mutually exclusive groups, +required-together groups, conditional requirements, numeric range +validation, aliases, deprecated arguments, Commands section in help, +Global Options heading, full command path in child help/errors, unknown +subcommand error, custom tips, and shell completion script generation. Try these: - git --help # root help (Commands + Global Options) - git --version - git clone --help # child help with full path - git clone https://example.com/repo.git my-project --depth 1 - git commit -am "initial commit" - git log --oneline -n 20 --author "Alice" - git remote add origin https://example.com/repo.git - git -v push origin main --force --tags - git --completions bash # shell completion script (built-in) + mgit --help # root help (Commands + Global Options) + mgit --version + mgit clone --help # child help with full path + mgit clone https://example.com/repo.git my-project --depth 1 + mgit commit -am "initial commit" + mgit log --oneline -n 20 --author "Alice" + mgit remote add origin https://example.com/repo.git + mgit -v push origin main --force --tags + mgit stash push -m "wip" # stash sub-subcommand + mgit stash pop # stash pop + mgit config set user.name "Alice" # config sub-subcommand + mgit config get user.name + mgit --completions bash # shell completion script (built-in) """ -from argmojo import Argument, Command +from argmojo import Argument, Command, ParseResult + + +# ═══════════════════════════════════════════════════════════════════════════ +# Handler functions — called automatically by execute() via auto-dispatch +# ═══════════════════════════════════════════════════════════════════════════ + + +def handle_clone(result: ParseResult) raises: + """Handler for 'mgit clone'.""" + var repo = result.get_string("repository") + var msg = String("Cloning into '") + try: + msg += result.get_string("directory") + except: + # Use the last segment of the URL as directory name. + var parts = repo.split("/") + var last = parts[len(parts) - 1] + if last.endswith(".git"): + msg += last[byte = : len(last) - 4] + else: + msg += last + msg += "'..." + print(msg) + print(" remote: " + repo) + try: + var depth = result.get_int("depth") + print(" shallow clone: depth " + String(depth)) + except: + pass + if result.get_flag("bare"): + print(" creating bare repository") + try: + print(" branch: " + result.get_string("branch")) + except: + pass + if result.get_flag("recurse-submodules"): + print(" recursing into submodules") + print("done.") + + +def handle_init(result: ParseResult) raises: + """Handler for 'mgit init'.""" + var dir = result.get_string("directory") + if result.get_flag("bare"): + print("Initialized empty bare Git repository in " + dir + "/") + else: + print("Initialized empty Git repository in " + dir + "/.git/") + try: + print(" template: " + result.get_string("template")) + except: + pass + try: + print(" initial branch: " + result.get_string("initial-branch")) + except: + pass + + +def handle_add(result: ParseResult) raises: + """Handler for 'mgit add'.""" + if result.get_flag("dry-run"): + print("dry run — nothing will be staged") + if result.get_flag("all"): + print("Adding all changed files to the index") + else: + try: + print("Adding '" + result.get_string("pathspec") + "' to the index") + except: + print("Adding files to the index") + if result.get_flag("force"): + print(" (including ignored files)") + if result.get_flag("patch"): + print(" entering interactive patch mode...") + + +def handle_commit(result: ParseResult) raises: + """Handler for 'mgit commit'.""" + var msg = result.get_string("message") + if result.get_flag("amend"): + print("[main abc1234] (amended) " + msg) + else: + print("[main def5678] " + msg) + if result.get_flag("all"): + print(" auto-staged modified and deleted files") + try: + print(" author: " + result.get_string("author")) + except: + pass + if result.get_flag("no-verify"): + print(" (hooks skipped)") + print(" 3 files changed, 42 insertions(+), 7 deletions(-)") + + +def handle_push(result: ParseResult) raises: + """Handler for 'mgit push'.""" + var remote = result.get_string("remote") + var refspec = String("HEAD") + try: + refspec = result.get_string("refspec") + except: + pass + if result.get_flag("dry-run"): + print("(dry run) ", end="") + if result.get_flag("force"): + print("Force pushing to " + remote + "/" + refspec + "...") + elif result.get_flag("force-with-lease"): + print("Force-with-lease pushing to " + remote + "/" + refspec + "...") + else: + print("Pushing to " + remote + "/" + refspec + "...") + if result.get_flag("set-upstream"): + print( + " branch '" + + refspec + + "' set up to track '" + + remote + + "/" + + refspec + + "'" + ) + if result.get_flag("tags"): + print(" including all tags") + print(" Everything up-to-date") + + +def handle_pull(result: ParseResult) raises: + """Handler for 'mgit pull'.""" + var remote = result.get_string("remote") + var branch = String("main") + try: + branch = result.get_string("branch") + except: + pass + print("Pulling from " + remote + "/" + branch + "...") + if result.get_flag("autostash"): + print(" auto-stashing local changes") + if result.get_flag("rebase"): + print(" rebasing on top of upstream") + elif result.get_flag("no-rebase"): + print(" merging upstream changes") + print(" Already up to date.") + + +def handle_log(result: ParseResult) raises: + """Handler for 'mgit log'.""" + var n = 5 + try: + n = result.get_int("number") + except: + pass + var oneline = result.get_flag("oneline") + var graph = result.get_flag("graph") + try: + print("Filtering by author: " + result.get_string("author")) + except: + pass + try: + print("Since: " + result.get_string("since")) + except: + pass + try: + print("Until: " + result.get_string("until")) + except: + pass + var patterns = result.get_list("grep") + if len(patterns) > 0: + for i in range(len(patterns)): + print("Grep: " + patterns[i]) + # Simulated log output. + var commits: List[String] = [ + "abc1234 Initial commit", + "def5678 Add README.md", + "789abcd Implement feature X", + "012efgh Fix bug in parser", + "345ijkl Refactor module Y", + "678mnop Add unit tests", + "901qrst Update documentation", + ] + var limit = n if n < len(commits) else len(commits) + for i in range(limit): + if graph: + print("* ", end="") + if oneline: + print(commits[i]) + else: + var parts = commits[i].split(" ", 1) + print("commit " + parts[0]) + print(" " + parts[1]) + print() + + +def handle_branch(result: ParseResult) raises: + """Handler for 'mgit branch'.""" + var name = String("") + try: + name = result.get_string("name") + except: + pass + if name != "" and result.get_flag("delete"): + print("Deleted branch " + name + " (was abc1234).") + elif name != "" and result.get_flag("force-delete"): + print("Force-deleted branch " + name + " (was abc1234).") + elif name != "" and result.get_flag("move"): + print("Renamed branch to '" + name + "'") + elif name != "": + print("Created branch '" + name + "' at abc1234") + else: + # List branches. + var show_all = result.get_flag("all-branches") + print("* main") + print(" develop") + print(" feature/auto-dispatch") + if show_all: + print(" remotes/origin/main") + print(" remotes/origin/develop") + + +def handle_diff(result: ParseResult) raises: + """Handler for 'mgit diff'.""" + var path = String("") + try: + path = result.get_string("path") + except: + pass + if result.get_flag("staged"): + print("Changes staged for commit", end="") + else: + print("Changes not staged for commit", end="") + if path != "": + print(" (" + path + ")") + else: + print("") + if result.get_flag("stat"): + print(" src/main.mojo | 12 ++++++------") + print(" 1 file changed, 6 insertions(+), 6 deletions(-)") + elif result.get_flag("name-only"): + print(" src/main.mojo") + else: + print("diff --git a/src/main.mojo b/src/main.mojo") + print("--- a/src/main.mojo") + print("+++ b/src/main.mojo") + print("@@ -10,3 +10,3 @@") + print("- old_line()") + print("+ new_line()") + + +def handle_tag(result: ParseResult) raises: + """Handler for 'mgit tag'.""" + var name = String("") + try: + name = result.get_string("tagname") + except: + pass + if result.get_flag("tag-list") or name == "": + print("v0.1.0") + print("v0.2.0") + print("v0.5.0") + return + if result.get_flag("tag-delete"): + print("Deleted tag '" + name + "' (was abc1234)") + return + if result.get_flag("annotate"): + var msg = String("Release " + name) + try: + msg = result.get_string("tag-message") + except: + pass + print("Created annotated tag '" + name + "': " + msg) + else: + print("Created lightweight tag '" + name + "' at abc1234") + + +# ── remote sub-subcommand handlers ────────────────────────────────────── + + +def handle_remote_add(result: ParseResult) raises: + """Handler for 'mgit remote add'.""" + var name = result.get_string("name") + var url = result.get_string("url") + print("Added remote '" + name + "' -> " + url) + if result.get_flag("fetch"): + print("Fetching from " + name + "...") + print(" * [new branch] main -> " + name + "/main") + + +def handle_remote_remove(result: ParseResult) raises: + """Handler for 'mgit remote remove'.""" + print("Removed remote '" + result.get_string("name") + "'") + + +def handle_remote_rename(result: ParseResult) raises: + """Handler for 'mgit remote rename'.""" + print( + "Renamed remote '" + + result.get_string("old") + + "' to '" + + result.get_string("new") + + "'" + ) + + +def handle_remote_show(result: ParseResult) raises: + """Handler for 'mgit remote show'.""" + var name = result.get_string("name") + print("* remote " + name) + print(" Fetch URL: https://example.com/repo.git") + print(" Push URL: https://example.com/repo.git") + print(" HEAD branch: main") + print(" Remote branches:") + print(" main tracked") + print(" develop tracked") + + +# ── stash sub-subcommand handlers ─────────────────────────────────────── + + +def handle_stash_push(result: ParseResult) raises: + """Handler for 'mgit stash push'.""" + var msg = String("WIP on main") + try: + msg = result.get_string("stash-message") + except: + pass + print("Saved working directory and index state: " + msg) + if result.get_flag("keep-index"): + print(" (staged changes kept in index)") + if result.get_flag("include-untracked"): + print(" (including untracked files)") + + +def handle_stash_pop(result: ParseResult) raises: + """Handler for 'mgit stash pop'.""" + var index = String("0") + try: + index = result.get_string("stash-index") + except: + pass + print("Popping stash@{" + index + "}...") + print("On branch main") + print("Changes restored from stash.") + print("Dropped stash@{" + index + "}") + + +def handle_stash_list(result: ParseResult) raises: + """Handler for 'mgit stash list'.""" + _ = result + print("stash@{0}: WIP on main: abc1234 Fix parser bug") + print("stash@{1}: On develop: def5678 Half-done feature") + + +def handle_stash_drop(result: ParseResult) raises: + """Handler for 'mgit stash drop'.""" + var index = String("0") + try: + index = result.get_string("stash-index") + except: + pass + print("Dropped stash@{" + index + "} (abc1234)") + + +def handle_stash_apply(result: ParseResult) raises: + """Handler for 'mgit stash apply'.""" + var index = String("0") + try: + index = result.get_string("stash-index") + except: + pass + print("Applying stash@{" + index + "}...") + print("On branch main") + print("Changes restored from stash.") + + +# ── config sub-subcommand handlers ────────────────────────────────────── + + +def handle_config_get(result: ParseResult) raises: + """Handler for 'mgit config get'.""" + var key = result.get_string("key") + # Simulated config values. + if key == "user.name": + print("Alice") + elif key == "user.email": + print("alice@example.com") + elif key == "core.editor": + print("vim") + else: + print("(not set)") + + +def handle_config_set(result: ParseResult) raises: + """Handler for 'mgit config set'.""" + var key = result.get_string("key") + var value = result.get_string("value") + if result.get_flag("global"): + print("Set global config: " + key + " = " + value) + else: + print("Set local config: " + key + " = " + value) + + +def handle_config_list(result: ParseResult) raises: + """Handler for 'mgit config list'.""" + _ = result + print("user.name=Alice") + print("user.email=alice@example.com") + print("core.editor=vim") + print("core.autocrlf=input") + print("remote.origin.url=https://example.com/repo.git") + print("remote.origin.fetch=+refs/heads/*:refs/remotes/origin/*") + + +def handle_config_unset(result: ParseResult) raises: + """Handler for 'mgit config unset'.""" + var key = result.get_string("key") + if result.get_flag("global"): + print("Unset global config: " + key) + else: + print("Unset local config: " + key) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Command tree construction +# ═══════════════════════════════════════════════════════════════════════════ def main() raises: @@ -62,7 +487,7 @@ def main() raises: ) # ── Custom tips ────────────────────────────────────────────────────── - app.add_tip("Run 'git --help' for detailed help on a command.") + app.add_tip("Run 'mgit --help' for detailed help on a command.") # ── clone ──────────────────────────────────────────────────────────── var clone = Command("clone", "Clone a repository into a new directory") @@ -96,6 +521,7 @@ def main() raises: clone.help_on_no_arguments() var clone_aliases: List[String] = ["cl"] clone.command_aliases(clone_aliases^) + clone.set_run_function(handle_clone) app.add_subcommand(clone^) # ── init ───────────────────────────────────────────────────────────── @@ -121,6 +547,7 @@ def main() raises: .short["b"]() .default["main"]() ) + init.set_run_function(handle_init) app.add_subcommand(init^) # ── add ────────────────────────────────────────────────────────────── @@ -150,6 +577,7 @@ def main() raises: .short["p"]() .flag() ) + add.set_run_function(handle_add) app.add_subcommand(add^) # ── commit ─────────────────────────────────────────────────────────── @@ -190,6 +618,7 @@ def main() raises: ) var commit_aliases: List[String] = ["ci"] commit.command_aliases(commit_aliases^) + commit.set_run_function(handle_commit) app.add_subcommand(commit^) # ── push ───────────────────────────────────────────────────────────── @@ -229,6 +658,7 @@ def main() raises: ) var force_group: List[String] = ["force", "force-with-lease"] push.mutually_exclusive(force_group^) + push.set_run_function(handle_push) app.add_subcommand(push^) # ── pull ───────────────────────────────────────────────────────────── @@ -255,6 +685,7 @@ def main() raises: .long["autostash"]() .flag() ) + pull.set_run_function(handle_pull) app.add_subcommand(pull^) # ── log ────────────────────────────────────────────────────────────── @@ -306,11 +737,12 @@ def main() raises: .choice["full"]() .choice["fuller"]() ) + log.set_run_function(handle_log) app.add_subcommand(log^) - # ── remote ─────────────────────────────────────────────────────────── + # ── remote (with sub-subcommands) ──────────────────────────────────── var remote = Command("remote", "Manage set of tracked repositories") - # remote itself has subcommands: add, remove, rename, show + var remote_add = Command("add", "Add a new remote") remote_add.add_argument( Argument("name", help="Remote name").positional().required() @@ -325,6 +757,7 @@ def main() raises: .flag() ) remote_add.help_on_no_arguments() + remote_add.set_run_function(handle_remote_add) remote.add_subcommand(remote_add^) var remote_remove = Command("remove", "Remove a remote") @@ -332,6 +765,7 @@ def main() raises: Argument("name", help="Remote name to remove").positional().required() ) remote_remove.help_on_no_arguments() + remote_remove.set_run_function(handle_remote_remove) remote.add_subcommand(remote_remove^) var remote_rename = Command("rename", "Rename a remote") @@ -342,6 +776,7 @@ def main() raises: Argument("new", help="New remote name").positional().required() ) remote_rename.help_on_no_arguments() + remote_rename.set_run_function(handle_remote_rename) remote.add_subcommand(remote_rename^) var remote_show = Command("show", "Show information about a remote") @@ -349,6 +784,7 @@ def main() raises: Argument("name", help="Remote name").positional().required() ) remote_show.help_on_no_arguments() + remote_show.set_run_function(handle_remote_show) remote.add_subcommand(remote_show^) remote.help_on_no_arguments() @@ -386,6 +822,7 @@ def main() raises: .short["m"]() .flag() ) + branch.set_run_function(handle_branch) app.add_subcommand(branch^) # ── diff ───────────────────────────────────────────────────────────── @@ -416,6 +853,7 @@ def main() raises: .short["U"]() .value_name["N"]() ) + diff.set_run_function(handle_diff) app.add_subcommand(diff^) # ── tag ────────────────────────────────────────────────────────────── @@ -450,34 +888,131 @@ def main() raises: .short["f"]() .flag() ) + tag.set_run_function(handle_tag) app.add_subcommand(tag^) - # ── stash ──────────────────────────────────────────────────────────── + # ── stash (with sub-subcommands) ───────────────────────────────────── var stash = Command("stash", "Stash changes in working directory") var stash_aliases: List[String] = ["st"] stash.command_aliases(stash_aliases^) - stash.add_argument( + + var stash_push = Command("push", "Save local modifications to a new stash") + stash_push.add_argument( Argument("stash-message", help="Stash message") .long["message"]() .short["m"]() ) - stash.add_argument( + stash_push.add_argument( Argument("keep-index", help="Keep staged changes in the index") .long["keep-index"]() .short["k"]() .flag() ) - stash.add_argument( + stash_push.add_argument( Argument("include-untracked", help="Also stash untracked files") .long["include-untracked"]() .short["u"]() .flag() ) + stash_push.set_run_function(handle_stash_push) + stash.add_subcommand(stash_push^) + + var stash_pop = Command("pop", "Apply and remove the top stash entry") + stash_pop.add_argument( + Argument("stash-index", help="Stash index to pop") + .positional() + .default["0"]() + ) + stash_pop.set_run_function(handle_stash_pop) + stash.add_subcommand(stash_pop^) + + var stash_list = Command("list", "List all stash entries") + stash_list.set_run_function(handle_stash_list) + stash.add_subcommand(stash_list^) + + var stash_drop = Command("drop", "Remove a single stash entry") + stash_drop.add_argument( + Argument("stash-index", help="Stash index to drop") + .positional() + .default["0"]() + ) + stash_drop.set_run_function(handle_stash_drop) + stash.add_subcommand(stash_drop^) + + var stash_apply = Command("apply", "Apply a stash without removing it") + stash_apply.add_argument( + Argument("stash-index", help="Stash index to apply") + .positional() + .default["0"]() + ) + stash_apply.set_run_function(handle_stash_apply) + stash.add_subcommand(stash_apply^) + + stash.help_on_no_arguments() app.add_subcommand(stash^) + # ── config (with sub-subcommands) ──────────────────────────────────── + var config = Command("config", "Get and set repository or global options") + + var config_get = Command("get", "Get a configuration value") + config_get.add_argument( + Argument("key", help="Configuration key (e.g. user.name)") + .positional() + .required() + ) + config_get.help_on_no_arguments() + config_get.set_run_function(handle_config_get) + config.add_subcommand(config_get^) + + var config_set = Command("set", "Set a configuration value") + config_set.add_argument( + Argument("key", help="Configuration key (e.g. user.name)") + .positional() + .required() + ) + config_set.add_argument( + Argument("value", help="Value to set").positional().required() + ) + config_set.add_argument( + Argument("global", help="Write to global config instead of local") + .long["global"]() + .flag() + ) + config_set.help_on_no_arguments() + config_set.set_run_function(handle_config_set) + config.add_subcommand(config_set^) + + var config_list = Command("list", "List all configuration entries") + config_list.set_run_function(handle_config_list) + config.add_subcommand(config_list^) + + var config_unset = Command("unset", "Remove a configuration entry") + config_unset.add_argument( + Argument("key", help="Configuration key to remove") + .positional() + .required() + ) + config_unset.add_argument( + Argument("global", help="Remove from global config instead of local") + .long["global"]() + .flag() + ) + config_unset.help_on_no_arguments() + config_unset.set_run_function(handle_config_unset) + config.add_subcommand(config_unset^) + + config.help_on_no_arguments() + app.add_subcommand(config^) + # ── Show help when invoked with no arguments ───────────────────────── app.help_on_no_arguments() - # ── Parse & display ────────────────────────────────────────────────── - var result = app.parse() - result.print_summary() + # ── Auto-dispatch ──────────────────────────────────────────────────── + # Replaces the old pattern: + # var result = app.parse() + # if result.subcommand == "clone": handle_clone(...) + # elif result.subcommand == "commit": handle_commit(...) + # ... + # With a single call that walks the command tree and invokes the + # matching handler automatically: + app.execute() diff --git a/pixi.toml b/pixi.toml index a5aa066..19b1245 100644 --- a/pixi.toml +++ b/pixi.toml @@ -41,6 +41,7 @@ test = """\ && mojo run -I src -D ASSERT=all tests/test_hybrid.mojo \ && mojo run -I src -D ASSERT=all tests/test_subcommands_declarative.mojo \ && mojo run -I src -D ASSERT=all tests/test_wrappers.mojo \ + && mojo run -I src -D ASSERT=all tests/test_dispatch.mojo \ && mojo run -I src -D ASSERT=all tests/test_schema_validation.mojo \ && bash tests/check_schema_errors.sh""" # NOTE: test_response_file.mojo is excluded — response file expansion @@ -64,24 +65,16 @@ t = """\ mojo run -I src -D ASSERT=all tests/test_hybrid.mojo & pids+=($!); \ mojo run -I src -D ASSERT=all tests/test_subcommands_declarative.mojo & pids+=($!); \ mojo run -I src -D ASSERT=all tests/test_wrappers.mojo & pids+=($!); \ + mojo run -I src -D ASSERT=all tests/test_dispatch.mojo & pids+=($!); \ mojo run -I src -D ASSERT=all tests/test_schema_validation.mojo & pids+=($!); rc=0; for p in "${pids[@]}"; do wait "$p" || rc=1; done; \ bash tests/check_schema_errors.sh || rc=1; exit $rc' """ -# build example binaries (parallel, fail if any build fails) -build = """pixi run package && bash -c '\ - pids=(); \ - mojo build -I src examples/mgrep.mojo -o mgrep & pids+=($!); \ - mojo build -I src examples/mgit.mojo -o mgit & pids+=($!); \ - mojo build -I src examples/demo.mojo -o demo & pids+=($!); \ - mojo build -I src examples/yu.mojo -o yu & pids+=($!); \ - mojo build -I src examples/declarative/search.mojo -o search & pids+=($!); \ - mojo build -I src examples/declarative/deploy.mojo -o deploy & pids+=($!); \ - mojo build -I src examples/declarative/convert.mojo -o convert & pids+=($!); \ - mojo build -I src examples/declarative/jomo.mojo -o jomo & pids+=($!); \ - rc=0; for p in "${pids[@]}"; do wait "$p" || rc=1; done; exit $rc' -""" +# build example binaries (with timing); pass a name to build one only +# Usage: pixi run build (all examples) +# pixi run build mgit (mgit only) +build = "bash examples/build.sh" # run (debug mode) example binaries # Uses --help so examples with required positional args exit cleanly. diff --git a/src/argmojo/command.mojo b/src/argmojo/command.mojo index 0d9b2f8..a8a8f65 100644 --- a/src/argmojo/command.mojo +++ b/src/argmojo/command.mojo @@ -275,6 +275,14 @@ struct Command(Copyable, Movable, Writable): option. Default False → ``--completions``. Call ``completions_as_subcommand()`` to switch to ``myapp completions bash``.""" + # === Private fields — Auto-dispatch === + var _run_function: Optional[def(ParseResult) raises] + """Optional run function for auto-dispatch. When set via + ``set_run_function()``, the ``execute()`` method will call this + function with the parsed result. For commands with subcommands, + ``execute()`` walks the subcommand chain and invokes the matching + handler.""" + # ===------------------------------------------------------------------=== # # Life cycle methods # ===------------------------------------------------------------------=== # @@ -331,6 +339,8 @@ struct Command(Copyable, Movable, Writable): self._completions_enabled = True self._completions_name = String("completions") self._completions_is_subcommand = False + # ── Auto-dispatch ── + self._run_function = None def __init__(out self, *, deinit take: Self): """Moves a Command, transferring ownership of all fields. @@ -381,6 +391,8 @@ struct Command(Copyable, Movable, Writable): self._completions_enabled = take._completions_enabled self._completions_name = take._completions_name^ self._completions_is_subcommand = take._completions_is_subcommand + # ── Auto-dispatch ── + self._run_function = take._run_function^ def __init__(out self, *, copy: Self): """Creates a deep copy of a Command. @@ -448,6 +460,8 @@ struct Command(Copyable, Movable, Writable): self._completions_enabled = copy._completions_enabled self._completions_name = copy._completions_name self._completions_is_subcommand = copy._completions_is_subcommand + # ── Auto-dispatch ── + self._run_function = copy._run_function # ===------------------------------------------------------------------=== # # Builder methods for configuring the command @@ -1874,6 +1888,175 @@ struct Command(Copyable, Movable, Writable): s += " [OPTIONS]" return s + # ===------------------------------------------------------------------=== # + # Auto-dispatch: set_run_function / execute + # ===------------------------------------------------------------------=== # + + def set_run_function(mut self, handler: def(ParseResult) raises): + """Registers a run function for auto-dispatch via ``execute()``. + + When ``execute()`` is called, the command tree is parsed and the + matching handler is invoked automatically — no manual ``if/elif`` + dispatch is needed. + + The handler receives the ``ParseResult`` for the command it is + attached to. For a root command without subcommands, that is the + full result; for a subcommand, it is the sub-result obtained via + ``get_subcommand_result()``. + + Args: + handler: A function ``def (ParseResult) raises`` to + call when this command is selected. + + Examples: + + ```mojo + from argmojo import Command, Argument, ParseResult + + def handle_build(result: ParseResult) raises: + print("Building target: " + result.get_string("target")) + + def main() raises: + var app = Command("app", "My CLI") + var build = Command("build", "Build the project") + build.add_argument( + Argument("target", help="Build target").positional() + ) + build.set_run_function(handle_build) + app.add_subcommand(build^) + app.execute() + ``` + """ + self._run_function = handler + + def execute(self) raises: + """Parses ``sys.argv()`` and auto-dispatches to the appropriate run + function. + + Walks the subcommand chain from root to leaf, finds the deepest + command with a matching subcommand, and invokes its ``_run_function`` + handler. If no handler is registered on the matched command, an + error is raised. + + This method combines ``parse()`` + dispatch in a single call, + replacing the common pattern:: + + var result = app.parse() + if result.subcommand == "build": + handle_build(result.get_subcommand_result()) + elif result.subcommand == "test": + handle_test(result.get_subcommand_result()) + + with simply:: + + app.execute() + + Raises: + Error if no run function is registered for the resolved command. + """ + var result = self.parse() + self._dispatch(result) + + def _execute_with_arguments(self, raw_args: List[String]) raises: + """Parses the given argument list and auto-dispatches to the + appropriate run function. + + This is a **testing helper** that accepts an explicit argument + list instead of reading from ``sys.argv()``. In tests you + cannot control the real command line, so pass the arguments + directly: + + var args: List[String] = ["app", "build", "mylib"] + app._execute_with_arguments(args) + + The leading underscore marks this method as internal. Production + code should call ``execute()``; only test code should call this. + + **Difference from ``execute()``:** ``execute()`` calls ``parse()`` + which catches parse errors and calls ``exit(2)``; this method + calls ``parse_arguments()`` which **raises** on parse errors, + making it suitable for tests that need to catch and inspect errors. + + Args: + raw_args: The raw argument strings (including program name at + index 0). + + Raises: + Error if parsing fails or no run function is registered for the + resolved command. + """ + var result = self.parse_arguments(raw_args) + self._dispatch(result) + + def _dispatch(self, result: ParseResult) raises: + """Walks the subcommand chain and invokes the matching run function. + + Internal auto-dispatch. Delegates to ``_dispatch_with_path`` with + the root command name as the initial path segment. + + Args: + result: The ParseResult from parsing. + + Raises: + Error if the resolved command has no run function registered. + """ + self._dispatch_with_path(result, self.name) + + def _dispatch_with_path( + self, result: ParseResult, command_path: String + ) raises: + """Walks the subcommand chain using a fully-qualified command path. + + Uses the accumulated ``command_path`` in error messages so that + nested failures include the full command path (e.g. + ``"app remote add"`` instead of just ``"add"``). + + When a command has subcommands but no registered handler and no + subcommand was selected, help is printed instead of raising — + matching Cobra's behaviour for grouping commands. + + Args: + result: The ParseResult from parsing. + command_path: The resolved command path up to ``self``. + + Raises: + Error if the resolved command has no run function registered. + """ + if result.subcommand != "": + # Find the matching subcommand and recurse. + for i in range(len(self.subcommands)): + if self.subcommands[i].name == result.subcommand: + var subcommand_path = result.subcommand + if command_path != "": + subcommand_path = command_path + " " + result.subcommand + if result.has_subcommand_result(): + self.subcommands[i]._dispatch_with_path( + result.get_subcommand_result(), subcommand_path + ) + else: + # Invariant: the parser always creates a child + # ParseResult when a subcommand is matched. + raise Error( + "Missing parse result for subcommand '" + + subcommand_path + + "'" + ) + return + var missing_path = result.subcommand + if command_path != "": + missing_path = command_path + " " + result.subcommand + raise Error("No matching subcommand for '" + missing_path + "'") + # No subcommand — execute this command's handler. + if self._run_function: + self._run_function.value()(result) + elif len(self.subcommands) > 0: + # Grouping command with no handler — show help (Cobra behaviour). + print(self._generate_help()) + else: + raise Error( + "No run function registered for command '" + command_path + "'" + ) + # ===------------------------------------------------------------------=== # # Public parse methods # ===------------------------------------------------------------------=== # diff --git a/tests/test_dispatch.mojo b/tests/test_dispatch.mojo new file mode 100644 index 0000000..96f1856 --- /dev/null +++ b/tests/test_dispatch.mojo @@ -0,0 +1,327 @@ +"""Tests for argmojo auto-dispatch (set_run_function / _execute_with_arguments). + +Tests: + • Root command dispatch (no subcommands). + • Subcommand dispatch (single level). + • Nested subcommand dispatch (multi-level). + • Error when no run function registered. + • set_run_function replaces previous handler. + • Persistent flags accessible in dispatched handler. + • Subcommand aliases with dispatch. +""" + +from std.testing import assert_true, assert_false, assert_equal, TestSuite +from argmojo import Argument, Command, ParseResult + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Handler functions +# ═══════════════════════════════════════════════════════════════════════════════ +# Since Mojo does not support global mutable vars or closures, handlers +# either succeed silently (happy path) or raise with a known marker that +# we catch and inspect. + + +def _handler_noop(result: ParseResult) raises: + """Handler that does nothing — used to verify dispatch reaches it.""" + pass + + +def _handler_assert_verbose(result: ParseResult) raises: + """Handler that asserts --verbose was set.""" + assert_true(result.get_flag("verbose"), "Expected verbose=True") + + +def _handler_assert_target(result: ParseResult) raises: + """Handler that asserts target positional.""" + assert_equal(result.get_string("target"), "mylib") + + +def _handler_assert_target_default(result: ParseResult) raises: + """Handler that asserts target default.""" + assert_equal(result.get_string("target"), "all") + + +def _handler_assert_target_and_verbose(result: ParseResult) raises: + """Handler that asserts both target positional and verbose flag.""" + assert_equal(result.get_string("target"), "mylib") + assert_true(result.get_flag("verbose"), "Expected verbose=True") + + +def _handler_assert_filter(result: ParseResult) raises: + """Handler that asserts filter option.""" + assert_equal(result.get_string("filter"), "unit_*") + + +def _handler_assert_remote_name(result: ParseResult) raises: + """Handler that asserts remote name positional.""" + assert_equal(result.get_string("name"), "origin") + + +def _handler_raise_marker(result: ParseResult) raises: + """Handler that raises a known marker error.""" + raise Error("MARKER:replaced") + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Root command dispatch +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_root_dispatch_no_subcommands() raises: + """Root command with set_run_function dispatches to its handler.""" + var app = Command("app", "Test app") + app.add_argument( + Argument("verbose", help="Verbose").long["verbose"]().flag() + ) + app.set_run_function(_handler_assert_verbose) + var args: List[String] = ["app", "--verbose"] + app._execute_with_arguments(args) # Asserts inside handler + + +def test_root_dispatch_noop() raises: + """Root command dispatch to a no-op handler succeeds.""" + var app = Command("app", "Test app") + app.set_run_function(_handler_noop) + var args: List[String] = ["app"] + app._execute_with_arguments(args) + + +def test_root_dispatch_with_result_values() raises: + """Handler receives correct parsed values.""" + var app = Command("build", "Build tool") + app.add_argument( + Argument("target", help="Target").positional().default["all"]() + ) + app.set_run_function(_handler_assert_target) + var args: List[String] = ["build", "mylib"] + app._execute_with_arguments(args) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Subcommand dispatch (single level) +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_subcommand_dispatch_build() raises: + """Subcommand dispatch routes to the correct handler.""" + var app = Command("app", "Test app") + + var build = Command("build", "Build") + build.add_argument( + Argument("target", help="Target").positional().default["all"]() + ) + build.set_run_function(_handler_assert_target) + app.add_subcommand(build^) + + var test_cmd = Command("test", "Test") + test_cmd.add_argument( + Argument("filter", help="Filter").long["filter"]().default["*"]() + ) + test_cmd.set_run_function(_handler_assert_filter) + app.add_subcommand(test_cmd^) + + var args: List[String] = ["app", "build", "mylib"] + app._execute_with_arguments(args) + + +def test_subcommand_dispatch_test() raises: + """Second subcommand dispatch works correctly.""" + var app = Command("app", "Test app") + + var build = Command("build", "Build") + build.add_argument( + Argument("target", help="Target").positional().default["all"]() + ) + build.set_run_function(_handler_assert_target) + app.add_subcommand(build^) + + var test_cmd = Command("test", "Test") + test_cmd.add_argument( + Argument("filter", help="Filter").long["filter"]().default["*"]() + ) + test_cmd.set_run_function(_handler_assert_filter) + app.add_subcommand(test_cmd^) + + var args: List[String] = ["app", "test", "--filter", "unit_*"] + app._execute_with_arguments(args) + + +def test_subcommand_default_values() raises: + """Subcommand handler receives default values when args are omitted.""" + var app = Command("app", "Test app") + + var build = Command("build", "Build") + build.add_argument( + Argument("target", help="Target").positional().default["all"]() + ) + build.set_run_function(_handler_assert_target_default) + app.add_subcommand(build^) + + var args: List[String] = ["app", "build"] + app._execute_with_arguments(args) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Nested subcommand dispatch (multi-level) +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_nested_subcommand_dispatch() raises: + """Nested subcommand (app remote add) dispatches to leaf handler.""" + var app = Command("app", "Test app") + + var remote = Command("remote", "Remote management") + remote.set_run_function(_handler_noop) + + var remote_add = Command("add", "Add a remote") + remote_add.add_argument( + Argument("name", help="Remote name").positional().required() + ) + remote_add.set_run_function(_handler_assert_remote_name) + remote.add_subcommand(remote_add^) + + app.add_subcommand(remote^) + + var args: List[String] = ["app", "remote", "add", "origin"] + app._execute_with_arguments(args) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Error cases +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_no_handler_raises_error() raises: + """Raises error when root has no run function.""" + var app = Command("app", "Test app") + + var caught = False + var args: List[String] = ["app"] + try: + app._execute_with_arguments(args) + except e: + caught = True + assert_true("No run function registered" in String(e)) + assert_true(caught, "Expected error for missing run function") + + +def test_no_handler_on_subcommand_raises_error() raises: + """Raises error when subcommand has no run function.""" + var app = Command("app", "Test app") + + var build = Command("build", "Build") + # Deliberately NOT calling set_run_function on build + app.add_subcommand(build^) + + var caught = False + var args: List[String] = ["app", "build"] + try: + app._execute_with_arguments(args) + except e: + caught = True + assert_true("No run function registered" in String(e)) + assert_true(caught, "Expected error for missing handler on subcommand") + + +def test_grouping_command_without_handler_shows_help() raises: + """Grouping command (has subcommands, no handler) shows help instead + of raising — matching Cobra behaviour.""" + var app = Command("app", "Test app") + + var remote = Command("remote", "Manage remotes") + + var remote_add = Command("add", "Add a remote") + remote_add.add_argument( + Argument("name", help="Remote name").positional().required() + ) + remote_add.set_run_function(_handler_noop) + remote.add_subcommand(remote_add^) + + app.add_subcommand(remote^) + + # Running "app remote" with no child subcommand should NOT raise. + # (remote is a grouping command — it shows help instead.) + var args: List[String] = ["app", "remote"] + app._execute_with_arguments(args) # Should succeed (shows help, no error) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# set_run_function replaces previous handler +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_set_run_replaces_handler() raises: + """Calling set_run_function a second time replaces the previous handler.""" + var app = Command("app", "Test app") + app.set_run_function(_handler_noop) + app.set_run_function(_handler_raise_marker) # Replace + + var caught = False + var args: List[String] = ["app"] + try: + app._execute_with_arguments(args) + except e: + caught = True + assert_true("MARKER:replaced" in String(e)) + assert_true(caught, "Expected replaced handler to execute") + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Persistent flags in dispatch +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_persistent_flags_in_subcommand_dispatch() raises: + """Persistent flags are available to dispatched subcommand handlers.""" + var app = Command("app", "Test app") + app.add_argument( + Argument("verbose", help="Verbose") + .long["verbose"]() + .short["v"]() + .flag() + .persistent() + ) + + var build = Command("build", "Build") + build.add_argument( + Argument("target", help="Target").positional().default["all"]() + ) + build.set_run_function(_handler_assert_target_and_verbose) + app.add_subcommand(build^) + + # --verbose before subcommand + var args: List[String] = ["app", "--verbose", "build", "mylib"] + app._execute_with_arguments(args) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Subcommand aliases with dispatch +# ═══════════════════════════════════════════════════════════════════════════════ + + +def test_subcommand_alias_dispatch() raises: + """Subcommand aliases are resolved correctly during dispatch.""" + var app = Command("app", "Test app") + + var build = Command("build", "Build") + build.add_argument( + Argument("target", help="Target").positional().default["all"]() + ) + build.set_run_function(_handler_assert_target) + var aliases: List[String] = ["b"] + build.command_aliases(aliases^) + app.add_subcommand(build^) + + # Use alias "b" + var args: List[String] = ["app", "b", "mylib"] + app._execute_with_arguments(args) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Entry point +# ═══════════════════════════════════════════════════════════════════════════════ + + +def main() raises: + TestSuite.discover_tests[__functions_in_module()]().run()