Skip to content

Commit 17d2176

Browse files
authored
[core] Add shell completion support + built-in completion (#4)
1 parent ba1613f commit 17d2176

File tree

10 files changed

+2019
-35
lines changed

10 files changed

+2019
-35
lines changed

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ A command-line argument parser library for [Mojo](https://www.modular.com/mojo),
99
[![pixi](https://img.shields.io/badge/pixi%20add-argmojo-brightgreen)](https://prefix.dev/channels/modular-community/packages/argmojo)
1010
[![User manual](https://img.shields.io/badge/user-manual-purple)](https://github.com/forfudan/argmojo/wiki)
1111

12+
<video src="https://raw.githubusercontent.com/forfudan/forfudan-github-data/main/argmojo/completions.mp4" controls width="800"></video>
13+
<p align="center"><em>Shell auto-completion of a mock <code>git</code> CLI built with ArgMojo</em></p>
14+
1215
<!--
1316
[![CI](https://img.shields.io/github/actions/workflow/status/forfudan/argmojo/run_tests.yaml?branch=main&label=tests)](https://github.com/forfudan/argmojo/actions/workflows/run_tests.yaml)
1417
[![License](https://img.shields.io/github/license/forfudan/argmojo)](LICENSE)
@@ -47,6 +50,7 @@ ArgMojo provides a builder-pattern API for defining and parsing command-line arg
4750
- **One-required groups**: require at least one argument from a group (e.g., must provide `--json` or `--yaml`)
4851
- **Value delimiter**: `--env dev,staging,prod` splits by delimiter into a list with `.delimiter(",")`
4952
- **Multi-value options (nargs)**: `--point 10 20` consumes N consecutive values with `.nargs(N)`
53+
- **Shell completion script generation**: `generate_completion("bash"|"zsh"|"fish")` produces a complete tab-completion script for your CLI
5054

5155
---
5256

@@ -142,7 +146,7 @@ ArgMojo ships with two complete example CLIs:
142146
| Example | File | Features |
143147
| ----------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
144148
| `grep` — simulated grep | `examples/grep.mojo` | Positional args, flags, count flags, negatable flags, choices, metavar, append/collect, value delimiter, nargs, mutually exclusive groups, required-together groups, conditional requirements, numeric range, key-value map, aliases, deprecated args, hidden args, negative-number passthrough, `--` stop marker, custom tips |
145-
| `git` — simulated git | `examples/git.mojo` | Subcommands (clone/init/add/commit/push/pull/log/remote/branch/diff/tag/stash), nested subcommands (remote add/remove/rename/show), persistent (global) flags, per-command args, mutually exclusive groups, choices, aliases, deprecated args, custom tips |
149+
| `git` — simulated git | `examples/git.mojo` | Subcommands (clone/init/add/commit/push/pull/log/remote/branch/diff/tag/stash), nested subcommands (remote add/remove/rename/show), persistent (global) flags, per-command args, mutually exclusive groups, choices, aliases, deprecated args, custom tips, shell completion script generation |
146150

147151
Build both example binaries:
148152

@@ -152,6 +156,8 @@ pixi run build
152156

153157
### `grep` (no subcommands)
154158

159+
![grep CLI demo](https://raw.githubusercontent.com/forfudan/forfudan-github-data/main/argmojo/grep.png)
160+
155161
```bash
156162
# Help and version
157163
./grep --help
@@ -175,6 +181,8 @@ pixi run build
175181

176182
### `git` (with subcommands)
177183

184+
![git clone subcommand](https://raw.githubusercontent.com/forfudan/forfudan-github-data/main/argmojo/git-clone.png)
185+
178186
```bash
179187
# Root help — shows Commands section + Global Options
180188
./git --help
@@ -195,6 +203,11 @@ pixi run build
195203
# Unknown subcommand → clear error
196204
./git foo
197205
# error: git: Unknown command 'foo'. Available commands: clone, init, ...
206+
207+
# Shell completion script generation
208+
./git --completions bash # bash completion script
209+
./git --completions zsh # zsh completion script
210+
./git --completions fish # fish completion script
198211
```
199212

200213
## Development

docs/argmojo_overall_planning.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,7 @@ Before adding Phase 5 features, further decompose `parse_args()` for readability
521521
- [x] **Typo suggestions**"Unknown option '--vrb', did you mean '--verbose'?" (Levenshtein distance; cobra, argparse 3.14)
522522
- [ ] **Flag counter with ceiling**`.count().max(3)` caps `-vvvvv` at 3 (no major library has this)
523523
- [x] **Colored error output** — ANSI styled error messages (help output already colored)
524+
- [x] **Shell completion script generation**`generate_completion("bash"|"zsh"|"fish")` returns a complete completion script; static approach (no runtime hook), covers options/flags/choices/subcommands (clap `generate`, cobra `completion`, click `shell_complete`)
524525
- [ ] **Argument groups in help** — group related options under headings (argparse add_argument_group)
525526
- [ ] **Usage line customisation** — two approaches: (1) manual override via `.usage("...")` for git-style hand-written usage strings (e.g. `[-v | --version] [-h | --help] [-C <path>] ...`); (2) auto-expanded mode that enumerates every flag inline like argparse (good for small CLIs, noisy for large ones). Current default `[OPTIONS]` / `<COMMAND>` is the cobra/clap/click convention and is the right default.
526527
- [ ] **Partial parsing** — parse known args only, return unknown args as-is (argparse `parse_known_args`)

docs/user_manual.md

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ from argmojo import Argument, Command
5757
- [Negative Number Passthrough](#negative-number-passthrough)
5858
- [Long Option Prefix Matching](#long-option-prefix-matching)
5959
- [The `--` Stop Marker](#the----stop-marker)
60+
- [Shell Completion](#shell-completion)
61+
- [Built-in `--completions` Flag](#built-in---completions-flag)
62+
- [Disabling the Built-in Flag](#disabling-the-built-in-flag)
63+
- [Customising the Trigger Name](#customising-the-trigger-name)
64+
- [Using a Subcommand Instead of an Option](#using-a-subcommand-instead-of-an-option)
65+
- [Generating a Script Programmatically](#generating-a-script-programmatically)
66+
- [Installing Completions](#installing-completions)
67+
- [What Gets Completed](#what-gets-completed)
6068

6169
## Getting Started
6270

@@ -2026,3 +2034,155 @@ myapp -- -10.18
20262034
```
20272035

20282036
> **Tip:** ArgMojo's [Auto-detect](#negative-number-passthrough) can handle most negative-number cases without `--`. Use `--` only when auto-detect is insufficient (e.g., a digit short option is registered without `allow_negative_numbers()`).
2037+
2038+
## Shell Completion
2039+
2040+
ArgMojo can generate **shell completion scripts** for Bash, Zsh, and Fish. These scripts enable tab-completion for your CLI's options, flags, subcommands, and choice values — with zero runtime overhead.
2041+
2042+
The generated scripts are **static**: they are produced once from your command tree and sourced by the user's shell. No runtime hook or callback mechanism is needed.
2043+
2044+
### Built-in `--completions` Flag
2045+
2046+
Every `Command` automatically responds to `--completions <shell>` — just like `--help` and `--version`. **No extra code is required.**
2047+
2048+
```mojo
2049+
var app = Command("myapp", "My application", version="1.0.0")
2050+
app.add_argument(Argument("verbose", help="Verbose output").long("verbose").short("v").flag())
2051+
app.add_argument(Argument("output", help="Output file").long("output").short("o").metavar("FILE"))
2052+
var formats: List[String] = ["json", "csv", "table"]
2053+
app.add_argument(Argument("format", help="Output format").long("format").choices(formats^))
2054+
2055+
var sub = Command("serve", "Start a server")
2056+
sub.add_argument(Argument("port", help="Port number").long("port").short("p"))
2057+
app.add_subcommand(sub^)
2058+
2059+
# parse() handles --completions automatically — no extra code needed
2060+
var result = app.parse()
2061+
```
2062+
2063+
Users run:
2064+
2065+
```bash
2066+
myapp --completions bash # prints Bash completion script and exits
2067+
myapp --completions zsh # prints Zsh completion script and exits
2068+
myapp --completions fish # prints Fish completion script and exits
2069+
```
2070+
2071+
The `--completions` option is shown in the help output alongside `--help` and `--version`:
2072+
2073+
```bash
2074+
Options:
2075+
--help Show this help message and exit
2076+
--version Show the version and exit
2077+
--completions {bash,zsh,fish}
2078+
Generate shell completion script
2079+
```
2080+
2081+
### Disabling the Built-in Flag
2082+
2083+
If you want to use `completions` as a regular argument name — or handle completion triggering entirely on your own — call `disable_default_completions()`:
2084+
2085+
```mojo
2086+
var app = Command("myapp", "My CLI")
2087+
app.disable_default_completions() # --completions is now an unknown option
2088+
```
2089+
2090+
`disable_default_completions()` removes `--completions` from the parse loop, help output, and all generated completion scripts. The `generate_completion()` method remains available for programmatic use.
2091+
2092+
### Customising the Trigger Name
2093+
2094+
By default the option is called `--completions`. Use `completions_name()` to rename it:
2095+
2096+
```mojo
2097+
var app = Command("myapp", "My CLI")
2098+
app.completions_name("autocomp") # → --autocomp bash/zsh/fish
2099+
```
2100+
2101+
Help output, parse loop, and generated scripts all reflect the new name.
2102+
2103+
### Using a Subcommand Instead of an Option
2104+
2105+
To expose completion generation as a subcommand rather than a `--` option, call `completions_as_subcommand()`:
2106+
2107+
```mojo
2108+
var app = Command("myapp", "My CLI")
2109+
app.completions_as_subcommand() # → myapp completions bash
2110+
```
2111+
2112+
The trigger moves from `Options:` to `Commands:` in help output. This can be combined with `completions_name()`:
2113+
2114+
```mojo
2115+
app.completions_name("comp")
2116+
app.completions_as_subcommand() # → myapp comp bash
2117+
```
2118+
2119+
### Generating a Script Programmatically
2120+
2121+
You can also call `generate_completion(shell)` directly to get a completion script as a `String`:
2122+
2123+
```mojo
2124+
# Generate for any supported shell
2125+
var script = app.generate_completion("bash") # or "zsh" or "fish"
2126+
print(script)
2127+
```
2128+
2129+
The `shell` argument is **case-insensitive** (`"Bash"`, `"BASH"`, `"bash"` all work). An error is raised for unrecognised shell names.
2130+
2131+
### Installing Completions
2132+
2133+
After generating a script, users `source` it or place it in a shell-specific directory.
2134+
2135+
---
2136+
2137+
**Bash:**
2138+
2139+
```bash
2140+
# One-shot (current session only)
2141+
eval "$(myapp --completions bash)"
2142+
2143+
# Persistent
2144+
myapp --completions bash > ~/.bash_completion.d/myapp
2145+
# Then add to ~/.bashrc: source ~/.bash_completion.d/myapp
2146+
```
2147+
2148+
---
2149+
2150+
**Zsh:**
2151+
2152+
```bash
2153+
# Place in your fpath (file must be named _myapp)
2154+
myapp --completions zsh > ~/.zsh/completions/_myapp
2155+
2156+
# Make sure ~/.zsh/completions is in fpath (add to ~/.zshrc):
2157+
# fpath=(~/.zsh/completions $fpath)
2158+
# autoload -Uz compinit && compinit
2159+
```
2160+
2161+
---
2162+
2163+
**Fish:**
2164+
2165+
```bash
2166+
# Fish auto-loads from this directory
2167+
myapp --completions fish > ~/.config/fish/completions/myapp.fish
2168+
```
2169+
2170+
### What Gets Completed
2171+
2172+
The generated scripts cover the full command tree:
2173+
2174+
| Element | Completed? | Notes |
2175+
| --------------------------------- | ---------------- | -------------------------------------------------------------------- |
2176+
| Long options (`--verbose`) | Yes | With description text from `help` |
2177+
| Short options (`-v`) | Yes | Paired with long option when both exist |
2178+
| Boolean flags | Yes | Marked as no-argument (no file/value completion after the flag) |
2179+
| Count flags (`-vvv`) | Yes | Treated like boolean flags (no value expected) |
2180+
| Choices (`--format json`) | Yes | Tab-completes the allowed values (`json`, `csv`, `table`) |
2181+
| Subcommands | Yes | Listed with descriptions; scoped completions for each subcommand |
2182+
| Built-in `--help` / `--version` | Yes | Automatically included |
2183+
| Built-in `--completions {bash,…}` | Yes | Automatically included; disable with `disable_default_completions()` |
2184+
| Hidden arguments | No (intentional) | `.hidden()` arguments are excluded from completion |
2185+
| Positional arguments | No (by design) | Positionals use default shell completion (file paths, etc.) |
2186+
| Persistent (global) flags | Yes (root level) | Inherited flags appear in the root command's completions |
2187+
2188+
> **Note:** Negatable flags (`--color` / `--no-color`) — the `--no-X` form is **not** separately listed in completions. The base `--color` flag is completed; users type `--no-` manually. This matches the behaviour of other CLI frameworks.

examples/git.mojo

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ required arguments, metavar, hidden arguments, append/collect, value
99
delimiter, mutually exclusive groups, required-together groups, conditional
1010
requirements, numeric range validation, aliases, deprecated arguments,
1111
Commands section in help, Global Options heading, full command path in
12-
child help/errors, unknown subcommand error, and custom tips.
12+
child help/errors, unknown subcommand error, custom tips, and shell
13+
completion script generation.
1314
1415
Try these:
1516
git --help # root help (Commands + Global Options)
@@ -20,6 +21,7 @@ Try these:
2021
git log --oneline -n 20 --author "Alice"
2122
git remote add origin https://example.com/repo.git
2223
git -v push origin main --force --tags
24+
git --completions bash # shell completion script (built-in)
2325
"""
2426

2527
from argmojo import Argument, Command

pixi.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ test = """\
3535
&& mojo run -I src tests/test_subcommands.mojo \
3636
&& mojo run -I src tests/test_negative_numbers.mojo \
3737
&& mojo run -I src tests/test_persistent.mojo \
38-
&& mojo run -I src tests/test_typo_suggestions.mojo"""
38+
&& mojo run -I src tests/test_typo_suggestions.mojo \
39+
&& mojo run -I src tests/test_completion.mojo"""
3940

4041
# build example binaries
4142
build = """pixi run package \

src/argmojo/argument.mojo

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33

44
comptime Arg = Argument
5-
"""Shorthand alias for `Argument`."""
5+
"""Shorthand alias for ``Argument``."""
66

77

88
struct Argument(Copyable, Movable, Stringable, Writable):
@@ -321,8 +321,8 @@ struct Argument(Copyable, Movable, Stringable, Writable):
321321
fn metavar(var self, name: String) -> Self:
322322
"""Sets the display name for the value in help text.
323323
324-
For example, `.metavar("FILE")` causes help to show `--output FILE`
325-
instead of `--output OUTPUT`.
324+
For example, ``.metavar("FILE")`` causes help to show ``--output FILE``
325+
instead of ``--output OUTPUT``.
326326
327327
Args:
328328
name: The display name.
@@ -345,8 +345,8 @@ struct Argument(Copyable, Movable, Stringable, Writable):
345345
fn count(var self) -> Self:
346346
"""Marks this argument as a counter flag.
347347
348-
Each occurrence increments a counter. For example, `-vvv` sets
349-
the count to 3. Use `get_count()` on ParseResult to retrieve.
348+
Each occurrence increments a counter. For example, ``-vvv`` sets
349+
the count to 3. Use ``get_count()`` on ParseResult to retrieve.
350350
351351
Returns:
352352
Self marked as a counter.
@@ -358,9 +358,9 @@ struct Argument(Copyable, Movable, Stringable, Writable):
358358
fn negatable(var self) -> Self:
359359
"""Marks this flag as negatable.
360360
361-
A negatable flag accepts both `--X` (sets True) and `--no-X`
362-
(sets False). For example, `.long("color").flag().negatable()`
363-
accepts `--color` and `--no-color`.
361+
A negatable flag accepts both ``--X`` (sets True) and ``--no-X``
362+
(sets False). For example, ``.long("color").flag().negatable()``
363+
accepts ``--color`` and ``--no-color``.
364364
365365
Returns:
366366
Self marked as negatable.
@@ -372,7 +372,7 @@ struct Argument(Copyable, Movable, Stringable, Writable):
372372
"""Marks this argument as an append/collect option.
373373
374374
Each occurrence adds its value to a list. For example,
375-
`--tag x --tag y` collects `["x", "y"]`. Use `get_list()`
375+
``--tag x --tag y`` collects ``["x", "y"]``. Use ``get_list()``
376376
on ParseResult to retrieve the collected values.
377377
378378
Returns:
@@ -390,9 +390,9 @@ struct Argument(Copyable, Movable, Stringable, Writable):
390390
"""Sets a value delimiter for splitting a single value into multiple.
391391
392392
When set, each provided value is split by the delimiter, and each
393-
piece is added to the list individually. Implies `.append()`.
394-
For example, `.delimiter(",")` causes `--tag a,b,c` to produce
395-
`["a", "b", "c"]`.
393+
piece is added to the list individually. Implies ``.append()``.
394+
For example, ``.delimiter(",")`` causes ``--tag a,b,c`` to produce
395+
``["a", "b", "c"]``.
396396
397397
Args:
398398
sep: The delimiter string (e.g., ",").

0 commit comments

Comments
 (0)