From 325b2d74305fdf8e2444ab80001cc2dc19f2888e Mon Sep 17 00:00:00 2001 From: mal_h Date: Sun, 12 Jul 2026 10:23:14 +1000 Subject: [PATCH] Discover nested subcommands from help output --- README.md | 39 ++++-- main.go | 222 +++++++++++++++++++++++++++++---- main_test.go | 79 +++++++++++- tests/help-completion-live.zsh | 33 ++++- 4 files changed, 336 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index d4035ae..878df3e 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ your `fpath` before `compinit` runs. | Command | What it does | |---|---| | `install TOOL` | Try a curated list of native generator invocations (`completion zsh`, `completions --shell zsh`, …) and install the first valid result. `--generator-arg` supplies a nonstandard invocation. | -| `generate TOOL` | Run `TOOL --help` (or `--help-arg`) and build a completion from the recognized flags. Never invoked implicitly. | +| `generate TOOL` | Starting at `TOOL --help` (or `--help-arg`), discover recognized subcommand sections recursively and build a context-aware completion. Never invoked implicitly. | | `update [TOOL]` | Re-run each definition's recorded generation — native command, help invocation, or import source — and replace it only if the result validates. | | `list` | Show managed definitions, their source kind, and anything they shadow. | | `inspect TOOL` | Full provenance as JSON. | @@ -90,7 +90,11 @@ you can generate one from its help output instead: ### generate — help parsing, explicitly `generate` parses GNU/BSD option tables and the two-line layouts used by -clap and Commander, including `[possible values: …]` annotations: +clap and Commander, including `[possible values: …]` annotations. When it +finds a recognized `Commands:`, `Available Commands:`, or `Subcommands:` +section, it runs each listed command with the same help argument and repeats +the process. Thus `tool run --help` supplies the options offered after +`tool run`, independently of the root options: ``` ❱ completionctl generate fnm --force # fnm has a native command; forced for illustration @@ -100,11 +104,29 @@ generated fnm '--log-level[The log level of fnm commands]:LOG_LEVEL:(quiet error info)' \ ``` -It is deliberately conservative: unrecognized grammar (subcommands, unusual -layouts) is omitted rather than guessed, positionals are never assumed to be -files, and descriptions are rendered inert — help text can't inject code into -your shell. If the tool has a working native command, `generate` refuses and -points you at `install` unless you pass `--force`. +Discovery is bounded and configurable: + +| Flag | Default | Limit | +|---|---:|---| +| `--max-depth` | `3` | deepest subcommand help level inspected (`0` runs only the root help) | +| `--max-commands` | `64` | total help commands run, including the root | +| `--timeout` | `5s` | wall-clock limit for each help command | +| `--max-output` | `4194304` | cumulative help bytes accepted across the tree | + +These settings are recorded in managed metadata, so `update` uses the same +depth, command-count, and output policy. `--timeout` remains a global runtime +override for all subprocess-based operations. + +It is deliberately conservative: only the three exact headings above and +indented `name description` rows are recognized as subcommands. Aliases, +multi-word command columns, missing descriptions, unrecognized headings, and +unusual layouts are omitted rather than guessed. Positionals are never assumed +to be files, and descriptions are rendered inert — help text can't inject code +into your shell. Reaching the depth limit leaves deeper commands listed but +does not inspect their options; exceeding the command-count, output-size, or +timeout limit fails generation without replacing the installed definition. If +the tool has a working native command, `generate` refuses and points you at +`install` unless you pass `--force`. ### Provenance @@ -182,7 +204,8 @@ tab completion works through the wrapper. - `install` and `generate` **execute the target tool** with your privileges. Execution is explicit (never a silent fallback), stdin is closed, output is - size-capped, and runs are killed after `--timeout` (default 5s) — but this + size-capped (cumulatively during recursive discovery), and runs are killed + after `--timeout` (default 5s) — but this is not a sandbox. Don't point it at binaries you don't trust. - Candidates must be non-empty, declare the intended `#compdef`, contain no control characters, and pass `zsh -n` before an atomic rename replaces the diff --git a/main.go b/main.go index bb6c214..ba0b501 100644 --- a/main.go +++ b/main.go @@ -32,6 +32,8 @@ type metadata struct { Tool, Source, Executable string Args []string ImportSource, Parser string + MaxDepth, MaxCommands int `json:",omitempty"` + MaxOutput int64 `json:",omitempty"` } type app struct { dir string @@ -136,7 +138,8 @@ func validMetadata(m metadata) bool { case "native": return m.Executable != "" && len(m.Args) > 0 && m.ImportSource == "" && m.Parser == "" case "help": - return m.Executable != "" && len(m.Args) > 0 && m.ImportSource == "" && m.Parser == "flat-options-v1" + return m.Executable != "" && len(m.Args) > 0 && m.ImportSource == "" && + (m.Parser == "flat-options-v1" || (m.Parser == "command-tree-v1" && m.MaxDepth >= 0 && m.MaxCommands > 0 && m.MaxOutput > 0)) case "import": return m.Executable == "" && len(m.Args) == 0 && m.ImportSource != "" && m.Parser == "" default: @@ -208,13 +211,16 @@ func mutation(tool string) error { return f.Close() } func capture(path string, args []string, d time.Duration) ([]byte, error) { + return captureLimit(path, args, d, maxOutput) +} +func captureLimit(path string, args []string, d time.Duration, limit int64) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), d) defer cancel() c := exec.CommandContext(ctx, path, args...) c.Stdin = nil var out, er bytes.Buffer - c.Stdout = &limited{w: &out, n: maxOutput} - c.Stderr = &limited{w: &er, n: maxOutput} + c.Stdout = &limited{w: &out, n: limit} + c.Stderr = &limited{w: &er, n: limit} e := c.Run() if ctx.Err() != nil { return nil, fmt.Errorf("command timed out") @@ -245,15 +251,15 @@ func stripErrorPrefix(s string) string { type limited struct { w io.Writer - n int + n int64 } func (l *limited) Write(p []byte) (int, error) { - if len(p) > l.n { + if int64(len(p)) > l.n { return 0, errors.New("output exceeds limit") } n, e := l.w.Write(p) - l.n -= n + l.n -= int64(n) return n, e } func validate(tool string, b []byte) error { @@ -375,7 +381,7 @@ func (a *app) install() *cobra.Command { for _, ar := range sets { b, e := capture(exe, ar, a.timeout) if e == nil { - m := metadata{1, x[0], "native", exe, ar, "", ""} + m := metadata{Version: 1, Tool: x[0], Source: "native", Executable: exe, Args: ar} if e = a.write(x[0], b, m); e == nil { fmt.Fprintln(c.OutOrStdout(), paint(useColor.out, "32", "installed"), x[0]) return nil @@ -414,12 +420,24 @@ func (a *app) updateOne(tool string) error { } return a.write(tool, b, m) } - b, e = capture(m.Executable, m.Args, a.timeout) - if e != nil { - return e - } if m.Source == "help" { - b, e = renderHelp(tool, b) + if m.Parser == "command-tree-v1" { + var tree commandTree + tree, e = a.discoverHelp(m.Executable, m.Args, discoveryLimits{m.MaxDepth, m.MaxCommands, m.MaxOutput}) + if e == nil { + b, e = renderTree(tool, tree) + } + } else { + b, e = capture(m.Executable, m.Args, a.timeout) + if e == nil { + b, e = renderHelp(tool, b) + } + } + if e != nil { + return e + } + } else { + b, e = capture(m.Executable, m.Args, a.timeout) if e != nil { return e } @@ -519,7 +537,7 @@ func (a *app) importCmd() *cobra.Command { return e } abs, _ := filepath.Abs(x[1]) - if e = a.write(x[0], b, metadata{1, x[0], "import", "", nil, abs, ""}); e != nil { + if e = a.write(x[0], b, metadata{Version: 1, Tool: x[0], Source: "import", ImportSource: abs}); e != nil { return e } fmt.Fprintln(c.OutOrStdout(), paint(useColor.out, "32", "imported"), x[0]) @@ -564,6 +582,7 @@ func firstLine(s string) string { func (a *app) generate() *cobra.Command { var help []string var force bool + limits := discoveryLimits{MaxDepth: 3, MaxCommands: 64, MaxOutput: maxOutput} c := &cobra.Command{Use: "generate TOOL", Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, x []string) error { if len(help) == 0 { help = []string{"--help"} @@ -588,15 +607,16 @@ func (a *app) generate() *cobra.Command { } } } - b, e := capture(exe, help, a.timeout) + tree, e := a.discoverHelp(exe, help, limits) if e != nil { return e } - z, e := renderHelp(x[0], b) + z, e := renderTree(x[0], tree) if e != nil { return e } - if e = a.write(x[0], z, metadata{1, x[0], "help", exe, help, "", "flat-options-v1"}); e != nil { + m := metadata{Version: 1, Tool: x[0], Source: "help", Executable: exe, Args: help, Parser: "command-tree-v1", MaxDepth: limits.MaxDepth, MaxCommands: limits.MaxCommands, MaxOutput: limits.MaxOutput} + if e = a.write(x[0], z, m); e != nil { return e } fmt.Fprintln(c.OutOrStdout(), paint(useColor.out, "32", "generated"), x[0]) @@ -604,6 +624,15 @@ func (a *app) generate() *cobra.Command { }} c.Flags().StringSliceVar(&help, "help-arg", nil, "help argument (repeatable or comma-separated)") c.Flags().BoolVar(&force, "force", false, "generate from help even when a native generator exists") + c.Flags().IntVar(&limits.MaxDepth, "max-depth", limits.MaxDepth, "maximum subcommand depth to inspect") + c.Flags().IntVar(&limits.MaxCommands, "max-commands", limits.MaxCommands, "maximum help commands to run") + c.Flags().Int64Var(&limits.MaxOutput, "max-output", limits.MaxOutput, "maximum total help output in bytes") + c.PreRunE = func(_ *cobra.Command, _ []string) error { + if limits.MaxDepth < 0 || limits.MaxCommands < 1 || limits.MaxOutput < 1 { + return errors.New("discovery limits must be positive (max-depth may be zero)") + } + return nil + } return c } @@ -642,7 +671,71 @@ type parsedOption struct { Action optionAction Choices []string } -type parsedCommand struct{ Options []parsedOption } +type parsedSubcommand struct{ Name, Description string } +type parsedCommand struct { + Options []parsedOption + Subcommands []parsedSubcommand +} + +type commandNode struct { + Path []string + Command parsedCommand +} +type commandTree struct{ Nodes []commandNode } +type discoveryLimits struct { + MaxDepth, MaxCommands int + MaxOutput int64 +} + +// discoverHelp walks only subcommands emitted by parseHelp's recognized +// sections. Arguments are passed directly to exec: no help text is ever +// evaluated or converted into a shell command. +func (a *app) discoverHelp(exe string, helpArgs []string, limits discoveryLimits) (commandTree, error) { + if limits.MaxDepth < 0 || limits.MaxCommands < 1 || limits.MaxOutput < 1 { + return commandTree{}, errors.New("invalid discovery limits") + } + queue := [][]string{{}} + seen := map[string]bool{"": true} + remaining := limits.MaxOutput + var tree commandTree + for len(queue) > 0 { + if len(tree.Nodes) >= limits.MaxCommands { + return commandTree{}, fmt.Errorf("command count exceeds limit of %d", limits.MaxCommands) + } + path := queue[0] + queue = queue[1:] + args := append(append([]string{}, path...), helpArgs...) + b, err := captureLimit(exe, args, a.timeout, remaining) + if err != nil { + return commandTree{}, fmt.Errorf("help for %s: %w", commandLabel(path), err) + } + remaining -= int64(len(b)) + command, err := parseHelp(b) + if err != nil { + return commandTree{}, fmt.Errorf("help for %s: %w", commandLabel(path), err) + } + tree.Nodes = append(tree.Nodes, commandNode{Path: append([]string{}, path...), Command: command}) + if len(path) >= limits.MaxDepth { + continue + } + for _, sub := range command.Subcommands { + child := append(append([]string{}, path...), sub.Name) + key := strings.Join(child, "\x00") + if !seen[key] { + seen[key] = true + queue = append(queue, child) + } + } + } + return tree, nil +} + +func commandLabel(path []string) string { + if len(path) == 0 { + return "root command" + } + return "subcommand " + strings.Join(path, " ") +} func parseHelp(b []byte) (parsedCommand, error) { if bytes.IndexFunc(b, func(r rune) bool { return r < 32 && r != '\n' && r != '\t' && r != '\r' }) >= 0 { @@ -650,6 +743,7 @@ func parseHelp(b []byte) (parsedCommand, error) { } var command parsedCommand lines := strings.Split(string(b), "\n") + parseSubcommandSections(lines, &command) for i, ln := range lines { parts := optionSeparator.Split(strings.TrimSpace(ln), 2) if !strings.HasPrefix(parts[0], "-") { @@ -716,12 +810,43 @@ func parseHelp(b []byte) (parsedCommand, error) { } command.Options = append(command.Options, o) } - if len(command.Options) == 0 { - return parsedCommand{}, errors.New("no recognizable options in help output") + if len(command.Options) == 0 && len(command.Subcommands) == 0 { + return parsedCommand{}, errors.New("no recognizable options or subcommands in help output") } return command, nil } +var subcommandHeadings = map[string]bool{ + "Commands:": true, "Available Commands:": true, "Subcommands:": true, +} + +// parseSubcommandSections deliberately accepts only established section +// headings and same-line "name description" rows. Usage text, prose, aliases, +// and unfamiliar layouts are never inferred as commands. +func parseSubcommandSections(lines []string, command *parsedCommand) { + for i := 0; i < len(lines); i++ { + if !subcommandHeadings[strings.TrimSpace(lines[i])] { + continue + } + for i++; i < len(lines); i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if line == strings.TrimLeft(line, " \t") { + i-- + break + } + parts := optionSeparator.Split(trimmed, 2) + if len(parts) != 2 || strings.ContainsAny(parts[0], " \t,") || !nameOK(parts[0]) || strings.TrimSpace(parts[1]) == "" { + continue + } + command.Subcommands = append(command.Subcommands, parsedSubcommand{parts[0], strings.TrimSpace(parts[1])}) + } + } +} + var ( optionSeparator = regexp.MustCompile(`\s{2,}`) optionName = regexp.MustCompile(`^--?[A-Za-z0-9][A-Za-z0-9-]*$`) @@ -788,6 +913,14 @@ func renderZsh(tool string, command parsedCommand) ([]byte, error) { if !nameOK(tool) { return nil, errors.New("invalid tool name") } + specs := renderOptionSpecs(command) + if len(specs) == 0 { + return nil, errors.New("no options") + } + return []byte("#compdef " + tool + "\n# help-derived: conservative flat options only\n_arguments \\\n " + strings.Join(specs, " \\\n ") + "\n"), nil +} + +func renderOptionSpecs(command parsedCommand) []string { var specs []string for _, o := range command.Options { if len(o.Aliases) == 0 { @@ -819,11 +952,56 @@ func renderZsh(tool string, command parsedCommand) ([]byte, error) { specs = append(specs, "'("+strings.Join(o.Aliases, " ")+")'{"+strings.Join(o.Aliases, ",")+"}'"+body+"'") } } - if len(specs) == 0 { - return nil, errors.New("no options") + return specs +} + +func renderTree(tool string, tree commandTree) ([]byte, error) { + if !nameOK(tool) || len(tree.Nodes) == 0 { + return nil, errors.New("empty command tree") } - return []byte("#compdef " + tool + "\n# help-derived: conservative flat options only\n_arguments \\\n " + strings.Join(specs, " \\\n ") + "\n"), nil + var b strings.Builder + fmt.Fprintf(&b, "#compdef %s\n# help-derived: recognized command sections and options only\n_completionctl_generated() {\n", tool) + b.WriteString(" local context state line _cc_key='' _cc_i=2\n typeset -A opt_args\n") + // Consume only an exact, recognized command prefix. Once flags or unknown + // positionals begin, dispatch remains at the last confirmed node. + b.WriteString(" while (( _cc_i < CURRENT )); do\n case \"$_cc_key|${words[_cc_i]}\" in\n") + for _, n := range tree.Nodes { + parent := strings.Join(n.Path, " ") + for _, sub := range n.Command.Subcommands { + child := strings.TrimSpace(parent + " " + sub.Name) + fmt.Fprintf(&b, " %s) _cc_key=%s ;;\n", zshCaseWord(parent+"|"+sub.Name), zshWordUnsafe(child)) + } + } + b.WriteString(" *) break ;;\n esac\n (( _cc_i++ ))\n done\n case \"$_cc_key\" in\n") + for _, n := range tree.Nodes { + key := strings.Join(n.Path, " ") + fmt.Fprintf(&b, " %s)\n", zshCaseWord(key)) + if len(n.Path) > 0 { + fmt.Fprintf(&b, " words=(\"$words[1]\" \"${words[%d,-1]}\")\n (( CURRENT -= %d ))\n", len(n.Path)+2, len(n.Path)) + } + specs := renderOptionSpecs(n.Command) + if len(n.Command.Subcommands) > 0 { + specs = append(specs, "'1:command:->commands'") + } + if len(specs) > 0 { + fmt.Fprintf(&b, " _arguments -C \\\n %s && return\n", strings.Join(specs, " \\\n ")) + } + if len(n.Command.Subcommands) > 0 { + b.WriteString(" if [[ $state == commands ]]; then\n local -a _cc_commands=(\n") + for _, sub := range n.Command.Subcommands { + fmt.Fprintf(&b, " %s\n", zshWordUnsafe(sub.Name+":"+sub.Description)) + } + b.WriteString(" )\n _describe 'command' _cc_commands\n fi\n") + } + b.WriteString(" ;;\n") + } + b.WriteString(" esac\n}\n") + b.WriteString("_completionctl_generated \"$@\"\n") + return []byte(b.String()), nil } + +func zshWordUnsafe(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } +func zshCaseWord(s string) string { return zshWordUnsafe(s) } func renderHelp(tool string, b []byte) ([]byte, error) { c, e := parseHelp(b) if e != nil { diff --git a/main_test.go b/main_test.go index 3bcd507..da0febf 100644 --- a/main_test.go +++ b/main_test.go @@ -158,13 +158,90 @@ func TestParseHelpSameLinePossibleValues(t *testing.T) { } func TestParseHelpRejectsUnsafeAndRequiresOption(t *testing.T) { - for _, input := range []string{"Commands:\n run desc", "--color desc", "--x X\x00 desc"} { + for _, input := range []string{"Tasks:\n run desc", "--color desc", "--x X\x00 desc"} { if _, err := parseHelp([]byte(input)); err == nil { t.Errorf("accepted %q", input) } } } +func TestParseRecognizedSubcommandSectionsOnly(t *testing.T) { + got, err := parseHelp([]byte("Usage: tool COMMAND\nAvailable Commands:\n run Run work\n config Manage config\n\nExamples:\n tool made-up prose\n")) + if err != nil { + t.Fatal(err) + } + want := []parsedSubcommand{{"run", "Run work"}, {"config", "Manage config"}} + if !reflect.DeepEqual(got.Subcommands, want) { + t.Fatalf("subcommands=%#v", got.Subcommands) + } + for _, unsafe := range []string{ + "Tasks:\n run Run work", + "Commands:\n run, r Run work", + "Commands:\n run", + } { + if _, err := parseHelp([]byte(unsafe)); err == nil { + t.Fatalf("accepted unrecognized command grammar %q", unsafe) + } + } +} + +func TestDiscoverHelpRecursesAndHonorsLimits(t *testing.T) { + bin := t.TempDir() + tool := filepath.Join(bin, "tool") + log := filepath.Join(bin, "calls") + script := `#!/bin/sh +printf '%s\n' "$*" >> "$FAKE_LOG" +case "$*" in + --help) printf '%s\n' 'Options:' ' --root root option' 'Commands:' ' alpha Alpha command' ;; + 'alpha --help') printf '%s\n' 'Options:' ' --child FILE child option' 'Subcommands:' ' deep Deep command' ;; + 'alpha deep --help') printf '%s\n' 'Options:' ' --leaf DIR leaf option' ;; + *) exit 4 ;; +esac +` + if err := os.WriteFile(tool, []byte(script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("FAKE_LOG", log) + a := app{timeout: time.Second} + tree, err := a.discoverHelp(tool, []string{"--help"}, discoveryLimits{MaxDepth: 2, MaxCommands: 3, MaxOutput: 4096}) + if err != nil { + t.Fatal(err) + } + if len(tree.Nodes) != 3 || strings.Join(tree.Nodes[2].Path, " ") != "alpha deep" { + t.Fatalf("tree=%#v", tree) + } + calls, _ := os.ReadFile(log) + if string(calls) != "--help\nalpha --help\nalpha deep --help\n" { + t.Fatalf("calls=%q", calls) + } + if _, err := a.discoverHelp(tool, []string{"--help"}, discoveryLimits{MaxDepth: 2, MaxCommands: 2, MaxOutput: 4096}); err == nil || !strings.Contains(err.Error(), "command count") { + t.Fatalf("command limit error=%v", err) + } + if _, err := a.discoverHelp(tool, []string{"--help"}, discoveryLimits{MaxDepth: 2, MaxCommands: 3, MaxOutput: 10}); err == nil { + t.Fatal("output limit was not enforced") + } +} + +func TestRenderTreeContainsContextSpecificOptions(t *testing.T) { + tree := commandTree{Nodes: []commandNode{ + {Command: parsedCommand{Options: []parsedOption{{Aliases: []string{"--root"}, Description: "root"}}, Subcommands: []parsedSubcommand{{"run", "Run it"}}}}, + {Path: []string{"run"}, Command: parsedCommand{Options: []parsedOption{{Aliases: []string{"--child"}, Description: "child"}}}}, + }} + b, err := renderTree("tool", tree) + if err != nil { + t.Fatal(err) + } + s := string(b) + for _, want := range []string{"'|run'", "'run')", "--root", "--child", "_describe 'command'"} { + if !strings.Contains(s, want) { + t.Errorf("missing %q in %s", want, s) + } + } + if err := validate("tool", b); err != nil { + t.Fatal(err) + } +} + func TestRenderRequiredAndOptional(t *testing.T) { c, _ := parseHelp([]byte("--required FILE r\n--optional [DIR] o")) b, e := renderZsh("tool", c) diff --git a/tests/help-completion-live.zsh b/tests/help-completion-live.zsh index 1a4d76a..4bb0d0f 100644 --- a/tests/help-completion-live.zsh +++ b/tests/help-completion-live.zsh @@ -10,10 +10,23 @@ mkdir -p "$tmp/bin" "$tmp/comp" # Single-alias, multi-alias, and choice options; two-line and same-line rows. cat >"$tmp/bin/faketool" <<'EOF' #!/bin/sh -[ "$1" = --help ] || exit 3 -printf ' -o, --output Write output\n' -printf ' --visibility \n Set thread visibility (private, unlisted, workspace, group)\n' -printf ' --mode Select mode\n' +case "$*" in + --help) + printf ' -o, --output Write output\n' + printf ' --visibility \n Set thread visibility (private, unlisted, workspace, group)\n' + printf ' --mode Select mode\n' + printf 'Commands:\n run Run a job\n' + ;; + 'run --help') + printf ' --child Child-only directory\n' + printf 'Available Commands:\n deep Go deeper\n' + ;; + 'run deep --help') + printf ' --leaf Leaf-only mode\n' + printf ' --leaf-file Leaf-only file\n' + ;; + *) exit 3 ;; +esac EOF chmod +x "$tmp/bin/faketool" PATH="$tmp/bin:$PATH" "$bin" --dir "$tmp/comp" generate faketool >/dev/null @@ -21,7 +34,7 @@ PATH="$tmp/bin:$PATH" "$bin" --dir "$tmp/comp" generate faketool >/dev/null # Markers are quoted in the input so the pattern only matches command output, # not the pty's echo of the command itself. zmodload zsh/zpty -zpty comp "TERM=vt100 HOME=$tmp/home zsh -f -i" +zpty comp "TERM=vt100 HOME=$tmp/home PATH=$tmp/bin:$PATH zsh -f -i" zpty -w comp "fpath=($tmp/comp \$fpath); autoload -Uz compinit; compinit -u -d $tmp/dump" zpty -w comp "print 'SET''UP'" zpty -r comp seen "*SETUP*" @@ -30,7 +43,6 @@ zpty -wn comp $'faketool --\t' zpty -wn comp $'\n' zpty -w comp "print 'DONE''MARKER'" zpty -r comp out "*DONEMARKER*" -zpty -d comp if [[ $out == *"invalid argument"* || $out == *"_arguments:"* ]]; then print -u2 -- "live completion reported an _arguments error:" @@ -40,4 +52,13 @@ fi for want in -- --output --visibility --mode; do [[ $out == *"$want"* ]] || { print -u2 -- "missing $want in completion listing:"; print -u2 -- "$out"; exit 1; } done + +# The dispatcher selects options from the confirmed nested command path. +zpty -wn comp $'faketool run deep --\t' +zpty -wn comp $'\n' +zpty -w comp "print 'LEAF''MARKER'" +zpty -r comp nested "*LEAFMARKER*" +zpty -d comp +[[ $nested == *--leaf* ]] || { print -u2 -- "missing nested --leaf completion:"; print -u2 -- "$nested"; exit 1; } +[[ $nested != *--output* && $nested != *--child* ]] || { print -u2 -- "completion leaked options from another context:"; print -u2 -- "$nested"; exit 1; } print ok