diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 6b36e5a94..4a100d4c2 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -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" diff --git a/docs/src/shell-completion.md b/docs/src/shell-completion.md new file mode 100644 index 000000000..19add90f7 --- /dev/null +++ b/docs/src/shell-completion.md @@ -0,0 +1,198 @@ + +
+mlr {main flags} verb1 {verb1 flags} then verb2 {verb2 flags} {filenames}
+
+
+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`:
+
++eval "$(mlr completion bash)" ++ +Or install it system-wide (loaded by the `bash-completion` package): + +
+mlr completion bash > /etc/bash_completion.d/mlr ++ +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`: + +
+eval "$(mlr completion zsh)" ++ +Or place the script on your `$fpath` so zsh autoloads it: + +
+mlr completion zsh > "${fpath[1]}/_mlr"
+
+
+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, TAB offers verb names along with subcommands like
+`help` and `version`:
+
++mlr TAB +altkv cat completion count ... help version ++ +The top-level help and version flags are offered too: + +
+mlr --vTAB +--value-color --version --vflatsep ++ +The `help` subcommand completes its topics, and topics that take a name +argument complete that too: + +
+mlr help TAB +flags list-verbs verb function keyword ... + +mlr help verb TAB +altkv bar cat count cut ... + +mlr help function strlTAB +strlen ++ +A leading dash offers main flags, including the format-conversion +keystroke-savers (`--c2j`, `--x2y`, and so on): + +
+mlr --cTAB +--c2b --c2c --c2d --c2j --c2p ... --csv --csvlite ++ +Inside a verb, TAB offers that verb's flags: + +
+mlr cat -TAB +-n -N -g --filename --filenum ++ +After `then`, TAB offers verb names again: + +
+mlr --icsv cat -n then head -n 10 then TAB +altkv cat count cut ... ++ +For flags whose argument is a known set of values, TAB offers those +values. Format flags (`-i`, `-o`, `--io`) offer file-format names: + +
+mlr -i TAB +csv csvlite dcf dkvp dkvpx gen json markdown nidx pprint tsv xtab yaml ++ +and separator flags (`--ifs`, `--ofs`, `--ips`, and so on) offer the named +separator aliases: + +
+mlr --ifs TAB +comma pipe semicolon space tab ... ++ +## Generating the scripts + +The `mlr completion` command prints the scripts, and `mlr completion --help` +describes the options: + +
+mlr completion --help ++
+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.
+
+
+## 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.
diff --git a/docs/src/shell-completion.md.in b/docs/src/shell-completion.md.in
new file mode 100644
index 000000000..0b48ba0b4
--- /dev/null
+++ b/docs/src/shell-completion.md.in
@@ -0,0 +1,157 @@
+# Shell completion
+
+Miller can generate tab-completion scripts for `bash` and `zsh`. Once installed,
+pressing TAB 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, TAB offers verb names along with subcommands like
+`help` and `version`:
+
+GENMD-CARDIFY
+mlr TAB
+altkv cat completion count ... help version
+GENMD-EOF
+
+The top-level help and version flags are offered too:
+
+GENMD-CARDIFY
+mlr --vTAB
+--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 TAB
+flags list-verbs verb function keyword ...
+
+mlr help verb TAB
+altkv bar cat count cut ...
+
+mlr help function strlTAB
+strlen
+GENMD-EOF
+
+A leading dash offers main flags, including the format-conversion
+keystroke-savers (`--c2j`, `--x2y`, and so on):
+
+GENMD-CARDIFY
+mlr --cTAB
+--c2b --c2c --c2d --c2j --c2p ... --csv --csvlite
+GENMD-EOF
+
+Inside a verb, TAB offers that verb's flags:
+
+GENMD-CARDIFY
+mlr cat -TAB
+-n -N -g --filename --filenum
+GENMD-EOF
+
+After `then`, TAB offers verb names again:
+
+GENMD-CARDIFY
+mlr --icsv cat -n then head -n 10 then TAB
+altkv cat count cut ...
+GENMD-EOF
+
+For flags whose argument is a known set of values, TAB offers those
+values. Format flags (`-i`, `-o`, `--io`) offer file-format names:
+
+GENMD-CARDIFY
+mlr -i TAB
+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 TAB
+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.
diff --git a/pkg/cli/flag_types.go b/pkg/cli/flag_types.go
index 001669fa4..1bb2c7af7 100644
--- a/pkg/cli/flag_types.go
+++ b/pkg/cli/flag_types.go
@@ -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
+}
diff --git a/pkg/cli/flag_types_test.go b/pkg/cli/flag_types_test.go
new file mode 100644
index 000000000..fbb97b7c5
--- /dev/null
+++ b/pkg/cli/flag_types_test.go
@@ -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)
+ }
+ }
+}
diff --git a/pkg/cli/separators.go b/pkg/cli/separators.go
index 43eb60232..0d7e26251 100644
--- a/pkg/cli/separators.go
+++ b/pkg/cli/separators.go
@@ -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",
diff --git a/pkg/climain/mlrcli_parse.go b/pkg/climain/mlrcli_parse.go
index 78837b557..f6ee5e149 100644
--- a/pkg/climain/mlrcli_parse.go
+++ b/pkg/climain/mlrcli_parse.go
@@ -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)
diff --git a/pkg/dsl/cst/builtin_function_manager.go b/pkg/dsl/cst/builtin_function_manager.go
index 364f98fbd..5ceb4cc06 100644
--- a/pkg/dsl/cst/builtin_function_manager.go
+++ b/pkg/dsl/cst/builtin_function_manager.go
@@ -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() {
diff --git a/pkg/dsl/cst/keyword_usage.go b/pkg/dsl/cst/keyword_usage.go
index 92bf7b9d4..43e2e1c7c 100644
--- a/pkg/dsl/cst/keyword_usage.go
+++ b/pkg/dsl/cst/keyword_usage.go
@@ -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() {
diff --git a/pkg/terminals/completion/completion.go b/pkg/terminals/completion/completion.go
new file mode 100644
index 000000000..6989d4f72
--- /dev/null
+++ b/pkg/terminals/completion/completion.go
@@ -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
+}
diff --git a/pkg/terminals/completion/completion_test.go b/pkg/terminals/completion/completion_test.go
new file mode 100644
index 000000000..5cbe84edf
--- /dev/null
+++ b/pkg/terminals/completion/completion_test.go
@@ -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 -