diff --git a/README.md b/README.md index cbb0cff5..39dad5c2 100644 --- a/README.md +++ b/README.md @@ -69,13 +69,14 @@ Alternatively, `tea auth login --host https://your-instance --oauth` authorizes ### Shell completion -If you installed from source or the package does not provide the completions with it you can add them yourself with `tea completion ` command. To generate the completions run one of the following commands depending on your shell. +If you installed from source or the package does not provide the completions with it you can add them yourself with `tea completion ` command. Completion offers commands, flags, flag values such as `--state` and `--json` fields, and values from your instance such as repositories. Add the line for your shell to its startup file: ```shell -# .bashrc -source <(tea completion bash) +# ~/.bashrc (on macOS, ~/.bash_profile) +eval "$(tea completion bash)" -# .zshrc +# ~/.zshrc, after compinit +autoload -U compinit && compinit source <(tea completion zsh) # fish @@ -85,6 +86,8 @@ tea completion fish > ~/.config/fish/completions/tea.fish tea completion powershell | Out-String | Invoke-Expression ``` +The bash script needs the bash-completion package. On macOS, install `bash-completion` with Homebrew for the system bash 3.2 (or `bash-completion@2` for a Homebrew bash 4.2+), and use `eval` as above: bash 3.2 cannot `source <(...)`. Without `compinit`, zsh reports `command not found: compdef`. `tea completion --help` shows these steps and how to install the script system-wide. + ### Man Page The hidden command `tea man` can be used to generate the `tea` man page. diff --git a/internal/app/alias_completion_test.go b/internal/app/alias_completion_test.go index 37fe78d5..d64d1c2f 100644 --- a/internal/app/alias_completion_test.go +++ b/internal/app/alias_completion_test.go @@ -4,9 +4,22 @@ package app import ( + "bytes" "context" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "strings" "testing" + "time" + "gitea.dev/tea/internal/auth" + "gitea.dev/tea/internal/clierr" + "gitea.dev/tea/internal/services" + "gitea.dev/tea/internal/settings" + "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/stretchr/testify/require" ) @@ -24,9 +37,316 @@ func TestAliasCompletionUsesBuiltInCommandsAndFlags(t *testing.T) { {[]string{"__complete", "listing", "--auth"}, "--author"}, {[]string{"__completeNoDesc", "--no-input", "listing", "--ba"}, "--base"}, {[]string{"__complete", "inspect", "7", "--j"}, "--json"}, + {[]string{"__complete", "listing", "--json", "nu"}, "number"}, } { out.Reset() require.NoError(t, Execute(context.Background(), deps, tc.args)) require.Contains(t, out.String(), tc.want) } } + +// complete runs `tea __complete args...` and returns the candidates and the directive. +func complete(t *testing.T, deps Dependencies, out *bytes.Buffer, args ...string) ([]string, cobra.ShellCompDirective) { + t.Helper() + out.Reset() + require.NoError(t, Execute(context.Background(), deps, append([]string{cobra.ShellCompRequestCmd}, args...))) + lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n") + directive, err := strconv.Atoi(strings.TrimPrefix(lines[len(lines)-1], ":")) + require.NoError(t, err, out.String()) + return lines[:len(lines)-1], cobra.ShellCompDirective(directive) +} + +func TestEnumFlagsShareValidationAndCompletion(t *testing.T) { + deps, out, _ := testDeps(t) + var state string + var events []string + run := func(args ...string) error { + root := NewRoot(deps) + probe := &cobra.Command{Use: "probe", RunE: func(*cobra.Command, []string) error { return nil }} + enumFlag(probe, &state, "state", "s", "open", "Filter by state", "open", "closed", "all") + probe.Flags().StringSliceVar(&events, "events", nil, "Events to deliver") + enumValues(probe, "events", "push", "pull_request", "release") + root.AddCommand(probe) + root.SetArgs(args) + out.Reset() + return root.ExecuteContext(context.Background()) + } + require.NoError(t, run("probe", "-s", "CLOSED", "--events", "Push,release")) + require.Equal(t, "closed", state) + require.Equal(t, []string{"push", "release"}, events) + err := run("probe", "--state", "merged") + require.Equal(t, clierr.Usage, clierr.ExitCode(err)) + require.EqualError(t, err, "--state must be open, closed, or all") + require.EqualError(t, run("probe", "--events", "push,nope"), "--events must be push, pull_request, or release") + require.NoError(t, run("__complete", "probe", "--state", "c")) + require.Equal(t, "closed\n:4\n", out.String()) + require.NoError(t, run("__complete", "probe", "--events", "push,")) + require.Equal(t, "push,pull_request\npush,release\n:6\n", out.String()) + probe := &cobra.Command{Use: "probe"} + enumFlag(probe, &state, "state", "s", "open", "Filter by state", "open", "closed", "all") + require.Equal(t, "Filter by state {open|closed|all}", probe.Flag("state").Usage) + require.Equal(t, "s", probe.Flag("state").Shorthand) +} + +func TestJSONFieldCompletion(t *testing.T) { + deps, out, _ := testDeps(t) + values, directive := complete(t, deps, out, "issue", "list", "--json", "") + require.Equal(t, jsonFields[services.Issue](), values) + require.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive) + for args, want := range map[string][]string{ + "issue list --json number,ti": {"number,title"}, + "issue list --json=num": {"number"}, + "issue list --json=state,ti": {"state,title"}, + "pr view --json head_r": {"head_remote_deleted", "head_repo"}, + "version --json ": {}, + } { + values, _ := complete(t, deps, out, strings.Split(args, " ")...) + require.Equal(t, want, values, args) + } + values, _ = complete(t, deps, out, "issue", "list", "--json", "number,") + require.Contains(t, values, "number,title") + require.NotContains(t, values, "number,number") +} + +func TestCompletionOffersPathsOnlyWherePathsAreExpected(t *testing.T) { + deps, out, _ := testDeps(t) + for args, want := range map[string]cobra.ShellCompDirective{ + "issue view ": cobra.ShellCompDirectiveNoFileComp, + "issue view 5 ": cobra.ShellCompDirectiveNoFileComp, + "issue list --state ": cobra.ShellCompDirectiveNoFileComp, + "issue create --body-file ": cobra.ShellCompDirectiveDefault, + "issue create --attachment ": cobra.ShellCompDirectiveDefault, + "api --input ": cobra.ShellCompDirectiveDefault, + "auth login --ssh-key ": cobra.ShellCompDirectiveDefault, + "ssh-key create ": cobra.ShellCompDirectiveDefault, + "release asset upload ": cobra.ShellCompDirectiveNoFileComp, + "release asset upload v1 ": cobra.ShellCompDirectiveDefault, + "release asset upload v1 a.txt ": cobra.ShellCompDirectiveDefault, + "repo clone alice/project ": cobra.ShellCompDirectiveFilterDirs, + "repo clone alice/project dir ": cobra.ShellCompDirectiveNoFileComp, + "--cwd ": cobra.ShellCompDirectiveFilterDirs, + "--jq ": cobra.ShellCompDirectiveNoFileComp, + } { + _, directive := complete(t, deps, out, strings.Split(args, " ")...) + require.Equal(t, want, directive, args) + } + // No other argument or flag value falls back to file names. + walkCommands(NewRoot(deps), func(cmd *cobra.Command) { + if !cmd.HasParent() || !cmd.IsAvailableCommand() { + return + } + path := strings.Fields(cmd.CommandPath())[1:] + slots := argSlots(cmd.Use) + _, directive := complete(t, deps, out, append(path, "")...) + require.Equal(t, len(slots) > 0 && slots[0].kind == "file", directive == cobra.ShellCompDirectiveDefault, "%s : %d", cmd.CommandPath(), directive) + for _, flags := range []*pflag.FlagSet{cmd.LocalNonPersistentFlags(), cmd.PersistentFlags()} { + flags.VisitAll(func(flag *pflag.Flag) { + if flag.NoOptDefVal != "" || flag.Hidden { + return + } + _, directive := complete(t, deps, out, append(path, "--"+flag.Name, "")...) + path := strings.HasSuffix(flag.Name, "-file") || slices.Contains(pathFlags, flag.Name) + require.Equal(t, path, directive == cobra.ShellCompDirectiveDefault, "%s --%s : %d", cmd.CommandPath(), flag.Name, directive) + }) + } + }) +} + +func TestArgSlots(t *testing.T) { + for use, want := range map[string][]argSlot{ + "list": {}, + "view ": {{}}, + "upload ...": {{}, {kind: "file", variadic: true}}, + "clone [directory]": {{}, {kind: "directory"}}, + "create ": {{kind: "file"}}, + "review [ | | ]": {{}}, + "man [command...]": {{variadic: true}}, + "set ": {{}, {}}, + } { + require.Equal(t, want, append([]argSlot{}, argSlots(use)...), use) + } +} + +func TestOfflineCompletions(t *testing.T) { + deps, out, _ := testDeps(t) + deps.Git = &fakeGit{results: map[string]string{ + `config --get-regexp ^remote\..*\.url$`: "remote.origin.url https://gitea.example.com/alice/project.git\nremote.my.fork.url git@gitea.example.com:bob/project.git\n", + "for-each-ref --format=%(refname:short) refs/heads": "main\nfeature/login\n", + }} + configureAccounts(t, deps, "https://gitea.example.com", "alice", "alice", "bob") + require.NoError(t, Execute(context.Background(), deps, []string{"alias", "set", "co", "pr checkout"})) + for args, want := range map[string][]string{ + "--host ": {"https://gitea.example.com\talice, bob"}, + "--user ": {"alice\thttps://gitea.example.com (active)", "bob\thttps://gitea.example.com"}, + "--remote ": {"origin\thttps://gitea.example.com/alice/project.git", "my.fork\tgit@gitea.example.com:bob/project.git"}, + } { + values, directive := complete(t, deps, out, strings.Split(args, " ")...) + require.Equal(t, want, values, args) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive, args) + } + a := &application{deps: deps} + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + for _, tc := range []struct { + complete cobra.CompletionFunc + args []string + prefix string + want []string + }{ + {a.completeAliases, nil, "", []string{"co\tpr checkout"}}, + {a.completeConfig, nil, "git", []string{"git_protocol"}}, + {a.completeConfig, []string{"color"}, "a", []string{"auto", "always"}}, + {a.completeConfig, []string{"default_host"}, "", []string{"https://gitea.example.com\talice, bob"}}, + {a.completeConfig, []string{"color", "auto"}, "", []string{}}, + {a.completeLocalBranches, nil, "f", []string{"feature/login\tlocal branch"}}, + } { + values, _ := tc.complete(cmd, tc.args, tc.prefix) + require.Equal(t, tc.want, append([]string{}, values...), "%v %q", tc.args, tc.prefix) + } +} + +func TestRepositoryCompletionUsesRemotesAndInstance(t *testing.T) { + var tokens []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokens = append(tokens, r.Header.Get("Authorization")) + switch r.URL.Path { + case "/api/v1/user/repos", "/api/v1/users/alice/repos": + _, _ = w.Write([]byte(`[{"full_name":"alice/project","description":"Demo"},{"full_name":"alice/other"},{"full_name":"alice/x\u001b[2J"}]`)) + case "/api/v1/users/alice": + _, _ = w.Write([]byte(`{"id":1,"login":"alice"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + deps, out, _ := testDeps(t) + deps.Git = &fakeGit{results: map[string]string{ + `config --get-regexp ^remote\..*\.url$`: "remote.origin.url " + server.URL + "/alice/project.git\nremote.github.url https://github.com/cli/cli.git\n", + }} + configureAccounts(t, deps, server.URL, "alice", "alice") + values, directive := complete(t, deps, out, "-R", "") + require.Equal(t, []string{"alice/project\tremote origin", "alice/other"}, values) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + require.Equal(t, []string{"token alice-token"}, tokens) + // An owner before the slash lists that owner's repositories. + values, _ = complete(t, deps, out, "issue", "list", "-R", "alice/o") + require.Equal(t, []string{"alice/other"}, values) + require.Contains(t, tokens, "token alice-token") +} + +func TestAPICompletionNeverRefreshesOrFails(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + deps, out, _ := testDeps(t) + now := time.Now() + deps.Now = func() time.Time { return now } + require.NoError(t, testManager(t, deps).Register(server.URL, "alice", settings.StoreFile, settings.AuthOAuth, auth.HostOptions{}, auth.Credential{AccessToken: "expired", RefreshToken: "refresh", ExpiresAt: now.Add(-time.Minute)})) + values, directive := complete(t, deps, out, "-R", "") + require.Empty(t, values) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + require.Zero(t, requests, "an expired OAuth token must not be refreshed while completing") + credential, err := deps.Credentials.Load(server.URL, "alice", settings.StoreFile) + require.NoError(t, err) + require.Equal(t, "refresh", credential.RefreshToken) + configureAccounts(t, deps, server.URL, "alice", "alice") + values, directive = complete(t, deps, out, "-R", "") + require.Empty(t, values) + require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + require.Equal(t, 1, requests, "completion requests are not retried") +} + +func TestInstanceCompletions(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/repos/alice/project/pulls": + require.Equal(t, "open", r.URL.Query().Get("state")) + _, _ = w.Write([]byte(`[{"number":13,"title":"Add login support"},{"number":2,"title":"Fix\u001b]0;pwned\u0007 docs"},{"number":17,"title":"Improve greeting"}]`)) + case "/api/v1/repos/alice/project/labels": + _, _ = w.Write([]byte(`[{"id":1,"name":"bug","description":"Something is broken"},{"id":2,"name":"docs"},{"id":3,"name":"bad\u001b[31m"}]`)) + case "/api/v1/orgs/alice/labels": + w.WriteHeader(http.StatusNotFound) + case "/api/v1/repos/alice/project/assignees": + _, _ = w.Write([]byte(`[{"login":"alice","full_name":"Alice"},{"login":"bob"}]`)) + case "/api/v1/user/orgs": + _, _ = w.Write([]byte(`[{"id":4,"username":"acme","full_name":"Acme Inc"}]`)) + case "/api/v1/orgs/acme/teams": + _, _ = w.Write([]byte(`[{"id":2,"name":"developers","description":"Developers"}]`)) + case "/api/v1/repos/alice/project/issues": + require.Equal(t, "issues", r.URL.Query().Get("type")) + _, _ = w.Write([]byte(`[{"number":4,"title":"Crash when config is missing"}]`)) + case "/api/v1/repos/alice/project/milestones": + _, _ = w.Write([]byte(`[{"id":1,"title":"v1.0","description":"First release"}]`)) + case "/api/v1/repos/alice/project/branches": + _, _ = w.Write([]byte(`[{"name":"main","protected":true},{"name":"docs/guide"}]`)) + case "/api/v1/repos/alice/project/tags": + _, _ = w.Write([]byte(`[{"name":"v0.1.0"}]`)) + case "/api/v1/repos/alice/project/releases": + _, _ = w.Write([]byte(`[{"id":1,"tag_name":"v0.1.0","name":"First release"}]`)) + case "/api/v1/repos/alice/project/actions/workflows": + _, _ = w.Write([]byte(`{"total_count":1,"workflows":[{"id":"ci.yml","name":"CI"}]}`)) + case "/api/v1/repos/alice/project/actions/runs": + _, _ = w.Write([]byte(`{"workflow_runs":[{"id":47,"status":"completed","conclusion":"failure","display_title":"Add workflows"}]}`)) + case "/api/v1/repos/alice/project/actions/secrets": + _, _ = w.Write([]byte(`[{"name":"DEPLOY_TOKEN","description":"Deploy key"}]`)) + case "/api/v1/orgs/acme/actions/variables": + _, _ = w.Write([]byte(`[{"name":"REGION","data":"eu"}]`)) + default: + t.Errorf("unexpected request %s", r.URL.Path) + w.WriteHeader(http.StatusTeapot) + } + })) + defer server.Close() + deps, _, _ := serverDeps(t, server.URL) + a := &application{deps: deps} + a.options.repo = "alice/project" + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + for _, tc := range []struct { + name string + complete cobra.CompletionFunc + args []string + prefix string + want []string + directive cobra.ShellCompDirective + }{ + {"pulls by prefix", a.completePulls("open"), nil, "1", []string{"13\tAdd login support", "17\tImprove greeting"}, cobra.ShellCompDirectiveNoFileComp}, + {"pulls skip given arguments and sanitize titles", a.completePulls("open"), []string{"13", "17"}, "", []string{"2\tFix]0;pwned docs"}, cobra.ShellCompDirectiveNoFileComp}, + {"labels are comma-separated", a.completeLabels(), nil, "bug,", []string{"bug,docs"}, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp}, + {"labels with descriptions", a.completeLabels(), nil, "b", []string{"bug\tSomething is broken"}, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp}, + {"users", a.completeUsers(), nil, "", []string{"alice\tAlice", "bob"}, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp}, + {"teams start with organizations", a.completeTeams(), nil, "", []string{"acme/\tAcme Inc"}, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp}, + {"teams of an organization", a.completeTeams(), nil, "acme/d", []string{"acme/developers\tDevelopers (ID 2)"}, cobra.ShellCompDirectiveNoFileComp}, + {"issues", a.completeIssues("open"), nil, "", []string{"4\tCrash when config is missing"}, cobra.ShellCompDirectiveNoFileComp}, + {"milestones", a.completeMilestones(), nil, "", []string{"v1.0\tFirst release"}, cobra.ShellCompDirectiveNoFileComp}, + {"branches", a.completeBranches(), nil, "", []string{"main\tprotected", "docs/guide"}, cobra.ShellCompDirectiveNoFileComp}, + {"tags", a.completeTags(), nil, "v", []string{"v0.1.0"}, cobra.ShellCompDirectiveNoFileComp}, + {"releases", a.completeReleases(), nil, "", []string{"v0.1.0\tFirst release"}, cobra.ShellCompDirectiveNoFileComp}, + {"workflows", a.completeWorkflows(), nil, "", []string{"ci.yml\tCI"}, cobra.ShellCompDirectiveNoFileComp}, + {"runs", a.completeRuns(), nil, "4", []string{"47\tcompleted · failure · Add workflows"}, cobra.ShellCompDirectiveNoFileComp}, + {"repository secrets", a.completeSecrets(func() services.ActionScope { return services.ActionScope{} }), nil, "", []string{"DEPLOY_TOKEN\tDeploy key"}, cobra.ShellCompDirectiveNoFileComp}, + {"organization variables", a.completeVariables(func() services.ActionScope { return services.ActionScope{Organization: "acme"} }), nil, "", []string{"REGION"}, cobra.ShellCompDirectiveNoFileComp}, + } { + values, directive := tc.complete(cmd, tc.args, tc.prefix) + require.Equal(t, tc.want, values, tc.name) + require.Equal(t, tc.directive, directive, tc.name) + } +} + +func TestCompletionInstallHelp(t *testing.T) { + deps, out, _ := testDeps(t) + for shell, want := range map[string]string{ + "bash": "$ eval \"$(tea completion bash)\"\n", + "zsh": "$ autoload -U compinit && compinit\n", + "fish": "$ tea completion fish > ~/.config/fish/completions/tea.fish\n", + "powershell": "$ tea completion powershell | Out-String | Invoke-Expression\n", + } { + out.Reset() + require.NoError(t, Execute(context.Background(), deps, []string{"completion", shell, "--help"})) + require.Contains(t, out.String(), want, shell) + require.NotContains(t, out.String(), "source <(tea completion bash)\n", shell) + } +} diff --git a/internal/app/app.go b/internal/app/app.go index a371b231..f6008f59 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -118,10 +118,19 @@ func NewRoot(deps Dependencies) *cobra.Command { flags.BoolVar(&a.options.interactive, "interactive", false, "Allow prompts with machine output") flags.BoolVarP(&a.options.yes, "yes", "y", false, "Accept confirmations without prompting") flags.BoolVar(&a.options.debug, "debug", false, "Log the method, URL, status, and duration of each HTTP request to stderr") + _ = root.RegisterFlagCompletionFunc("host", a.completeHosts) + _ = root.RegisterFlagCompletionFunc("user", a.completeAccounts) + _ = root.RegisterFlagCompletionFunc("repo", a.completeRepos()) + _ = root.RegisterFlagCompletionFunc("remote", a.completeRemotes) + _ = root.RegisterFlagCompletionFunc("cwd", completeDirectories) + _ = root.RegisterFlagCompletionFunc("json", completeJSONFields) root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { if err := a.checkOutputFlags(cmd); err != nil { return err } + if err := validateEnums(cmd); err != nil { + return err + } if cmd.Flags().Changed("sudo") { if a.options.sudo == "" || strings.IndexFunc(a.options.sudo, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 { return clierr.Wrap(clierr.Usage, errors.New("--sudo requires a username")) @@ -151,8 +160,10 @@ func NewRoot(deps Dependencies) *cobra.Command { root.SetHelpCommand(a.helpCommand()) root.InitDefaultHelpCmd() root.InitDefaultCompletionCmd() + completionInstallHelp(root) root.SetVersionTemplate("{{.Version}}\n") root.SetHelpFunc(a.writeHelp) + root.CompletionOptions.SetDefaultShellCompDirective(cobra.ShellCompDirectiveNoFileComp) root.AddGroup( &cobra.Group{ID: "core", Title: "Core commands"}, &cobra.Group{ID: "actions", Title: "Actions commands"}, @@ -166,6 +177,7 @@ func NewRoot(deps Dependencies) *cobra.Command { } rejectUnknownSubcommands(root) addVerbAliases(root) + completeDefaults(root) return root } @@ -303,6 +315,9 @@ func Execute(ctx context.Context, deps Dependencies, args []string) error { } args = joinJSONFields(root, slices.Concat(args[:commandAt], expanded)) } + if completion { + args = completeJSONValue(args) + } } if loadErr == nil { for name, expansion := range snapshot.Preferences.Aliases { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index afd4f839..00d8b814 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -208,6 +208,7 @@ func TestRootHelpGroupsCommandsAndTopics(t *testing.T) { "\n repo: ", "\nACTIONS COMMANDS\n run: ", "\nADDITIONAL COMMANDS\n admin: ", + "\n completion: Generate shell completion scripts\n", "\nALIAS COMMANDS\n co: Alias for \"pr checkout\"\n", "\nHELP TOPICS\n environment: ", "\nFLAGS\n --help Show help for command\n --version Show tea version\n", @@ -302,6 +303,10 @@ func TestHelpTopicsAndUnknownTopics(t *testing.T) { require.EqualError(t, err, want, args) require.Empty(t, out.String(), args) } + values, _ := complete(t, deps, out, "help", "e") + require.Equal(t, []string{"environment\tEnvironment variables and global flags", "exit-codes\tExit codes used by tea"}, values) + values, _ = complete(t, deps, out, "help", "issue", "l") + require.Equal(t, []string{"list\tList repository issues"}, values) } func TestVerbAndFlagAliases(t *testing.T) { @@ -423,10 +428,6 @@ const ( branch rename branch unprotect branch view - completion bash - completion fish - completion powershell - completion zsh config get config list config path diff --git a/internal/app/completion.go b/internal/app/completion.go new file mode 100644 index 00000000..09049780 --- /dev/null +++ b/internal/app/completion.go @@ -0,0 +1,687 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package app + +import ( + "context" + "encoding/json" + "errors" + "maps" + "net/http" + "slices" + "strconv" + "strings" + "time" + + "gitea.dev/tea/internal/auth" + "gitea.dev/tea/internal/services" + "gitea.dev/tea/internal/settings" + "gitea.dev/tea/internal/terminal" + "gitea.dev/tea/internal/transport" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const ( + // completionLimit caps how many values an instance completer fetches and offers. + completionLimit = 50 + // completionTimeout bounds the Git and API calls behind one completion. + completionTimeout = 2 * time.Second + // enumAnnotation holds the values of an enum flag, which validation and completion share. + enumAnnotation = "tea.enum" +) + +// enumFlag registers a string flag that accepts one of values, case-insensitively, completes them, +// and lists them in its usage, as in "Filter by state {open|closed|all}". +func enumFlag(cmd *cobra.Command, p *string, name, shorthand, value, usage string, values ...string) { + cmd.Flags().StringVarP(p, name, shorthand, value, usage+" {"+strings.Join(values, "|")+"}") + enumValues(cmd, name, values...) +} + +// enumValues restricts the values of the existing flag name, so that validation and completion share +// one list. A slice flag accepts and completes comma-separated values. +func enumValues(cmd *cobra.Command, name string, values ...string) { + _ = cmd.Flags().SetAnnotation(name, enumAnnotation, values) + complete := completeValues(values...) + if _, ok := cmd.Flags().Lookup(name).Value.(pflag.SliceValue); ok { + complete = commaSeparated(complete) + } + _ = cmd.RegisterFlagCompletionFunc(name, complete) +} + +// validateEnums rejects enum flag values outside their lists and stores the listed spelling of each +// value, so that values match case-insensitively. +func validateEnums(cmd *cobra.Command) error { + var err error + cmd.Flags().VisitAll(func(flag *pflag.Flag) { + allowed := flag.Annotations[enumAnnotation] + if err != nil || allowed == nil || !flag.Changed { + return + } + slice, isSlice := flag.Value.(pflag.SliceValue) + given := []string{flag.Value.String()} + if isSlice { + given = slice.GetSlice() + } + for i, value := range given { + at := slices.IndexFunc(allowed, func(v string) bool { return strings.EqualFold(v, strings.TrimSpace(value)) }) + if at < 0 { + err = oneOf(flag.Name, value, allowed...) + return + } + given[i] = allowed[at] + } + if isSlice { + err = slice.Replace(given) + } else { + err = flag.Value.Set(given[0]) + } + }) + return err +} + +// completeValues completes the given values. +func completeValues(values ...string) cobra.CompletionFunc { + return func(_ *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + return matching(values, toComplete), cobra.ShellCompDirectiveNoFileComp + } +} + +// matching keeps the candidates whose value starts with prefix, at most completionLimit of them. +func matching(candidates []cobra.Completion, prefix string) []cobra.Completion { + result := []cobra.Completion{} + for _, candidate := range candidates { + if value, _, _ := strings.Cut(candidate, "\t"); strings.HasPrefix(value, prefix) { + result = append(result, candidate) + if len(result) == completionLimit { + break + } + } + } + return result +} + +// commaSeparated completes the last entry of a comma-separated value with complete, without offering +// entries that are already given, and keeps the cursor after it so that another comma can follow. +func commaSeparated(complete cobra.CompletionFunc) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + done := toComplete[:strings.LastIndexByte(toComplete, ',')+1] + given := strings.Split(done, ",") + values, directive := complete(cmd, args, toComplete[len(done):]) + result := make([]cobra.Completion, 0, len(values)) + for _, value := range values { + if name, _, _ := strings.Cut(value, "\t"); !slices.Contains(given, name) { + result = append(result, done+value) + } + } + return result, directive | cobra.ShellCompDirectiveNoSpace + } +} + +// mergeCompletions offers the candidates of every completer in order, each value once. +func mergeCompletions(completers ...cobra.CompletionFunc) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + result, seen := []cobra.Completion{}, map[string]bool{} + for _, complete := range completers { + values, _ := complete(cmd, args, toComplete) + for _, value := range values { + if name, _, _ := strings.Cut(value, "\t"); !seen[name] { + seen[name] = true + result = append(result, value) + } + } + } + return matching(result, toComplete), cobra.ShellCompDirectiveNoFileComp + } +} + +// completeJSONFields completes the comma-separated fields that --json can select for the command. +var completeJSONFields = commaSeparated(func(cmd *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + if cmd.Annotations["tea.text"] != "" || cmd.Annotations["tea.group"] != "" { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return matching(commandOutputFields(cmd), toComplete), cobra.ShellCompDirectiveNoFileComp +}) + +// completeJSONValue completes the word after a bare --json as its fields. Without this, cobra completes +// that word as a positional argument, because the value of --json is optional. +func completeJSONValue(args []string) []string { + if n := len(args); n >= 2 && args[n-2] == "--json" && !strings.HasPrefix(args[n-1], "-") { + return slices.Concat(args[:n-2], []string{"--json=" + args[n-1]}) + } + return args +} + +// describe turns items into completion candidates, each a value with a one-line description. Server +// text cannot drive the terminal: values with control characters are skipped, descriptions sanitized. +func describe[T any](items []T, candidate func(T) (value, description string)) []cobra.Completion { + result := make([]cobra.Completion, 0, len(items)) + for _, item := range items { + value, description := candidate(item) + if value == "" || terminal.SafeLine(value) != value { + continue + } + if description = strings.Join(strings.Fields(terminal.SafeLine(description)), " "); description != "" { + value = cobra.CompletionWithDesc(value, description) + } + result = append(result, value) + } + return result +} + +// completionContext bounds the work behind one completion. +func completionContext(cmd *cobra.Command) (context.Context, context.CancelFunc) { + return context.WithTimeout(cmd.Context(), completionTimeout) +} + +// completeHosts completes the configured instances with their accounts. +func (a *application) completeHosts(_ *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + snapshot, err := a.deps.Settings.Load() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + hosts := slices.Sorted(maps.Keys(snapshot.Hosts.Hosts)) + return matching(describe(hosts, func(host string) (string, string) { + return host, strings.Join(slices.Sorted(maps.Keys(snapshot.Hosts.Hosts[host].Users)), ", ") + }), toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// completeAccounts completes the accounts of the selected instance, or of every instance when +// none is selected. +func (a *application) completeAccounts(cmd *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + ctx, cancel := completionContext(cmd) + defer cancel() + snapshot, err := a.deps.Settings.Load() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + hosts := snapshot.Hosts.Hosts + if t, err := a.resolveTarget(ctx, false, false); err == nil { + hosts = map[string]settings.Host{t.Host: hosts[t.Host]} + } + accounts := []cobra.Completion{} + for _, host := range slices.Sorted(maps.Keys(hosts)) { + accounts = append(accounts, describe(slices.Sorted(maps.Keys(hosts[host].Users)), func(user string) (string, string) { + if hosts[host].User == user { + return user, host + " (active)" + } + return user, host + })...) + } + return matching(accounts, toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// gitRemotes lists the remotes of the working directory with their URLs, in configuration order. +func (a *application) gitRemotes(ctx context.Context) [][2]string { + out, err := a.gitProbe().Output(ctx, a.options.cwd, false, "config", "--get-regexp", `^remote\..*\.url$`) + if err != nil { + return nil + } + var remotes [][2]string + for line := range strings.Lines(out) { + if key, url, ok := strings.Cut(strings.TrimSpace(line), " "); ok { + remotes = append(remotes, [2]string{strings.TrimSuffix(strings.TrimPrefix(key, "remote."), ".url"), url}) + } + } + return remotes +} + +// completeRemotes completes the Git remotes of the working directory. +func (a *application) completeRemotes(cmd *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + ctx, cancel := completionContext(cmd) + defer cancel() + return matching(describe(a.gitRemotes(ctx), func(remote [2]string) (string, string) { return remote[0], remote[1] }), toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// completeLocalBranches completes the branches of the local checkout. +func (a *application) completeLocalBranches(cmd *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + ctx, cancel := completionContext(cmd) + defer cancel() + out, err := a.gitProbe().Output(ctx, a.options.cwd, false, "for-each-ref", "--format=%(refname:short)", "refs/heads") + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return matching(describe(strings.Fields(out), func(branch string) (string, string) { return branch, "local branch" }), toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// completeAliases completes the configured alias names with their expansions. +func (a *application) completeAliases(_ *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + snapshot, err := a.deps.Settings.Load() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + aliases := snapshot.Preferences.Aliases + return matching(describe(slices.Sorted(maps.Keys(aliases)), func(name string) (string, string) { return name, aliases[name] }), toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// configValues lists the values of the settings that accept a fixed set. +var configValues = map[string][]string{ + "git_protocol": {"https", "ssh"}, + "prompt": {"enabled", "disabled"}, + "color": {"auto", "always", "never"}, + "insecure": {"true", "false"}, + "ssh_agent": {"true", "false"}, +} + +// completeConfig completes a setting name, then its value: the fixed values, the configured instances +// for default_host, or the Git remotes for remote. +func (a *application) completeConfig(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + switch { + case len(args) == 0: + return matching(configKeys, toComplete), cobra.ShellCompDirectiveNoFileComp + case len(args) > 1: + return nil, cobra.ShellCompDirectiveNoFileComp + case args[0] == "default_host": + return a.completeHosts(cmd, args, toComplete) + case args[0] == "remote": + return a.completeRemotes(cmd, args, toComplete) + } + return matching(configValues[args[0]], toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// apiCompletion completes the values that list fetches from the selected instance; requireRepo +// selects whether list needs a repository. It never prompts, refreshes an OAuth token or retries, +// and gives up after completionTimeout: failures complete nothing rather than file names. Values +// already given as arguments are not offered again. +func (a *application) apiCompletion(requireRepo bool, list func(ctx context.Context, t target, toComplete string) ([]cobra.Completion, error)) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + ctx, cancel := completionContext(cmd) + defer cancel() + t, err := a.completionTarget(ctx, requireRepo) + var values []cobra.Completion + if err == nil { + values, err = list(ctx, t, toComplete) + } + if err != nil { + cobra.CompDebugln("tea: "+err.Error(), false) + return nil, cobra.ShellCompDirectiveNoFileComp + } + values = slices.DeleteFunc(values, func(value cobra.Completion) bool { + name, _, _ := strings.Cut(value, "\t") + return slices.Contains(args, name) + }) + return matching(values, toComplete), cobra.ShellCompDirectiveNoFileComp + } +} + +// completionTarget resolves an API completion's target like resolve, but never prompts or rotates +// credentials: an expired OAuth token or an encrypted SSH key completes nothing. +func (a *application) completionTarget(ctx context.Context, requireRepo bool) (target, error) { + t, err := a.resolveTarget(ctx, requireRepo, false) + if err != nil { + return t, err + } + var signer *auth.HTTPSign + token := a.deps.Getenv("GITEA_TOKEN") + if token != "" { + if instance, err := settings.NormalizeHost(a.tokenInstance()); err != nil || instance != t.Host { + return t, errors.New("GITEA_TOKEN belongs to another instance") + } + } else { + account, exists := t.host.Users[t.User] + switch { + case !exists: + return t, errors.New("no account selected") + case account.Tokenless(): + if signer, err = auth.SignerForAccount(account, nil); err != nil { + return t, err + } + default: + credential, err := a.deps.Credentials.Load(t.Host, t.User, account.CredentialStore) + if err != nil { + return t, err + } + if !credential.ExpiresAt.IsZero() && !a.deps.Now().Before(credential.ExpiresAt) { + return t, errors.New("access token expired") + } + token = credential.AccessToken + } + } + t.client = a.deps.Client(t.Host, token, t.host.Insecure) + if signer != nil { + t.client.Signer = signer + } + t.client.Retries, t.client.Sudo = 0, a.options.sudo + return t, nil +} + +// fetchJSON decodes the response to a GET of endpoint, for values that no service lists. +func fetchJSON(ctx context.Context, client *transport.Client, endpoint string, out any) error { + return services.API{Client: client}.Visit(ctx, services.APIOptions{Method: http.MethodGet, Endpoint: endpoint}, func(response *http.Response) error { + if response.StatusCode != http.StatusOK { + return nil // Visit reports the status. + } + return json.NewDecoder(response.Body).Decode(out) + }) +} + +// completeIssues completes issue numbers in state (open, closed or all) with their titles. +func (a *application) completeIssues(state string) cobra.CompletionFunc { + return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + items, _, err := services.Issues{Client: t.client, Repository: t.Repo}.List(ctx, services.ListOptions{State: state, Kind: "issues", Limit: completionLimit, Page: 1}) + return describe(items, func(item services.Issue) (string, string) { return strconv.FormatInt(item.Number, 10), item.Title }), err + }) +} + +// completePulls completes pull request numbers in state (open, closed or all) with their titles. +func (a *application) completePulls(state string) cobra.CompletionFunc { + return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + items, _, err := services.Pulls{Client: t.client, Repository: t.Repo}.List(ctx, services.PullListOptions{State: state, Limit: completionLimit, Page: 1}) + return describe(items, func(item services.Pull) (string, string) { return strconv.FormatInt(item.Number, 10), item.Title }), err + }) +} + +// completeLabels completes comma-separated label names of the repository and, when its owner is an +// organization, of the organization. +func (a *application) completeLabels() cobra.CompletionFunc { + return commaSeparated(a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + labels, err := services.Labels{Client: t.client, Repository: t.Repo}.List(ctx, completionLimit, 1) + if err != nil { + return nil, err + } + owner, _, _ := strings.Cut(t.Repo, "/") + if orgLabels, err := (services.Labels{Client: t.client, Organization: owner}).List(ctx, completionLimit, 1); err == nil { + labels = append(labels, orgLabels...) + } + return describe(labels, func(label services.Label) (string, string) { return label.Name, label.Description }), nil + })) +} + +// completeMilestones completes the titles of open milestones. +func (a *application) completeMilestones() cobra.CompletionFunc { + return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + items, err := services.Milestones{Client: t.client, Repository: t.Repo}.List(ctx, "open", completionLimit, 1) + return describe(items, func(item services.Milestone) (string, string) { return item.Title, item.Description }), err + }) +} + +// completeUsers completes comma-separated usernames that can be assigned in the repository. +func (a *application) completeUsers() cobra.CompletionFunc { + return commaSeparated(a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + type user struct { + Login string `json:"login"` + FullName string `json:"full_name"` + } + var users []user + err := fetchJSON(ctx, t.client, "repos/"+t.Repo+"/assignees", &users) + return describe(users, func(item user) (string, string) { return item.Login, item.FullName }), err + })) +} + +// completeBranches completes the local branches, then the repository's branches. +func (a *application) completeBranches() cobra.CompletionFunc { + return mergeCompletions(a.completeLocalBranches, a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + items, err := services.Branches{Client: t.client}.List(ctx, t.Repo, completionLimit, 1) + return describe(items, func(item services.Branch) (string, string) { + if item.Protected { + return item.Name, "protected" + } + return item.Name, "" + }), err + })) +} + +// completeTags completes the repository's tags. +func (a *application) completeTags() cobra.CompletionFunc { + return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + type tag struct { + Name string `json:"name"` + } + var tags []tag + err := fetchJSON(ctx, t.client, "repos/"+t.Repo+"/tags?limit="+strconv.Itoa(completionLimit), &tags) + return describe(tags, func(item tag) (string, string) { return item.Name, "" }), err + }) +} + +// completeReleases completes release tags with their titles. +func (a *application) completeReleases() cobra.CompletionFunc { + return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + items, err := services.Releases{Client: t.client, Repository: t.Repo}.List(ctx, completionLimit, 1) + return describe(items, func(item services.Release) (string, string) { return item.Tag, item.Title }), err + }) +} + +// completeOrgs completes the organizations of the account. +func (a *application) completeOrgs() cobra.CompletionFunc { + return a.apiCompletion(false, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + items, _, err := services.Organizations{Client: t.client}.List(ctx, true, "", completionLimit, 1) + return describe(items, func(item services.Organization) (string, string) { return item.Name, item.FullName }), err + }) +} + +// completeTeams completes teams as ORG/TEAM: first the account's organizations, then the teams of the +// organization before the slash. +func (a *application) completeTeams() cobra.CompletionFunc { + orgs := a.completeOrgs() + teams := a.apiCompletion(false, func(ctx context.Context, t target, toComplete string) ([]cobra.Completion, error) { + org, _, _ := strings.Cut(toComplete, "/") + items, err := services.Teams{Client: t.client}.List(ctx, org, completionLimit, 1) + return describe(items, func(item services.Team) (string, string) { + return org + "/" + item.Name, strings.TrimSpace(item.Description + " (ID " + strconv.FormatInt(item.ID, 10) + ")") + }), err + }) + return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + if strings.Contains(toComplete, "/") { + return teams(cmd, args, toComplete) + } + values, directive := orgs(cmd, args, toComplete) + for i, value := range values { + name, description, _ := strings.Cut(value, "\t") + values[i] = cobra.CompletionWithDesc(name+"/", description) + } + return values, directive | cobra.ShellCompDirectiveNoSpace + } +} + +// completeRepos completes repositories as OWNER/NAME: those of the Git remotes on configured +// instances, then those of the owner before the slash, or else the account's repositories. +func (a *application) completeRepos() cobra.CompletionFunc { + remotes := func(cmd *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + ctx, cancel := completionContext(cmd) + defer cancel() + snapshot, err := a.deps.Settings.Load() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + values := []cobra.Completion{} + for _, remote := range a.gitRemotes(ctx) { + if host, repo, err := settings.RepositoryURL(remote[1], snapshot.Hosts.Hosts); err == nil && snapshot.Hosts.Hosts[host].Users != nil { + values = append(values, cobra.CompletionWithDesc(repo, "remote "+remote[0])) + } + } + return matching(values, toComplete), cobra.ShellCompDirectiveNoFileComp + } + return mergeCompletions(remotes, a.apiCompletion(false, func(ctx context.Context, t target, toComplete string) ([]cobra.Completion, error) { + owner, _, found := strings.Cut(toComplete, "/") + if !found { + owner = "" + } + items, _, err := services.Repositories{Client: t.client}.List(ctx, services.RepositoryListOptions{Owner: owner, Limit: completionLimit, Page: 1}) + return describe(items, func(item services.Repository) (string, string) { return item.FullName, item.Description }), err + })) +} + +// completeWorkflows completes workflow files with their names. +func (a *application) completeWorkflows() cobra.CompletionFunc { + return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + items, err := services.Actions{Client: t.client, Repository: t.Repo}.Workflows(ctx) + return describe(items, func(item services.Workflow) (string, string) { return item.ID, item.Name }), err + }) +} + +// completeRuns completes the IDs of recent workflow runs with their status and title. +func (a *application) completeRuns() cobra.CompletionFunc { + return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + items, err := services.Actions{Client: t.client, Repository: t.Repo}.Runs(ctx, services.RunListOptions{Limit: completionLimit, Page: 1}) + return describe(items, func(item services.Run) (string, string) { + return strconv.FormatInt(item.ID, 10), strings.Join(slices.DeleteFunc([]string{item.Status, item.Conclusion, item.Title}, func(s string) bool { return s == "" }), " · ") + }), err + }) +} + +// completeSecrets completes Actions secret names in the scope that scope selects; a scope without an +// organization or account selects the repository. +func (a *application) completeSecrets(scope func() services.ActionScope) cobra.CompletionFunc { + return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + items, err := services.ActionSecrets{Client: t.client, Scope: actionScope(scope, t)}.List(ctx, completionLimit, 1) + return describe(items, func(item services.ActionSecret) (string, string) { return item.Name, item.Description }), err + }) +} + +// completeVariables completes Actions variable names in the scope that scope selects; a scope without +// an organization or account selects the repository. +func (a *application) completeVariables(scope func() services.ActionScope) cobra.CompletionFunc { + return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { + items, err := services.ActionVariables{Client: t.client, Scope: actionScope(scope, t)}.List(ctx, completionLimit, 1) + return describe(items, func(item services.ActionVariable) (string, string) { return item.Name, item.Description }), err + }) +} + +// actionScope applies the target's repository to a scope without organization or account. +func actionScope(scope func() services.ActionScope, t target) services.ActionScope { + selected := scope() + if selected.Organization == "" && !selected.Account { + selected.Repository = t.Repo + } + return selected +} + +// pathFlags take local file paths, like every flag whose name ends in -file. +var pathFlags = []string{"asset", "attachment", "file", "input", "ssh-key"} + +// completeDefaults completes paths only where a command takes them: path flags, flags marked with +// MarkFlagFilename, and positional arguments named file or directory in Use. Everything else without a +// completer completes nothing, by the root's default directive, rather than file names. +func completeDefaults(cmd *cobra.Command) { + for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.PersistentFlags()} { + flags.VisitAll(func(flag *pflag.Flag) { + if _, registered := cmd.GetFlagCompletionFunc(flag.Name); registered { + return + } + switch { + case flag.Name == "dir": + _ = cmd.RegisterFlagCompletionFunc(flag.Name, completeDirectories) + case strings.HasSuffix(flag.Name, "-file") || slices.Contains(pathFlags, flag.Name) || flag.Annotations[cobra.BashCompFilenameExt] != nil: + _ = cmd.RegisterFlagCompletionFunc(flag.Name, completeFiles) + } + }) + } + if slots := argSlots(cmd.Use); len(slots) > 0 { + complete := cmd.ValidArgsFunction + cmd.ValidArgsFunction = func(c *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + slot := slots[min(len(args), len(slots)-1)] + switch { + case len(args) >= len(slots) && !slot.variadic: + return nil, cobra.ShellCompDirectiveNoFileComp + case slot.kind == "file": + return completeFiles(c, args, toComplete) + case slot.kind == "directory": + return completeDirectories(c, args, toComplete) + case complete != nil: + return complete(c, args, toComplete) + } + return nil, cobra.ShellCompDirectiveNoFileComp + } + } + for _, child := range cmd.Commands() { + completeDefaults(child) + } +} + +// completeFiles leaves the completion of file paths to the shell. +func completeFiles(*cobra.Command, []string, string) ([]cobra.Completion, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveDefault +} + +// completeDirectories leaves the completion of directory paths to the shell. +func completeDirectories(*cobra.Command, []string, string) ([]cobra.Completion, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveFilterDirs +} + +// argSlot is one positional argument that a Use line declares, such as , [directory] or +// ...; kind is "file" or "directory" for path arguments. +type argSlot struct { + kind string + variadic bool +} + +// argSlots parses the positional arguments of a Use line. +func argSlots(use string) []argSlot { + _, rest, _ := strings.Cut(use, " ") + var slots []argSlot + depth, start := 0, 0 + for i, r := range rest { + switch r { + case '<', '[': + if depth == 0 { + start = i + } + depth++ + case '>', ']': + if depth--; depth != 0 { + continue + } + slot := argSlot{variadic: strings.HasPrefix(rest[i+1:], "...") || strings.HasSuffix(rest[start:i], "...")} + for name := range strings.FieldsFuncSeq(rest[start:i+1], func(r rune) bool { return strings.ContainsRune("<>[]|. ", r) }) { + switch { + case name == "file" || strings.HasSuffix(name, "-file"): + slot.kind = "file" + case name == "directory" || name == "dir": + slot.kind = "directory" + } + } + slots = append(slots, slot) + } + } + return slots +} + +// completionInstallHelp explains how to load the completion scripts, including with macOS's bash 3.2 +// and in zsh without compinit. +func completionInstallHelp(root *cobra.Command) { + completion, _, err := root.Find([]string{"completion"}) + if err != nil || completion == root { + return + } + completion.Short = "Generate shell completion scripts" + completion.Long = `Generate shell completion scripts for tea. + +Completion offers commands, flags, flag values such as --state or --json fields, and values +from the selected instance, such as repositories, issue numbers and labels. See +tea completion --help for how to load the script in your shell.` + help := map[string][2]string{ + "bash": {`Generate the completion script for bash. + +The script needs the bash-completion package. On macOS, install bash-completion with +Homebrew for the system bash 3.2, or bash-completion@2 for a Homebrew bash, and load it +as its install notes say. + +To load completions in every new shell, add the eval line below to ~/.bashrc (on macOS, +~/.bash_profile). Use eval: bash 3.2, the default on macOS, cannot source <(tea completion bash).`, `# Load completions in this shell, or in every shell from ~/.bashrc +$ eval "$(tea completion bash)" +# Or install the script for every user (Linux, then macOS) +$ tea completion bash > /etc/bash_completion.d/tea +$ tea completion bash > "$(brew --prefix)/etc/bash_completion.d/tea"`}, + "zsh": {`Generate the completion script for zsh. + +Completion must be initialized before the script loads: add these lines to ~/.zshrc, +keeping an existing compinit call instead of the first line. Without compinit, zsh +reports "command not found: compdef".`, `$ autoload -U compinit && compinit +$ source <(tea completion zsh) +# Or install the script into a directory of your fpath once +$ tea completion zsh > "${fpath[1]}/_tea"`}, + "fish": {`Generate the completion script for fish. + +To load completions in every new shell, install the script once.`, `$ tea completion fish > ~/.config/fish/completions/tea.fish`}, + "powershell": {`Generate the completion script for PowerShell. + +To load completions in every new shell, add this line to your PowerShell profile.`, `$ tea completion powershell | Out-String | Invoke-Expression`}, + } + for _, shell := range completion.Commands() { + shell.Long, shell.Example = help[shell.Name()][0], help[shell.Name()][1] + if flag := shell.Flags().Lookup("no-descriptions"); flag != nil { + flag.Usage = "Disable completion descriptions" + } + } +}