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 @@ + +
+ +Quick links: +  +Flags +  +Verbs +  +Functions +  +Glossary +  +Release docs + +
+# 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): + +
+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 -`: `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) + } + } +} diff --git a/pkg/terminals/completion/entry.go b/pkg/terminals/completion/entry.go new file mode 100644 index 000000000..29179e9ce --- /dev/null +++ b/pkg/terminals/completion/entry.go @@ -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 ... +// 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 ...`, 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. +`) +} diff --git a/pkg/terminals/completion/scripts.go b/pkg/terminals/completion/scripts.go new file mode 100644 index 000000000..2149cf143 --- /dev/null +++ b/pkg/terminals/completion/scripts.go @@ -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 +` diff --git a/pkg/terminals/completion/shell_smoke_test.go b/pkg/terminals/completion/shell_smoke_test.go new file mode 100644 index 000000000..dc5022185 --- /dev/null +++ b/pkg/terminals/completion/shell_smoke_test.go @@ -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) + } + } + }) +} diff --git a/pkg/terminals/completion/terminal_args.go b/pkg/terminals/completion/terminal_args.go new file mode 100644 index 000000000..3c25e28a0 --- /dev/null +++ b/pkg/terminals/completion/terminal_args.go @@ -0,0 +1,73 @@ +// Completion for the arguments of terminal subcommands -- chiefly +// `mlr help `, which completes help topics, and a topic's own argument +// (e.g. `mlr help verb ` -> verb names). `mlr completion ` 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 `: the help topics. + return Result{DirectiveCandidates, filterByPrefix(sortedUnion(help.GetTopicNames()), cur)} + case 1: + // `mlr help `: 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 +} diff --git a/pkg/terminals/completion/verbflags.go b/pkg/terminals/completion/verbflags.go new file mode 100644 index 000000000..721b5bbd8 --- /dev/null +++ b/pkg/terminals/completion/verbflags.go @@ -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 --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 +} diff --git a/pkg/terminals/help/entry.go b/pkg/terminals/help/entry.go index 6a9ed0563..5c3ec6796 100644 --- a/pkg/terminals/help/entry.go +++ b/pkg/terminals/help/entry.go @@ -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 `. +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 diff --git a/pkg/terminals/registry/registry.go b/pkg/terminals/registry/registry.go new file mode 100644 index 000000000..f27eabf72 --- /dev/null +++ b/pkg/terminals/registry/registry.go @@ -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} diff --git a/pkg/terminals/terminals.go b/pkg/terminals/terminals.go index 596bc81c2..eb3df01ab 100644 --- a/pkg/terminals/terminals.go +++ b/pkg/terminals/terminals.go @@ -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}, } } diff --git a/pkg/transformers/aaa_transformer_table.go b/pkg/transformers/aaa_transformer_table.go index fd8613d36..f328b66e9 100644 --- a/pkg/transformers/aaa_transformer_table.go +++ b/pkg/transformers/aaa_transformer_table.go @@ -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)