Shell tab-completion for bash and zsh (#2096)

* initial attempt

* fix bash

* fix zsh

* Add shell-completion docs page

Documents the new 'mlr completion {bash,zsh}' feature: the then-chain
context model, install instructions for bash and zsh (including the macOS
bash-3.2 'eval' caveat and zsh compinit self-init), and examples of
context-aware completion. Added to the nav under "Miller in more detail".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add enum value completion for format and separator flags

Completes the argument value for arg-taking main flags whose values are a
known set: file-format names for -i/-o/--io, separator aliases for
--ifs/--ofs/--ips/etc., and regex-separator aliases for --ifs-regex/--ips-regex.
Other arg-taking flags continue to fall back to filename completion.

Candidate sets come from new cli getters (GetFileFormatNames,
GetSeparatorAliasNames, GetSeparatorRegexAliasNames) that read the same maps
Miller uses at runtime, so there is no separate list to keep in sync. The
command-line walk now records which flag a value position belongs to.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Include format-conversion keystroke-savers in bare-dash completion

Reverts the suppression of --c2j/--x2y-style flags from 'mlr -<TAB>'. The full
set of main flags (297) is now offered, matching what is valid on the command
line. GetFlagNames no longer takes an includeSuppressed argument.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Complete terminal subcommands and top-level help/version flags

'mlr <TAB>' now offers subcommand names (help, version, repl, regtest, script,
completion, terminal-list) alongside verb names, and 'mlr -<TAB>' offers the
top-level terminal flags (-h, --help, --version, --bare-version, and the help
shorthands -g/-l/-L/-f/-F/-k/-K). Subcommand names are offered only as the
first non-flag token, where they are valid.

To let the completion engine know these names without an import cycle
(pkg/terminals imports pkg/terminals/completion), the canonical terminal names
and version-flag spellings are factored into a new leaf package
pkg/terminals/registry, imported by pkg/terminals, pkg/climain, and completion.
The help-flag spellings come from a new help.GetTerminalFlagNames derived from
the existing shorthand table, so nothing drifts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Complete 'mlr help' topics and topic arguments

'mlr help <TAB>' now completes help topics (flags, verb, function, keyword,
list-verbs, ...), and topics that take a name argument complete it too:
'mlr help verb <TAB>' -> verb names, 'mlr help function <TAB>' -> function
names, 'mlr help keyword <TAB>' -> keyword names, 'mlr help flag <TAB>' ->
flag names. 'mlr completion <TAB>' completes bash/zsh.

A terminal subcommand consumes the rest of the command line, so the walk now
returns a ctxTerminalArgs context carrying the terminal name and the words
typed after it. New getters supply the candidate names without drift:
help.GetTopicNames, help.GetFunctionNames/GetKeywordNames (wrapping new
cst.BuiltinFunctionManager.GetBuiltinFunctionNames and cst.GetKeywordNames).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs-neaten

* Move flag-value-candidate logic into pkg/cli; fix verb-flag collision

The mapping of which flags take a format/separator/regex-separator argument is
flag metadata, so it now lives with the flags in pkg/cli as
cli.FlagValueCandidates, alongside the existing GetFileFormatNames /
GetSeparatorAliasNames getters, replacing the maps that were in
pkg/terminals/completion/value_completion.go (now removed).

This also fixes a bug: value completion now applies only to main flags, not to
identically-spelled verb flags. Previously 'mlr uniq -o <TAB>' offered file
formats because uniq's -o (an output field name) collided with the main -o
format flag; it now correctly falls back to filename completion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
John Kerl 2026-06-21 12:47:54 -04:00 committed by GitHub
parent 690ce997eb
commit 299636038c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2040 additions and 10 deletions

View file

@ -37,6 +37,7 @@ nav:
- "Internationalization": "internationalization.md"
- "Output colorization": "output-colorization.md"
- "Customization: .mlrrc": "customization.md"
- "Shell completion": "shell-completion.md"
- "Syntax highlighting: vimrc": "vimrc.md"
- "The REPL": "repl.md"
- "Online help": "online-help.md"

View file

@ -0,0 +1,198 @@
<!--- PLEASE DO NOT EDIT DIRECTLY. EDIT THE .md.in FILE PLEASE. --->
<div>
<span class="quicklinks">
Quick links:
&nbsp;
<a class="quicklink" href="../reference-main-flag-list/index.html">Flags</a>
&nbsp;
<a class="quicklink" href="../reference-verbs/index.html">Verbs</a>
&nbsp;
<a class="quicklink" href="../reference-dsl-builtin-functions/index.html">Functions</a>
&nbsp;
<a class="quicklink" href="../glossary/index.html">Glossary</a>
&nbsp;
<a class="quicklink" href="../release-docs/index.html">Release docs</a>
</span>
</div>
# Shell completion
Miller can generate tab-completion scripts for `bash` and `zsh`. Once installed,
pressing <b>TAB</b> completes Miller's main flags, verb names, subcommands like
`help` and `version`, each verb's own flags, the `then` keyword, and filenames
-- and it does so in a way that understands Miller's
[then-chains](reference-main-then-chaining.md).
## Why this is more than the usual flag completion
Most command-line tools have a single set of flags: `prog --flag1 --flag2 file`.
Miller's command line, by contrast, is instead a [sequence of contexts](#reference-main-overview.md):
<pre class="pre-non-highlight-non-pair">
mlr {main flags} verb1 {verb1 flags} then verb2 {verb2 flags} {filenames}
</pre>
So the same word can mean different things depending on where it sits. Miller's
completion walks the command line left-to-right and offers candidates
appropriate to the cursor's position:
* Before the first verb: main flags (e.g. `--icsv`), verb names (e.g. `cat`),
and subcommands (e.g. `help`, `version`, `repl`).
* Inside a verb: that verb's own flags, plus `then` and filenames.
* Right after `then`: verb names.
* As the argument to a flag that takes one (e.g. `mlr --ifs`): the flag's
values where these are a known set, otherwise filenames.
## Installing for bash
Add this to your `~/.bashrc`:
<pre class="pre-non-highlight-non-pair">
eval "$(mlr completion bash)"
</pre>
Or install it system-wide (loaded by the `bash-completion` package):
<pre class="pre-non-highlight-non-pair">
mlr completion bash > /etc/bash_completion.d/mlr
</pre>
Prefer `eval "$(mlr completion bash)"` over `source <(mlr completion bash)`. The
latter silently does nothing on the bash 3.2 that ships with macOS, where
sourcing from a process-substitution file descriptor can read no data.
## Installing for zsh
Add this to your `~/.zshrc`:
<pre class="pre-non-highlight-non-pair">
eval "$(mlr completion zsh)"
</pre>
Or place the script on your `$fpath` so zsh autoloads it:
<pre class="pre-non-highlight-non-pair">
mlr completion zsh > "${fpath[1]}/_mlr"
</pre>
The generated script initializes zsh's completion system (`compinit`) if your
startup files have not already done so, so it works even with a minimal
`~/.zshrc`.
## What completion looks like
Before the first verb, <b>TAB</b> offers verb names along with subcommands like
`help` and `version`:
<pre class="pre-non-highlight-non-pair">
mlr <b>TAB</b>
altkv cat completion count ... help version
</pre>
The top-level help and version flags are offered too:
<pre class="pre-non-highlight-non-pair">
mlr --v<b>TAB</b>
--value-color --version --vflatsep
</pre>
The `help` subcommand completes its topics, and topics that take a name
argument complete that too:
<pre class="pre-non-highlight-non-pair">
mlr help <b>TAB</b>
flags list-verbs verb function keyword ...
mlr help verb <b>TAB</b>
altkv bar cat count cut ...
mlr help function strl<b>TAB</b>
strlen
</pre>
A leading dash offers main flags, including the format-conversion
keystroke-savers (`--c2j`, `--x2y`, and so on):
<pre class="pre-non-highlight-non-pair">
mlr --c<b>TAB</b>
--c2b --c2c --c2d --c2j --c2p ... --csv --csvlite
</pre>
Inside a verb, <b>TAB</b> offers that verb's flags:
<pre class="pre-non-highlight-non-pair">
mlr cat -<b>TAB</b>
-n -N -g --filename --filenum
</pre>
After `then`, <b>TAB</b> offers verb names again:
<pre class="pre-non-highlight-non-pair">
mlr --icsv cat -n then head -n 10 then <b>TAB</b>
altkv cat count cut ...
</pre>
For flags whose argument is a known set of values, <b>TAB</b> offers those
values. Format flags (`-i`, `-o`, `--io`) offer file-format names:
<pre class="pre-non-highlight-non-pair">
mlr -i <b>TAB</b>
csv csvlite dcf dkvp dkvpx gen json markdown nidx pprint tsv xtab yaml
</pre>
and separator flags (`--ifs`, `--ofs`, `--ips`, and so on) offer the named
separator aliases:
<pre class="pre-non-highlight-non-pair">
mlr --ifs <b>TAB</b>
comma pipe semicolon space tab ...
</pre>
## Generating the scripts
The `mlr completion` command prints the scripts, and `mlr completion --help`
describes the options:
<pre class="pre-highlight-in-pair">
<b>mlr completion --help</b>
</pre>
<pre class="pre-non-highlight-in-pair">
Usage: mlr completion {bash|zsh}
Generates a shell tab-completion script for Miller.
Bash:
Add to your ~/.bashrc:
eval "$(mlr completion bash)"
Or install system-wide:
mlr completion bash > /etc/bash_completion.d/mlr
Note: prefer 'eval' over 'source <(mlr completion bash)'. The latter
silently fails on the bash 3.2 that ships with macOS, where sourcing from a
process-substitution FIFO can read nothing.
Zsh:
Add to your ~/.zshrc:
eval "$(mlr completion zsh)"
Or place the output on your $fpath, e.g.:
mlr completion zsh > "${fpath[1]}/_mlr"
The script initializes zsh's completion system (compinit) if your startup
files have not done so already.
Completion is context-aware across Miller's then-chains: it offers main flags
and verb names before the first verb, the current verb's flags inside a verb,
verb names after 'then', and filenames where appropriate.
</pre>
## Notes
* Completion candidates are produced by Miller itself: the shell scripts simply
forward the current command-line words to `mlr` and render what it returns.
This means completion stays in sync with Miller's flags and verbs
automatically -- there is no separate list to maintain.
* Value completion is offered for flags with a known set of values (file
formats for `-i`/`-o`/`--io`, separator aliases for `--ifs` and friends).
Other arg-taking flags fall back to filename completion; per-verb argument
values (such as field names) are not yet completed.
* The `mlr completion complete ...` subcommand is an internal interface used by
the generated scripts; it is not intended to be run directly.

View file

@ -0,0 +1,157 @@
# Shell completion
Miller can generate tab-completion scripts for `bash` and `zsh`. Once installed,
pressing <b>TAB</b> completes Miller's main flags, verb names, subcommands like
`help` and `version`, each verb's own flags, the `then` keyword, and filenames
-- and it does so in a way that understands Miller's
[then-chains](reference-main-then-chaining.md).
## Why this is more than the usual flag completion
Most command-line tools have a single set of flags: `prog --flag1 --flag2 file`.
Miller's command line, by contrast, is instead a [sequence of contexts](#reference-main-overview.md):
GENMD-CARDIFY
mlr {main flags} verb1 {verb1 flags} then verb2 {verb2 flags} {filenames}
GENMD-EOF
So the same word can mean different things depending on where it sits. Miller's
completion walks the command line left-to-right and offers candidates
appropriate to the cursor's position:
* Before the first verb: main flags (e.g. `--icsv`), verb names (e.g. `cat`),
and subcommands (e.g. `help`, `version`, `repl`).
* Inside a verb: that verb's own flags, plus `then` and filenames.
* Right after `then`: verb names.
* As the argument to a flag that takes one (e.g. `mlr --ifs`): the flag's
values where these are a known set, otherwise filenames.
## Installing for bash
Add this to your `~/.bashrc`:
GENMD-CARDIFY
eval "$(mlr completion bash)"
GENMD-EOF
Or install it system-wide (loaded by the `bash-completion` package):
GENMD-CARDIFY
mlr completion bash > /etc/bash_completion.d/mlr
GENMD-EOF
Prefer `eval "$(mlr completion bash)"` over `source <(mlr completion bash)`. The
latter silently does nothing on the bash 3.2 that ships with macOS, where
sourcing from a process-substitution file descriptor can read no data.
## Installing for zsh
Add this to your `~/.zshrc`:
GENMD-CARDIFY
eval "$(mlr completion zsh)"
GENMD-EOF
Or place the script on your `$fpath` so zsh autoloads it:
GENMD-CARDIFY
mlr completion zsh > "${fpath[1]}/_mlr"
GENMD-EOF
The generated script initializes zsh's completion system (`compinit`) if your
startup files have not already done so, so it works even with a minimal
`~/.zshrc`.
## What completion looks like
Before the first verb, <b>TAB</b> offers verb names along with subcommands like
`help` and `version`:
GENMD-CARDIFY
mlr <b>TAB</b>
altkv cat completion count ... help version
GENMD-EOF
The top-level help and version flags are offered too:
GENMD-CARDIFY
mlr --v<b>TAB</b>
--value-color --version --vflatsep
GENMD-EOF
The `help` subcommand completes its topics, and topics that take a name
argument complete that too:
GENMD-CARDIFY
mlr help <b>TAB</b>
flags list-verbs verb function keyword ...
mlr help verb <b>TAB</b>
altkv bar cat count cut ...
mlr help function strl<b>TAB</b>
strlen
GENMD-EOF
A leading dash offers main flags, including the format-conversion
keystroke-savers (`--c2j`, `--x2y`, and so on):
GENMD-CARDIFY
mlr --c<b>TAB</b>
--c2b --c2c --c2d --c2j --c2p ... --csv --csvlite
GENMD-EOF
Inside a verb, <b>TAB</b> offers that verb's flags:
GENMD-CARDIFY
mlr cat -<b>TAB</b>
-n -N -g --filename --filenum
GENMD-EOF
After `then`, <b>TAB</b> offers verb names again:
GENMD-CARDIFY
mlr --icsv cat -n then head -n 10 then <b>TAB</b>
altkv cat count cut ...
GENMD-EOF
For flags whose argument is a known set of values, <b>TAB</b> offers those
values. Format flags (`-i`, `-o`, `--io`) offer file-format names:
GENMD-CARDIFY
mlr -i <b>TAB</b>
csv csvlite dcf dkvp dkvpx gen json markdown nidx pprint tsv xtab yaml
GENMD-EOF
and separator flags (`--ifs`, `--ofs`, `--ips`, and so on) offer the named
separator aliases:
GENMD-CARDIFY
mlr --ifs <b>TAB</b>
comma pipe semicolon space tab ...
GENMD-EOF
## Generating the scripts
The `mlr completion` command prints the scripts, and `mlr completion --help`
describes the options:
GENMD-RUN-COMMAND
mlr completion --help
GENMD-EOF
## Notes
* Completion candidates are produced by Miller itself: the shell scripts simply
forward the current command-line words to `mlr` and render what it returns.
This means completion stays in sync with Miller's flags and verbs
automatically -- there is no separate list to maintain.
* Value completion is offered for flags with a known set of values (file
formats for `-i`/`-o`/`--io`, separator aliases for `--ifs` and friends).
Other arg-taking flags fall back to filename completion; per-verb argument
values (such as field names) are not yet completed.
* The `mlr completion complete ...` subcommand is an internal interface used by
the generated scripts; it is not intended to be run directly.

View file

@ -520,3 +520,98 @@ func (flag *Flag) NilCheck() {
func NoOpParse1(args []string, argc int, pargi *int, options *TOptions) {
*pargi += 1
}
// Introspection accessors for shell-completion support
// (pkg/terminals/completion). These expose flag names and arity without
// invoking the parser functions, which is important since some flag parsers
// os.Exit on missing arguments.
// TakesArg reports whether the flag consumes a following argument value, e.g.
// `--ifs {string}` takes an argument whereas `--repifs` does not.
func (flag *Flag) TakesArg() bool {
return flag.arg != ""
}
// GetFlagNames returns every spelling of every main flag (primary names and
// alternate names), de-duplicated, for use as shell-completion candidates.
// This includes the format-conversion keystroke-savers (e.g. --c2j, --x2y)
// even though they are suppressed from `mlr help flags` enumeration.
func (ft *FlagTable) GetFlagNames() []string {
names := make([]string, 0)
seen := make(map[string]bool)
add := func(name string) {
if name != "" && !seen[name] {
seen[name] = true
names = append(names, name)
}
}
for _, section := range ft.sections {
for _, flag := range section.flags {
add(flag.name)
for _, altName := range flag.altNames {
add(altName)
}
}
}
return names
}
// FlagTakesArg looks up a main flag by any of its spellings. The first return
// value reports whether the flag is a recognized main flag; the second reports
// whether it consumes a following argument value. This drives the
// shell-completion command-line walk without calling the (possibly
// os.Exit-ing) parser functions.
func (ft *FlagTable) FlagTakesArg(name string) (found bool, takesArg bool) {
for _, section := range ft.sections {
for _, flag := range section.flags {
if flag.Owns(name) {
return true, flag.TakesArg()
}
}
}
return false, false
}
// Main flags whose argument is a known, enumerable set of values. These drive
// shell-completion value completion (pkg/terminals/completion); they apply only
// to main flags, not to identically-spelled verb flags (e.g. `mlr uniq -o`
// takes a field name, unrelated to the main `-o` format flag).
var (
formatValueFlags = map[string]bool{
"-i": true,
"-o": true,
"--io": true,
}
separatorValueFlags = map[string]bool{
"--fs": true,
"--ifs": true,
"--ofs": true,
"--ps": true,
"--ips": true,
"--ops": true,
"--rs": true,
"--irs": true,
"--ors": true,
}
separatorRegexValueFlags = map[string]bool{
"--ifs-regex": true,
"--ips-regex": true,
}
)
// FlagValueCandidates returns the enumerated values that the given main flag's
// argument may take -- file-format names for -i/-o/--io, separator aliases for
// --ifs and friends, and regex-separator aliases for --ifs-regex/--ips-regex.
// It returns nil if the flag's argument is not a known enumerable set (in which
// case shell completion falls back to filenames).
func FlagValueCandidates(flagName string) []string {
switch {
case formatValueFlags[flagName]:
return GetFileFormatNames()
case separatorValueFlags[flagName]:
return GetSeparatorAliasNames()
case separatorRegexValueFlags[flagName]:
return GetSeparatorRegexAliasNames()
}
return nil
}

View file

@ -0,0 +1,35 @@
package cli
import (
"slices"
"testing"
)
func TestFlagValueCandidates(t *testing.T) {
// Format flags offer file-format names.
for _, flag := range []string{"-i", "-o", "--io"} {
got := FlagValueCandidates(flag)
if !slices.Contains(got, "csv") || !slices.Contains(got, "json") {
t.Errorf("%s: expected file-format names, got %v", flag, got)
}
}
// Separator flags offer separator aliases.
got := FlagValueCandidates("--ifs")
if !slices.Contains(got, "comma") || !slices.Contains(got, "pipe") {
t.Errorf("--ifs: expected separator aliases, got %v", got)
}
// Regex-separator flags offer regex aliases.
got = FlagValueCandidates("--ifs-regex")
if !slices.Contains(got, "whitespace") {
t.Errorf("--ifs-regex: expected regex aliases, got %v", got)
}
// Flags without an enumerable value set return nil.
for _, flag := range []string{"--ofmt", "--from", "-n", "--not-a-flag"} {
if got := FlagValueCandidates(flag); got != nil {
t.Errorf("%s: expected nil, got %v", flag, got)
}
}
}

View file

@ -1,5 +1,7 @@
package cli
import "github.com/johnkerl/miller/v6/pkg/lib"
const COLON = ":"
const COMMA = ","
const CR = "\\r"
@ -97,6 +99,27 @@ var defaultFSes = map[string]string{
"xtab": "\n", // todo: windows-dependent ...
}
// GetFileFormatNames returns the canonical file-format names (csv, json, tsv,
// ...), sorted, for shell-completion of the -i/-o/--io flags. These are the
// keys of defaultFSes, which is the same set the reader/writer factories
// dispatch on.
func GetFileFormatNames() []string {
return lib.GetArrayKeysSorted(defaultFSes)
}
// GetSeparatorAliasNames returns the named separator aliases (comma, tab,
// pipe, ...), sorted, for shell-completion of separator flags like --ifs.
func GetSeparatorAliasNames() []string {
return lib.GetArrayKeysSorted(SEPARATOR_NAMES_TO_VALUES)
}
// GetSeparatorRegexAliasNames returns the named regex separator aliases
// (spaces, tabs, whitespace), sorted, for shell-completion of the
// --ifs-regex/--ips-regex flags.
func GetSeparatorRegexAliasNames() []string {
return lib.GetArrayKeysSorted(SEPARATOR_REGEX_NAMES_TO_VALUES)
}
var defaultPSes = map[string]string{
"gen": "N/A",
"csv": "N/A",

View file

@ -78,6 +78,7 @@ import (
"github.com/johnkerl/miller/v6/pkg/mlrval"
"github.com/johnkerl/miller/v6/pkg/terminals"
"github.com/johnkerl/miller/v6/pkg/terminals/help"
"github.com/johnkerl/miller/v6/pkg/terminals/registry"
"github.com/johnkerl/miller/v6/pkg/transformers"
"github.com/johnkerl/miller/v6/pkg/version"
)
@ -145,11 +146,11 @@ func parseCommandLinePassOne(
oargi := argi
if args[argi][0] == '-' {
if args[argi] == "--version" {
if args[argi] == registry.VersionFlag {
// Exiting flag: handle it immediately.
fmt.Printf("mlr %s\n", version.STRING)
os.Exit(0)
} else if args[argi] == "--bare-version" {
} else if args[argi] == registry.BareVersionFlag {
// Exiting flag: handle it immediately.
fmt.Printf("%s\n", version.STRING)
os.Exit(0)

View file

@ -2628,11 +2628,17 @@ func (mgr *BuiltinFunctionManager) ListBuiltinFunctionNamesVertically() {
}
func (mgr *BuiltinFunctionManager) ListBuiltinFunctionNamesAsParagraph() {
lib.PrintWordsAsParagraph(mgr.GetBuiltinFunctionNames())
}
// GetBuiltinFunctionNames returns all built-in DSL function names, e.g. for
// shell-completion of `mlr help function {name}`.
func (mgr *BuiltinFunctionManager) GetBuiltinFunctionNames() []string {
functionNames := make([]string, len(*mgr.lookupTable))
for i, builtinFunctionInfo := range *mgr.lookupTable {
functionNames[i] = builtinFunctionInfo.name
}
lib.PrintWordsAsParagraph(functionNames)
return functionNames
}
func (mgr *BuiltinFunctionManager) ListBuiltinFunctionsAsTable() {

View file

@ -120,11 +120,17 @@ func ListKeywordsVertically() {
}
func ListKeywordsAsParagraph() {
lib.PrintWordsAsParagraph(GetKeywordNames())
}
// GetKeywordNames returns all DSL keyword names, e.g. for shell-completion of
// `mlr help keyword {name}`.
func GetKeywordNames() []string {
keywords := make([]string, len(KEYWORD_USAGE_TABLE))
for i, entry := range KEYWORD_USAGE_TABLE {
keywords[i] = entry.name
}
lib.PrintWordsAsParagraph(keywords)
return keywords
}
func allKeywordUsage() {

View file

@ -0,0 +1,312 @@
// Package completion implements shell tab-completion for Miller.
//
// The hard part of completing Miller's command line is that, unlike most CLIs
// which have a single flag set, a Miller command line is a sequence of
// contexts:
//
// mlr [main flags] verb1 [verb1 flags] then verb2 [verb2 flags] ... [files]
//
// So completing the word under the cursor requires knowing *which* context the
// cursor is in. We determine that with a tolerant left-to-right walk of the
// words before the cursor (Layer A), then generate candidates appropriate to
// that context (Layer B).
//
// The walk needs to know, for each flag, whether it consumes a following
// argument value -- otherwise it can't tell a flag's value apart from a verb
// name, a `then`, or a filename. Main-flag arity comes exactly from the
// flag-table (see cli.FlagTable.FlagTakesArg). Per-verb flag names and arity
// are scraped from each verb's usage text (see verbflags.go), with a small
// override table for the few verbs whose usage text doesn't follow the
// `-f {arg}` convention.
//
// We deliberately do NOT drive the real verb parsers to do the walk: several of
// them call os.Exit on incomplete or unrecognized input (e.g. `mlr subs -f x`
// with no replacement text yet), which would kill the completion subprocess on
// very common mid-typing command lines. The tolerant walk never exits and
// degrades gracefully instead.
package completion
import (
"sort"
"strings"
"github.com/johnkerl/miller/v6/pkg/cli"
"github.com/johnkerl/miller/v6/pkg/terminals/help"
"github.com/johnkerl/miller/v6/pkg/terminals/registry"
"github.com/johnkerl/miller/v6/pkg/transformers"
)
// Directive tells the shell shim how to use the candidate list.
type Directive string
const (
// DirectiveCandidates: offer exactly the candidate words, nothing else.
DirectiveCandidates Directive = "candidates"
// DirectiveFiles: ignore the candidate words; do filename completion.
DirectiveFiles Directive = "files"
// DirectiveDefault: offer the candidate words AND do filename completion.
DirectiveDefault Directive = "default"
)
// Result is what Complete returns: a directive plus a prefix-filtered list of
// candidate words.
type Result struct {
Directive Directive
Candidates []string
}
// contextKind classifies the position of the cursor within the command line.
type contextKind int
const (
// ctxMainOrVerb: in the main-flags region -- either before the first verb,
// or after a `--` separator. A main flag or a (first) verb may come next.
ctxMainOrVerb contextKind = iota
// ctxExpectVerb: immediately after `then`/`+`; a verb name comes next.
ctxExpectVerb
// ctxVerbFlags: inside a verb's flag region. A verb flag, `then`, or a
// filename may come next.
ctxVerbFlags
// ctxFlagValue: the cursor word is the argument value for the immediately
// preceding arg-taking flag.
ctxFlagValue
// ctxFiles: in the trailing data-file-names region.
ctxFiles
// ctxTerminalArgs: after a terminal subcommand (mlr help, mlr completion,
// ...), which consumes the rest of the command line.
ctxTerminalArgs
)
type context struct {
kind contextKind
verb string // set when kind == ctxVerbFlags
flag string // set when kind == ctxFlagValue: the arg-taking flag being valued
sawVerb bool // for kind == ctxMainOrVerb: whether a verb has appeared yet
terminal string // set when kind == ctxTerminalArgs: the terminal name
terminalArgs []string // for kind == ctxTerminalArgs: words after the terminal, before the cursor
}
// Complete is the entry point for the engine. words is the full argv as the
// shell sees it (words[0] is the program name, e.g. "mlr"); cword is the
// zero-based index of the word the cursor is on (matching bash's COMP_CWORD).
func Complete(words []string, cword int) Result {
cur := ""
if cword >= 0 && cword < len(words) {
cur = words[cword]
}
// Walk the words strictly before the cursor to classify the cursor's
// context.
end := min(max(cword, 0), len(words))
ctx := walk(words, end)
switch ctx.kind {
case ctxMainOrVerb:
if strings.HasPrefix(cur, "-") {
// Main flags plus the top-level terminal flags (-h, --help,
// --version, the help shorthands, ...).
cands := sortedUnion(mainFlagNames(), terminalFlagNames())
return Result{DirectiveCandidates, filterByPrefix(cands, cur)}
}
// Verb names, plus terminal subcommand names (help, version, ...) when
// no verb has appeared yet -- terminals are valid only as the first
// non-flag token.
if !ctx.sawVerb {
cands := sortedUnion(verbNames(), terminalNames())
return Result{DirectiveCandidates, filterByPrefix(cands, cur)}
}
return Result{DirectiveCandidates, filterByPrefix(verbNames(), cur)}
case ctxExpectVerb:
return Result{DirectiveCandidates, filterByPrefix(verbNames(), cur)}
case ctxVerbFlags:
if strings.HasPrefix(cur, "-") {
return Result{DirectiveCandidates, filterByPrefix(verbFlagNames(ctx.verb), cur)}
}
// A non-flag here is either the verb-chain keyword `then` or the
// beginning of filenames.
return Result{DirectiveDefault, filterByPrefix([]string{"then"}, cur)}
case ctxFlagValue:
// For a main flag whose argument is a known enumerated set (file
// formats, separator aliases), offer those values. Verb-flag values
// are not yet completed (issue #2097) -- and we must not apply the
// main-flag value sets to an identically-spelled verb flag (e.g.
// `mlr uniq -o` takes a field name, not a format).
if ctx.verb == "" {
if values := cli.FlagValueCandidates(ctx.flag); values != nil {
return Result{DirectiveCandidates, filterByPrefix(values, cur)}
}
}
return Result{DirectiveFiles, nil}
case ctxFiles:
return Result{DirectiveFiles, nil}
case ctxTerminalArgs:
return completeTerminalArgs(ctx.terminal, ctx.terminalArgs, cur)
}
return Result{DirectiveFiles, nil}
}
// walk scans words[1:end] left to right and reports the context that the word
// at index `end` (the cursor word) sits in. It mirrors the segmentation done by
// climain's pass-one parser, but never exits and tolerates incomplete input.
func walk(words []string, end int) context {
i := 1
inVerb := false
curVerb := ""
// sawVerb records whether a verb has been seen yet. Terminal subcommands
// (mlr help, mlr version, ...) are valid only as the first non-flag token,
// i.e. before any verb, so they should be offered only while !sawVerb.
sawVerb := false
for i < end {
tok := words[i]
if !inVerb {
// Main-flags region (also the slot for the first verb).
if strings.HasPrefix(tok, "-") {
if tok == "--" {
// Separator between a verb and a following main flag.
i++
continue
}
found, takesArg := cli.FLAG_TABLE.FlagTakesArg(tok)
if found && takesArg {
if i+1 >= end {
// The value is the cursor word.
return context{kind: ctxFlagValue, flag: tok}
}
i += 2
continue
}
// Arity-0 or unrecognized main flag: consume just the flag.
i++
continue
}
if tok == "then" || tok == "+" {
i++
if i >= end {
return context{kind: ctxExpectVerb}
}
curVerb = words[i]
inVerb = true
sawVerb = true
i++
continue
}
// First non-flag token. A terminal subcommand (mlr help, mlr
// version, ...) is valid only here; everything after it belongs to
// that terminal.
if !sawVerb && isTerminalName(tok) {
return context{kind: ctxTerminalArgs, terminal: tok, terminalArgs: words[i+1 : end]}
}
// First verb.
curVerb = tok
inVerb = true
sawVerb = true
i++
continue
}
// Inside verb curVerb's flag region.
if tok == "then" || tok == "+" {
i++
if i >= end {
return context{kind: ctxExpectVerb}
}
curVerb = words[i]
i++
continue
}
if tok == "--" {
// Back to the main-flags region; main flags may follow a verb.
inVerb = false
i++
continue
}
if strings.HasPrefix(tok, "-") {
found, takesArg := verbFlagTakesArg(curVerb, tok)
if found && takesArg {
if i+1 >= end {
return context{kind: ctxFlagValue, verb: curVerb, flag: tok}
}
i += 2
continue
}
i++
continue
}
// A non-flag, non-keyword token inside a verb region begins the
// trailing data-file names (Miller puts filenames last).
return context{kind: ctxFiles}
}
// Reached the cursor word.
if inVerb {
return context{kind: ctxVerbFlags, verb: curVerb}
}
return context{kind: ctxMainOrVerb, sawVerb: sawVerb}
}
// filterByPrefix returns the candidates that have cur as a prefix, preserving
// input order.
func filterByPrefix(candidates []string, cur string) []string {
if cur == "" {
return candidates
}
out := make([]string, 0, len(candidates))
for _, c := range candidates {
if strings.HasPrefix(c, cur) {
out = append(out, c)
}
}
return out
}
// mainFlagNames returns all main-flag spellings, sorted for a navigable
// display. This includes the format-conversion keystroke-saver flags (--c2j,
// --x2y, ...).
func mainFlagNames() []string {
names := cli.FLAG_TABLE.GetFlagNames()
sort.Strings(names)
return names
}
// verbNames returns all verb names, sorted.
func verbNames() []string {
names := transformers.GetVerbNames()
sort.Strings(names)
return names
}
// terminalNames returns the terminal subcommand names (help, version, ...).
func terminalNames() []string {
return registry.Names
}
// terminalFlagNames returns the top-level terminal flags: -h/--help and the
// help shorthands, plus the version flags.
func terminalFlagNames() []string {
return append(help.GetTerminalFlagNames(), registry.VersionFlagNames...)
}
// sortedUnion merges the given name lists, de-duplicates, and sorts the result.
func sortedUnion(lists ...[]string) []string {
seen := make(map[string]bool)
out := make([]string, 0)
for _, list := range lists {
for _, name := range list {
if !seen[name] {
seen[name] = true
out = append(out, name)
}
}
}
sort.Strings(out)
return out
}

View file

@ -0,0 +1,400 @@
package completion
import (
"slices"
"testing"
)
// split builds a words slice and cword from a command line plus the
// already-typed current word. The cursor word is always the last element.
func wordsAndCword(words ...string) ([]string, int) {
return words, len(words) - 1
}
func TestContextDirectives(t *testing.T) {
tests := []struct {
name string
words []string
wantDir Directive
mustHave []string // candidates that must be present
mustLack []string // candidates that must be absent
emptyCand bool // candidate list must be empty
}{
{
name: "bare mlr offers verbs",
words: []string{"mlr", ""},
wantDir: DirectiveCandidates,
mustHave: []string{"cat", "sort", "put"},
mustLack: []string{"--icsv"},
},
{
name: "dash in main region offers main flags",
words: []string{"mlr", "--ic"},
wantDir: DirectiveCandidates,
mustHave: []string{"--icsv"},
mustLack: []string{"cat"},
},
{
name: "after format flag offers verbs",
words: []string{"mlr", "--icsv", ""},
wantDir: DirectiveCandidates,
mustHave: []string{"cat", "head"},
},
{
name: "main flag taking a filename arg yields file completion",
words: []string{"mlr", "--from", ""},
wantDir: DirectiveFiles,
emptyCand: true,
},
{
name: "inside verb, dash offers that verb's flags",
words: []string{"mlr", "cat", "-"},
wantDir: DirectiveCandidates,
mustHave: []string{"-n", "-N", "-g", "--filename"},
mustLack: []string{"-f"}, // -f is not a cat flag
},
{
name: "inside verb, non-flag offers then plus files",
words: []string{"mlr", "cat", ""},
wantDir: DirectiveDefault,
mustHave: []string{"then"},
},
{
name: "after then offers verbs",
words: []string{"mlr", "sort", "-f", "a,b", "then", ""},
wantDir: DirectiveCandidates,
mustHave: []string{"cat", "tac", "head"},
},
{
name: "verb flag taking arg yields file completion",
words: []string{"mlr", "sort", "-f", ""},
wantDir: DirectiveFiles,
emptyCand: true,
},
{
name: "trailing filename region",
words: []string{"mlr", "cat", "data.csv", ""},
wantDir: DirectiveFiles,
emptyCand: true,
},
{
name: "double-dash separator returns to main flags",
words: []string{"mlr", "cat", "--", "--oj"},
wantDir: DirectiveCandidates,
mustHave: []string{"--ojson"},
},
{
name: "second verb in chain completes its own flags",
words: []string{"mlr", "cat", "-n", "then", "head", "-"},
wantDir: DirectiveCandidates,
mustHave: []string{"-n", "-g"},
},
{
name: "prefix filtering inside verb flags",
words: []string{"mlr", "cut", "--comp"},
wantDir: DirectiveCandidates,
mustHave: []string{"--complement"},
mustLack: []string{"-f"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
words, cword := wordsAndCword(tc.words...)
got := Complete(words, cword)
if got.Directive != tc.wantDir {
t.Errorf("directive: got %q, want %q (candidates=%v)",
got.Directive, tc.wantDir, got.Candidates)
}
if tc.emptyCand && len(got.Candidates) != 0 {
t.Errorf("expected no candidates, got %v", got.Candidates)
}
for _, want := range tc.mustHave {
if !slices.Contains(got.Candidates, want) {
t.Errorf("missing expected candidate %q in %v", want, got.Candidates)
}
}
for _, lack := range tc.mustLack {
if slices.Contains(got.Candidates, lack) {
t.Errorf("unexpected candidate %q in %v", lack, got.Candidates)
}
}
})
}
}
// TestTerminalCompletion verifies that terminal subcommands (mlr help, mlr
// version, ...) and the top-level terminal flags (-h, --version, ...) are
// offered as completions -- and only where they are valid.
func TestTerminalCompletion(t *testing.T) {
tests := []struct {
name string
words []string
mustHave []string
mustLack []string
}{
{
name: "first word offers terminals alongside verbs",
words: []string{"mlr", ""},
mustHave: []string{"cat", "help", "version", "repl", "completion"},
},
{
name: "first word prefix matches both verb and terminal",
words: []string{"mlr", "he"},
mustHave: []string{"head", "help"},
},
{
name: "leading dash offers terminal flags",
words: []string{"mlr", "-"},
mustHave: []string{"-h", "--help", "--version", "--bare-version", "-L", "-F"},
},
{
name: "version flag prefix",
words: []string{"mlr", "--ve"},
mustHave: []string{"--version"},
mustLack: []string{"--bare-version"},
},
{
name: "terminals not offered after then",
words: []string{"mlr", "cat", "then", ""},
mustHave: []string{"head", "sort"},
mustLack: []string{"help", "version", "repl"},
},
{
name: "terminals not offered in main region after a verb",
words: []string{"mlr", "cat", "--", ""},
mustLack: []string{"help", "version"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Complete(tc.words, len(tc.words)-1)
for _, want := range tc.mustHave {
if !slices.Contains(got.Candidates, want) {
t.Errorf("missing expected candidate %q in %v", want, got.Candidates)
}
}
for _, lack := range tc.mustLack {
if slices.Contains(got.Candidates, lack) {
t.Errorf("unexpected candidate %q in %v", lack, got.Candidates)
}
}
})
}
}
// TestHelpTopicCompletion verifies completion of `mlr help` topics and their
// name arguments, plus `mlr completion` subcommands.
func TestHelpTopicCompletion(t *testing.T) {
tests := []struct {
name string
words []string
mustHave []string
mustLack []string
}{
{
name: "help topics",
words: []string{"mlr", "help", ""},
mustHave: []string{"flags", "verb", "function", "keyword", "list-verbs"},
},
{
name: "help topic prefix",
words: []string{"mlr", "help", "list-v"},
mustHave: []string{"list-verbs"},
mustLack: []string{"flags"},
},
{
name: "help verb takes verb names",
words: []string{"mlr", "help", "verb", ""},
mustHave: []string{"cat", "sort", "put"},
},
{
name: "help function takes function names",
words: []string{"mlr", "help", "function", "strl"},
mustHave: []string{"strlen"},
},
{
name: "help keyword takes keyword names",
words: []string{"mlr", "help", "keyword", ""},
mustHave: []string{"ENV", "FILENAME"},
},
{
name: "help flag takes flag names",
words: []string{"mlr", "help", "flag", "--ic"},
mustHave: []string{"--icsv"},
},
{
name: "help works after a main flag",
words: []string{"mlr", "--icsv", "help", ""},
mustHave: []string{"flags", "verb"},
},
{
name: "completion subcommands",
words: []string{"mlr", "completion", ""},
mustHave: []string{"bash", "zsh"},
},
{
name: "help topic with no name-argument yields nothing",
words: []string{"mlr", "help", "list-verbs", ""},
mustLack: []string{"cat", "flags"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Complete(tc.words, len(tc.words)-1)
for _, want := range tc.mustHave {
if !slices.Contains(got.Candidates, want) {
t.Errorf("missing expected candidate %q in %v", want, got.Candidates)
}
}
for _, lack := range tc.mustLack {
if slices.Contains(got.Candidates, lack) {
t.Errorf("unexpected candidate %q in %v", lack, got.Candidates)
}
}
})
}
}
// TestAdversarialFlagValue verifies that a verb flag's argument value which
// happens to look like the chain keyword `then` is treated as a value, not as a
// verb-chain separator. This requires correct per-verb arity.
func TestAdversarialFlagValue(t *testing.T) {
// `mlr cut -f then -<cursor>`: `then` is the value of cut's -f, so the
// cursor is still inside cut's flag region.
words := []string{"mlr", "cut", "-f", "then", "-"}
got := Complete(words, len(words)-1)
if got.Directive != DirectiveCandidates {
t.Fatalf("directive: got %q, want %q", got.Directive, DirectiveCandidates)
}
if !slices.Contains(got.Candidates, "-o") {
t.Errorf("expected cut flag -o among candidates, got %v", got.Candidates)
}
}
// TestPutArityOverride verifies the override table: put's `-s` takes an
// argument even though its usage text lacks the `{...}` convention.
func TestPutArityOverride(t *testing.T) {
found, takesArg := verbFlagTakesArg("put", "-s")
if !found || !takesArg {
t.Errorf("put -s: got found=%v takesArg=%v, want true/true", found, takesArg)
}
// And it should be offered as a candidate.
if !slices.Contains(verbFlagNames("put"), "-s") {
t.Errorf("put -s missing from candidates %v", verbFlagNames("put"))
}
}
// TestFlagValueCompletion verifies enum-value completion for arg-taking main
// flags: file formats for -i/-o/--io and separator aliases for --ifs etc.,
// with a fallback to filename completion for non-enum flags.
func TestFlagValueCompletion(t *testing.T) {
tests := []struct {
name string
words []string
wantDir Directive
mustHave []string
mustLack []string
}{
{
name: "format flag -i offers file formats",
words: []string{"mlr", "-i", ""},
wantDir: DirectiveCandidates,
mustHave: []string{"csv", "json", "tsv", "pprint"},
mustLack: []string{"comma"},
},
{
name: "format flag --io offers file formats",
words: []string{"mlr", "--io", "js"},
wantDir: DirectiveCandidates,
mustHave: []string{"json"},
mustLack: []string{"csv"},
},
{
name: "separator flag --ifs offers aliases",
words: []string{"mlr", "--ifs", ""},
wantDir: DirectiveCandidates,
mustHave: []string{"comma", "tab", "pipe", "semicolon"},
},
{
name: "separator flag --ofs prefix-filters aliases",
words: []string{"mlr", "--ofs", "co"},
wantDir: DirectiveCandidates,
mustHave: []string{"colon", "comma"},
mustLack: []string{"tab", "pipe"},
},
{
name: "regex separator flag offers regex aliases",
words: []string{"mlr", "--ifs-regex", ""},
wantDir: DirectiveCandidates,
mustHave: []string{"spaces", "tabs", "whitespace"},
},
{
name: "non-enum flag --ofmt falls back to files",
words: []string{"mlr", "--ofmt", ""},
wantDir: DirectiveFiles,
},
{
// A verb flag spelled like a main value-flag must NOT inherit the
// main flag's value set: `uniq -o` takes a field name, not a format.
name: "verb flag colliding with main format flag falls back to files",
words: []string{"mlr", "uniq", "-o", ""},
wantDir: DirectiveFiles,
},
{
name: "filename flag --from falls back to files",
words: []string{"mlr", "--from", ""},
wantDir: DirectiveFiles,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Complete(tc.words, len(tc.words)-1)
if got.Directive != tc.wantDir {
t.Errorf("directive: got %q, want %q (candidates=%v)",
got.Directive, tc.wantDir, got.Candidates)
}
for _, want := range tc.mustHave {
if !slices.Contains(got.Candidates, want) {
t.Errorf("missing expected value %q in %v", want, got.Candidates)
}
}
for _, lack := range tc.mustLack {
if slices.Contains(got.Candidates, lack) {
t.Errorf("unexpected value %q in %v", lack, got.Candidates)
}
}
})
}
}
// TestVerbFlagScrape sanity-checks the usage-text scraper on representative
// verbs.
func TestVerbFlagScrape(t *testing.T) {
cases := []struct {
verb string
flag string
takesArg bool
}{
{"head", "-n", true},
{"head", "-g", true},
{"cat", "-n", false},
{"cat", "-N", true},
{"cut", "-f", true},
{"cut", "-o", false},
{"cut", "--complement", false},
}
for _, c := range cases {
found, takesArg := verbFlagTakesArg(c.verb, c.flag)
if !found {
t.Errorf("%s %s: not found", c.verb, c.flag)
continue
}
if takesArg != c.takesArg {
t.Errorf("%s %s: takesArg got %v want %v", c.verb, c.flag, takesArg, c.takesArg)
}
}
}

View file

@ -0,0 +1,96 @@
// Entry point for the `mlr completion` terminal.
package completion
import (
"fmt"
"os"
"strconv"
)
// CompletionMain is the handler for `mlr completion ...`, dispatched by the
// terminals framework.
//
// mlr completion bash Print a bash completion script.
// mlr completion zsh Print a zsh completion script.
// mlr completion complete <cword> <word>...
// Internal: emit completion candidates for the
// given command-line words. Called by the shim
// scripts above; not intended for direct use.
func CompletionMain(args []string) int {
// args[0] is "completion".
if len(args) < 2 {
printUsage(os.Stdout)
return 0
}
switch args[1] {
case "bash":
fmt.Print(bashScript)
return 0
case "zsh":
fmt.Print(zshScript)
return 0
case "complete":
return runComplete(args[2:])
case "-h", "--help", "help":
printUsage(os.Stdout)
return 0
default:
fmt.Fprintf(os.Stderr, "mlr completion: unrecognized subcommand %q.\n", args[1])
fmt.Fprintf(os.Stderr, "Please run \"mlr completion --help\" for usage information.\n")
return 1
}
}
// runComplete handles `mlr completion complete <cword> <word>...`, printing a
// directive line followed by candidate words, one per line.
func runComplete(rest []string) int {
if len(rest) < 1 {
// Nothing to complete.
fmt.Println(string(DirectiveFiles))
return 0
}
cword, err := strconv.Atoi(rest[0])
if err != nil {
fmt.Println(string(DirectiveFiles))
return 0
}
words := rest[1:]
result := Complete(words, cword)
fmt.Println(string(result.Directive))
for _, candidate := range result.Candidates {
fmt.Println(candidate)
}
return 0
}
func printUsage(o *os.File) {
fmt.Fprintf(o, `Usage: mlr completion {bash|zsh}
Generates a shell tab-completion script for Miller.
Bash:
Add to your ~/.bashrc:
eval "$(mlr completion bash)"
Or install system-wide:
mlr completion bash > /etc/bash_completion.d/mlr
Note: prefer 'eval' over 'source <(mlr completion bash)'. The latter
silently fails on the bash 3.2 that ships with macOS, where sourcing from a
process-substitution FIFO can read nothing.
Zsh:
Add to your ~/.zshrc:
eval "$(mlr completion zsh)"
Or place the output on your $fpath, e.g.:
mlr completion zsh > "${fpath[1]}/_mlr"
The script initializes zsh's completion system (compinit) if your startup
files have not done so already.
Completion is context-aware across Miller's then-chains: it offers main flags
and verb names before the first verb, the current verb's flags inside a verb,
verb names after 'then', and filenames where appropriate.
`)
}

View file

@ -0,0 +1,77 @@
package completion
// bashScript is the bash completion shim emitted by `mlr completion bash`. It
// holds no knowledge of Miller's grammar: it forwards the current words to
// `mlr completion complete`, which returns a one-line directive followed by
// candidate words.
const bashScript = `# bash completion for mlr -- generated by 'mlr completion bash'
_mlr_complete() {
local cur response directive rest
cur="${COMP_WORDS[COMP_CWORD]}"
response=$("${COMP_WORDS[0]}" completion complete "$COMP_CWORD" "${COMP_WORDS[@]}" 2>/dev/null)
# The first line is a directive; the remaining lines are candidate words.
# We split with parameter expansion rather than array slicing
# (${arr[@]:1}), which is broken in the bash 3.2 that ships with macOS.
directive=${response%%$'\n'*}
if [[ "$response" == *$'\n'* ]]; then
rest=${response#*$'\n'}
else
rest=""
fi
# compgen -f rather than 'compopt -o default' for file completion, since
# compopt does not exist in bash 3.2.
local IFS=$'\n'
COMPREPLY=()
case "$directive" in
files)
COMPREPLY=( $(compgen -f -- "$cur") )
compopt -o filenames 2>/dev/null
;;
default)
COMPREPLY=( $rest $(compgen -f -- "$cur") )
compopt -o filenames 2>/dev/null
;;
*)
COMPREPLY=( $rest )
;;
esac
}
complete -F _mlr_complete mlr
`
// zshScript is the zsh completion shim emitted by `mlr completion zsh`.
const zshScript = `#compdef mlr
# zsh completion for mlr -- generated by 'mlr completion zsh'
# Initialize the completion system if it has not been already; this provides
# 'compdef' and makes 'source <(mlr completion zsh)' work even when the user's
# zsh startup files do not run compinit themselves.
if ! (( $+functions[compdef] )); then
autoload -Uz compinit && compinit
fi
_mlr() {
local -a response candidates
local directive
response=("${(@f)$(${words[1]} completion complete $((CURRENT-1)) "${words[@]}" 2>/dev/null)}")
directive=${response[1]}
candidates=(${response[2,-1]})
case $directive in
files)
_files
;;
default)
(( ${#candidates} )) && compadd -- ${candidates}
_files
;;
*)
compadd -- ${candidates}
;;
esac
}
compdef _mlr mlr
`

View file

@ -0,0 +1,294 @@
//go:build !windows
// Shell-level smoke tests for the generated bash completion script.
//
// These guard the shell glue itself -- in particular the bash 3.2 (macOS)
// array-slicing defect that caused every candidate to be mashed into a single
// COMPREPLY entry and dumped onto the command line. The Go-level engine tests
// cannot catch that class of bug, since it lives entirely in the emitted shell.
//
// The test sources the real generated script under /bin/bash, drives
// _mlr_complete with synthetic COMP_WORDS/COMP_CWORD, and inspects COMPREPLY.
// It builds the mlr binary because the shim shells back out to it.
package completion
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
)
var (
builtMlrOnce sync.Once
builtMlrPath string
builtMlrErr error
)
// buildMlr builds the mlr binary once per test process and returns its path.
func buildMlr(t *testing.T) string {
t.Helper()
builtMlrOnce.Do(func() {
dir, err := os.MkdirTemp("", "mlr-completion-smoke")
if err != nil {
builtMlrErr = err
return
}
bin := filepath.Join(dir, "mlr")
cmd := exec.Command("go", "build", "-o", bin, "github.com/johnkerl/miller/v6/cmd/mlr")
if out, err := cmd.CombinedOutput(); err != nil {
builtMlrErr = err
t.Logf("go build output:\n%s", out)
return
}
builtMlrPath = bin
})
if builtMlrErr != nil {
t.Fatalf("building mlr: %v", builtMlrErr)
}
return builtMlrPath
}
func shellSingleQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// runBashCompletion sources the script, drives _mlr_complete for the given
// command-line words (words[0] is replaced with the real mlr binary), and
// returns the COMPREPLY entries. dir, if non-empty, is the working directory
// (so file-completion behavior is deterministic).
func runBashCompletion(t *testing.T, scriptPath, mlrBin, dir string, words []string, cword int) []string {
t.Helper()
quoted := make([]string, len(words))
for i, w := range words {
if i == 0 {
quoted[i] = shellSingleQuote(mlrBin)
} else {
quoted[i] = shellSingleQuote(w)
}
}
driver := strings.Join([]string{
"source " + shellSingleQuote(scriptPath),
"COMP_WORDS=(" + strings.Join(quoted, " ") + ")",
"COMP_CWORD=" + strconv.Itoa(cword),
"COMPREPLY=()",
"_mlr_complete",
`for c in "${COMPREPLY[@]}"; do printf 'ENTRY:%s\n' "$c"; done`,
}, "\n")
cmd := exec.Command("/bin/bash", "-c", driver)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("bash driver failed: %v\n%s", err, out)
}
var entries []string
for _, line := range strings.Split(string(out), "\n") {
if rest, ok := strings.CutPrefix(line, "ENTRY:"); ok {
entries = append(entries, rest)
}
}
return entries
}
func TestBashShim(t *testing.T) {
if testing.Short() {
t.Skip("skipping shell smoke test in -short mode")
}
if _, err := exec.LookPath("go"); err != nil {
t.Skip("go toolchain not available to build mlr")
}
bashPath := "/bin/bash"
if _, err := os.Stat(bashPath); err != nil {
t.Skip("no /bin/bash available")
}
mlrBin := buildMlr(t)
scriptDir := t.TempDir()
scriptPath := filepath.Join(scriptDir, "mlr_completion.bash")
if err := os.WriteFile(scriptPath, []byte(bashScript), 0o644); err != nil {
t.Fatal(err)
}
containsAll := func(t *testing.T, got, want []string) {
t.Helper()
set := make(map[string]bool, len(got))
for _, g := range got {
set[g] = true
}
for _, w := range want {
if !set[w] {
t.Errorf("missing %q in COMPREPLY: %v", w, got)
}
}
}
// The core regression: multiple candidates must come back as SEPARATE
// COMPREPLY entries, not one space-joined blob (the bash 3.2 array-slicing
// bug). This is what caused all candidates to be inserted onto the line.
t.Run("candidates are split, not joined", func(t *testing.T) {
got := runBashCompletion(t, scriptPath, mlrBin, "", []string{"mlr", "--m"}, 1)
if len(got) < 2 {
t.Fatalf("expected multiple candidates for '--m', got %d: %v", len(got), got)
}
for _, c := range got {
if strings.ContainsAny(c, " \t") {
t.Errorf("candidate contains whitespace (slicing regression?): %q", c)
}
if !strings.HasPrefix(c, "--m") {
t.Errorf("unexpected candidate for '--m': %q", c)
}
}
})
t.Run("verb names before first verb", func(t *testing.T) {
got := runBashCompletion(t, scriptPath, mlrBin, "", []string{"mlr", ""}, 1)
containsAll(t, got, []string{"cat", "sort", "put"})
})
t.Run("terminal subcommands and flags", func(t *testing.T) {
got := runBashCompletion(t, scriptPath, mlrBin, "", []string{"mlr", ""}, 1)
containsAll(t, got, []string{"help", "version", "repl"})
gotFlags := runBashCompletion(t, scriptPath, mlrBin, "", []string{"mlr", "-"}, 1)
containsAll(t, gotFlags, []string{"-h", "--help", "--version"})
})
t.Run("help topics and topic arguments", func(t *testing.T) {
topics := runBashCompletion(t, scriptPath, mlrBin, "", []string{"mlr", "help", ""}, 2)
containsAll(t, topics, []string{"flags", "verb", "function"})
verbs := runBashCompletion(t, scriptPath, mlrBin, "", []string{"mlr", "help", "verb", ""}, 3)
containsAll(t, verbs, []string{"cat", "sort"})
})
t.Run("verb flags inside a verb", func(t *testing.T) {
got := runBashCompletion(t, scriptPath, mlrBin, "", []string{"mlr", "cat", "-"}, 2)
containsAll(t, got, []string{"-n", "--filename"})
})
t.Run("main flags on bare dash include all flags", func(t *testing.T) {
got := runBashCompletion(t, scriptPath, mlrBin, "", []string{"mlr", "-"}, 1)
// Includes ordinary flags as well as the format-conversion
// keystroke-savers (--c2j, --m2j, ...).
containsAll(t, got, []string{"--icsv", "--ojson", "--m2j", "--c2p"})
})
t.Run("conversion matrix narrows by prefix", func(t *testing.T) {
got := runBashCompletion(t, scriptPath, mlrBin, "", []string{"mlr", "--m2"}, 1)
containsAll(t, got, []string{"--m2j", "--m2p"})
})
// File-completion directives (the bash 3.2 'no compopt' path uses
// compgen -f). --from takes a filename argument, so it defers to file
// completion. Run in a temp dir with known files for determinism.
t.Run("file completion after filename flag", func(t *testing.T) {
dataDir := t.TempDir()
for _, name := range []string{"alpha.csv", "beta.csv"} {
if err := os.WriteFile(filepath.Join(dataDir, name), nil, 0o644); err != nil {
t.Fatal(err)
}
}
got := runBashCompletion(t, scriptPath, mlrBin, dataDir, []string{"mlr", "--from", ""}, 2)
containsAll(t, got, []string{"alpha.csv", "beta.csv"})
})
// Enum-value completion: an arg-taking flag whose values are a known set
// (here file formats) offers those values rather than filenames.
t.Run("enum value completion for format flag", func(t *testing.T) {
got := runBashCompletion(t, scriptPath, mlrBin, "", []string{"mlr", "-i", ""}, 2)
containsAll(t, got, []string{"csv", "json", "tsv"})
})
t.Run("then plus files inside verb", func(t *testing.T) {
dataDir := t.TempDir()
if err := os.WriteFile(filepath.Join(dataDir, "gamma.csv"), nil, 0o644); err != nil {
t.Fatal(err)
}
got := runBashCompletion(t, scriptPath, mlrBin, dataDir, []string{"mlr", "cat", ""}, 2)
containsAll(t, got, []string{"then", "gamma.csv"})
})
}
func TestZshShim(t *testing.T) {
if testing.Short() {
t.Skip("skipping shell smoke test in -short mode")
}
if _, err := exec.LookPath("go"); err != nil {
t.Skip("go toolchain not available to build mlr")
}
zshPath, err := exec.LookPath("zsh")
if err != nil {
t.Skip("no zsh available")
}
mlrBin := buildMlr(t)
scriptPath := filepath.Join(t.TempDir(), "_mlr")
if err := os.WriteFile(scriptPath, []byte(zshScript), 0o644); err != nil {
t.Fatal(err)
}
// The script must source cleanly even when compinit has never run (zsh -f
// loads no startup files). This guards the 'command not found: compdef'
// regression: the script self-initializes the completion system.
t.Run("sources cleanly and registers without precompinit", func(t *testing.T) {
driver := strings.Join([]string{
"source " + shellSingleQuote(scriptPath),
"(( $+functions[_mlr] )) || { print -r FAIL_NO_FUNC; exit 3 }",
"(( $+_comps[mlr] )) || { print -r FAIL_NO_COMPDEF; exit 4 }",
"print -r OK",
}, "\n")
cmd := exec.Command(zshPath, "-f", "-c", driver)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("zsh sourcing failed: %v\n%s", err, out)
}
if !strings.Contains(string(out), "OK") {
t.Fatalf("expected OK, got:\n%s", out)
}
})
// zsh-side parsing: the ${(@f)} newline-split plus the [2,-1] slice must
// yield each candidate as a separate array element (the zsh analogue of the
// bash array-slicing regression).
t.Run("splits candidates into separate elements", func(t *testing.T) {
driver := fmt.Sprintf(`response=("${(@f)$(%s completion complete 1 mlr --m 2>/dev/null)}")
candidates=(${response[2,-1]})
for c in $candidates; do print -r -- "ENTRY:$c"; done`, shellSingleQuote(mlrBin))
cmd := exec.Command(zshPath, "-f", "-c", driver)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("zsh driver failed: %v\n%s", err, out)
}
var entries []string
for _, line := range strings.Split(string(out), "\n") {
if rest, ok := strings.CutPrefix(line, "ENTRY:"); ok {
entries = append(entries, rest)
}
}
if len(entries) < 2 {
t.Fatalf("expected multiple candidates for '--m', got %d: %v", len(entries), entries)
}
for _, c := range entries {
if strings.ContainsAny(c, " \t") {
t.Errorf("candidate contains whitespace (slicing regression?): %q", c)
}
if !strings.HasPrefix(c, "--m") {
t.Errorf("unexpected candidate for '--m': %q", c)
}
}
})
}

View file

@ -0,0 +1,73 @@
// Completion for the arguments of terminal subcommands -- chiefly
// `mlr help <TAB>`, which completes help topics, and a topic's own argument
// (e.g. `mlr help verb <TAB>` -> verb names). `mlr completion <TAB>` completes
// bash/zsh. Other terminals have no argument completion.
package completion
import (
"github.com/johnkerl/miller/v6/pkg/terminals/help"
"github.com/johnkerl/miller/v6/pkg/terminals/registry"
)
// terminalNameSet is the set of terminal subcommand names, for quick lookup
// during the command-line walk.
var terminalNameSet = func() map[string]bool {
m := make(map[string]bool, len(registry.Names))
for _, name := range registry.Names {
m[name] = true
}
return m
}()
func isTerminalName(s string) bool {
return terminalNameSet[s]
}
// completeTerminalArgs produces candidates for the words following a terminal
// subcommand. args are the words already typed after the terminal name, before
// the cursor; cur is the word being completed.
func completeTerminalArgs(terminal string, args []string, cur string) Result {
switch terminal {
case registry.Help:
switch len(args) {
case 0:
// `mlr help <TAB>`: the help topics.
return Result{DirectiveCandidates, filterByPrefix(sortedUnion(help.GetTopicNames()), cur)}
case 1:
// `mlr help <topic> <TAB>`: the topic's own argument, if it takes
// one (e.g. a verb, function, keyword, or flag name).
if values := helpTopicArgValues(args[0]); values != nil {
return Result{DirectiveCandidates, filterByPrefix(values, cur)}
}
}
return Result{DirectiveCandidates, nil}
case registry.Completion:
if len(args) == 0 {
return Result{DirectiveCandidates, filterByPrefix([]string{"bash", "zsh"}, cur)}
}
return Result{DirectiveCandidates, nil}
}
// Other terminals (version, repl, regtest, script, terminal-list) have no
// argument completion.
return Result{DirectiveCandidates, nil}
}
// helpTopicArgValues returns the candidate values for the argument of a
// `mlr help {topic}` whose topic takes a name argument, or nil otherwise.
func helpTopicArgValues(topic string) []string {
switch topic {
case "verb":
return verbNames()
case "flag":
return mainFlagNames()
case "function":
return sortedUnion(help.GetFunctionNames())
case "keyword":
return sortedUnion(help.GetKeywordNames())
}
return nil
}

View file

@ -0,0 +1,159 @@
// Per-verb flag discovery for shell completion.
//
// Miller's verbs do not expose a structured flag table the way main flags do;
// each verb hand-rolls its own CLI parser. Rather than maintain a parallel
// hand-written table that could drift from the parsers, we scrape each verb's
// usage text -- which is the same source of truth shown to users by
// `mlr <verb> --help` -- for its flag names and arity.
//
// The scrape relies on Miller's consistent usage-text convention:
//
// -f {comma-separated field names} ... (takes an argument: brace follows)
// -n ... (no argument)
// -x|--complement ... (alternate spellings, '|'-separated)
//
// A handful of verbs document a flag without the `{...}` convention (e.g.
// `put -s name=value`); those are corrected via verbFlagArityOverrides. Getting
// arity slightly wrong only affects the tolerant command-line walk in rare
// adversarial cases (a flag value that looks like `then` or a verb name); it
// never crashes and never produces a wrong command line.
package completion
import (
"bytes"
"io"
"os"
"strings"
"github.com/johnkerl/miller/v6/pkg/transformers"
)
// verbFlagInfo is the scraped flag metadata for one verb.
type verbFlagInfo struct {
names []string // flag spellings, in usage-text order
takesArg map[string]bool // spelling -> whether it consumes an argument
}
// verbFlagArityOverrides corrects arity for flags whose usage text does not use
// the `-flag {arg}` convention. Keyed by verb, then by flag spelling, with the
// value being whether the flag takes an argument.
var verbFlagArityOverrides = map[string]map[string]bool{
// `put`/`filter` document `-s name=value` and `-e {expression}` etc.; the
// `-s` form has no braces, so the scraper would under-detect its arity.
"put": {"-s": true},
"filter": {"-s": true},
}
// verbFlagCache memoizes scrapes within a single process invocation.
var verbFlagCache = map[string]*verbFlagInfo{}
// verbFlagNames returns the flag spellings to offer as completion candidates
// for the given verb.
func verbFlagNames(verb string) []string {
return getVerbFlagInfo(verb).names
}
// verbFlagTakesArg reports whether `flag` is a known flag of `verb` and whether
// it consumes a following argument value.
func verbFlagTakesArg(verb string, flag string) (found bool, takesArg bool) {
info := getVerbFlagInfo(verb)
ta, ok := info.takesArg[flag]
return ok, ta
}
func getVerbFlagInfo(verb string) *verbFlagInfo {
if info, ok := verbFlagCache[verb]; ok {
return info
}
info := scrapeVerbFlagInfo(verb)
verbFlagCache[verb] = info
return info
}
func scrapeVerbFlagInfo(verb string) *verbFlagInfo {
info := &verbFlagInfo{
names: []string{},
takesArg: map[string]bool{},
}
usage, ok := captureVerbUsage(verb)
if ok {
parseUsageFlags(usage, info)
}
// Apply arity overrides, and ensure overridden flags are offered as
// candidates even if scraping missed them.
if overrides, ok := verbFlagArityOverrides[verb]; ok {
for flag, takesArg := range overrides {
if _, seen := info.takesArg[flag]; !seen {
info.names = append(info.names, flag)
}
info.takesArg[flag] = takesArg
}
}
return info
}
// parseUsageFlags extracts flag spellings and arity from verb usage text.
func parseUsageFlags(usage string, info *verbFlagInfo) {
for _, line := range strings.Split(usage, "\n") {
trimmed := strings.TrimLeft(line, " \t")
if !strings.HasPrefix(trimmed, "-") {
continue
}
fields := strings.Fields(trimmed)
if len(fields) == 0 {
continue
}
// A following `{...}` token signals an argument-taking flag.
takesArg := len(fields) >= 2 && strings.HasPrefix(fields[1], "{")
// The leading token may bundle alternate spellings, e.g. `-h|--help`
// or `-x|--complement` or `-tr|-rt`.
for _, name := range strings.Split(fields[0], "|") {
name = strings.TrimRight(name, ":,")
if name == "-" || name == "--" || !strings.HasPrefix(name, "-") {
continue
}
if _, seen := info.takesArg[name]; !seen {
info.names = append(info.names, name)
}
// If any spelling on the line takes an argument, all do.
if takesArg || info.takesArg[name] {
info.takesArg[name] = true
} else {
info.takesArg[name] = false
}
}
}
}
// captureVerbUsage runs a verb's UsageFunc and returns its text. UsageFunc
// writes to an *os.File, so we capture it through an os.Pipe.
func captureVerbUsage(verb string) (string, bool) {
setup := transformers.LookUp(verb)
if setup == nil || setup.UsageFunc == nil {
return "", false
}
r, w, err := os.Pipe()
if err != nil {
return "", false
}
done := make(chan string, 1)
go func() {
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
done <- buf.String()
}()
setup.UsageFunc(w)
_ = w.Close()
text := <-done
_ = r.Close()
return text, true
}

View file

@ -184,6 +184,47 @@ func init() {
}
}
// GetTopicNames returns the user-facing `mlr help {topic}` topic names (the
// internal docgen-only topics are excluded). For shell-completion of
// `mlr help <TAB>`.
func GetTopicNames() []string {
names := []string{}
for _, section := range handlerLookupTable.sections {
if section.internal {
continue
}
for _, info := range section.handlerInfos {
names = append(names, info.name)
}
}
return names
}
// GetFunctionNames returns all DSL built-in function names, for shell-completion
// of `mlr help function {name}`.
func GetFunctionNames() []string {
return cst.BuiltinFunctionManagerInstance.GetBuiltinFunctionNames()
}
// GetKeywordNames returns all DSL keyword names, for shell-completion of
// `mlr help keyword {name}`.
func GetKeywordNames() []string {
return cst.GetKeywordNames()
}
// GetTerminalFlagNames returns the top-level help flags that short-circuit
// normal command-line processing: "-h"/"--help" and the shorthands such as
// "-l" (for "help list-verbs") and "-F" (for "help usage-functions"). These
// are exposed for shell-completion candidates (pkg/terminals/completion). The
// shorthands are derived from shorthandLookupTable so the list can't drift.
func GetTerminalFlagNames() []string {
names := []string{"-h", "--help"}
for _, sinfo := range shorthandLookupTable.shorthandInfos {
names = append(names, sinfo.shorthand)
}
return names
}
// For things like 'mlr help foo', invoked through the terminals framework which
// goes through our HelpMain(). Here, the args are the terminal part of the full
// Miller command line: if the latter was "mlr --some-flag help foo bar" then

View file

@ -0,0 +1,43 @@
// Package registry is the single source of truth for the names of Miller's
// "terminals" -- the top-level subcommands like `mlr help` and `mlr version`
// -- and for the top-level version flags.
//
// It exists as its own leaf package (importing nothing within Miller) so that
// both pkg/terminals (which builds the dispatch table) and
// pkg/terminals/completion (which offers these as tab-completion candidates)
// can import it without an import cycle. pkg/terminals imports
// pkg/terminals/completion, so completion cannot import pkg/terminals directly.
package registry
// Terminal subcommand names, in display order. pkg/terminals builds its
// dispatch table from these constants.
const (
TerminalList = "terminal-list"
Completion = "completion"
Help = "help"
Regtest = "regtest"
Repl = "repl"
Script = "script"
Version = "version"
)
// Names is the ordered list of all terminal subcommand names.
var Names = []string{
TerminalList,
Completion,
Help,
Regtest,
Repl,
Script,
Version,
}
// Top-level version flags, handled in pkg/climain before normal command-line
// parsing.
const (
VersionFlag = "--version"
BareVersionFlag = "--bare-version"
)
// VersionFlagNames is the list of top-level version flags.
var VersionFlagNames = []string{VersionFlag, BareVersionFlag}

View file

@ -8,7 +8,9 @@ import (
"os"
"runtime"
"github.com/johnkerl/miller/v6/pkg/terminals/completion"
"github.com/johnkerl/miller/v6/pkg/terminals/help"
"github.com/johnkerl/miller/v6/pkg/terminals/registry"
"github.com/johnkerl/miller/v6/pkg/terminals/regtest"
"github.com/johnkerl/miller/v6/pkg/terminals/repl"
"github.com/johnkerl/miller/v6/pkg/terminals/script"
@ -31,12 +33,13 @@ var _TERMINAL_LOOKUP_TABLE = []tTerminalLookupEntry{}
func init() {
_TERMINAL_LOOKUP_TABLE = []tTerminalLookupEntry{
{"terminal-list", terminalListMain},
{"help", help.HelpMain},
{"regtest", regtest.RegTestMain},
{"repl", repl.ReplMain},
{"script", script.ScriptMain},
{"version", showVersion},
{registry.TerminalList, terminalListMain},
{registry.Completion, completion.CompletionMain},
{registry.Help, help.HelpMain},
{registry.Regtest, regtest.RegTestMain},
{registry.Repl, repl.ReplMain},
{registry.Script, script.ScriptMain},
{registry.Version, showVersion},
}
}

View file

@ -113,6 +113,16 @@ func LookUp(verb string) *TransformerSetup {
return nil
}
// GetVerbNames returns all verb names, in table order, for use as
// shell-completion candidates (pkg/terminals/completion).
func GetVerbNames() []string {
verbNames := make([]string, len(TRANSFORMER_LOOKUP_TABLE))
for i, transformerSetup := range TRANSFORMER_LOOKUP_TABLE {
verbNames[i] = transformerSetup.Verb
}
return verbNames
}
func ListVerbNamesVertically() {
for _, transformerSetup := range TRANSFORMER_LOOKUP_TABLE {
fmt.Printf("%s\n", transformerSetup.Verb)