diff --git a/action.go b/action.go index 5d3b7f61..721053f6 100644 --- a/action.go +++ b/action.go @@ -2,6 +2,7 @@ package carapace import ( "fmt" + "net/url" "os" "regexp" "runtime" @@ -462,6 +463,42 @@ func (a Action) Unless(condition func(c Context) bool) Action { }) } +// Uid TODO experimental +func (a Action) Uid(scheme, host string, opts ...string) Action { + return ActionCallback(func(c Context) Action { + if length := len(opts); length%2 != 0 { + return ActionMessage("invalid amount of arguments [Uid]: %v", length) + } + + invoked := a.Invoke(c) + for index, v := range invoked.action.rawValues { + uid := url.URL{ + Scheme: scheme, + Host: host, + Path: v.Value, + } + if len(opts) > 0 { + values := uid.Query() + for i := 0; i < len(opts); i += 2 { + if opts[i+1] != "" { // implicitly skip empty values + values.Set(opts[i], opts[i+1]) + } + } + uid.RawQuery = values.Encode() + } + invoked.action.rawValues[index].Uid = uid.String() + } + return invoked.ToA() + }) +} + +// UidF TODO experimental +func (a Action) UidF(f func(s string) (*url.URL, error)) Action { + return ActionCallback(func(c Context) Action { + return a.Invoke(c).UidF(f).ToA() + }) +} + // Usage sets the usage. func (a Action) Usage(usage string, args ...interface{}) Action { return a.UsageF(func() string { diff --git a/action_test.go b/action_test.go index 6cb43049..6c9c24cc 100644 --- a/action_test.go +++ b/action_test.go @@ -10,6 +10,7 @@ import ( "github.com/carapace-sh/carapace/internal/assert" "github.com/carapace-sh/carapace/internal/common" + "github.com/carapace-sh/carapace/internal/uid" "github.com/carapace-sh/carapace/pkg/style" ) @@ -122,7 +123,14 @@ func TestActionDirectories(t *testing.T) { "internal/", style.Of(style.Blue, style.Bold), "pkg/", style.Of(style.Blue, style.Bold), "third_party/", style.Of(style.Blue, style.Bold), - ).NoSpace('/').Tag("directories").Invoke(Context{}), + ).NoSpace('/').Tag("directories").Invoke(Context{}).UidF(uid.Map( + "example/", "file://"+wd("")+"/example/", + "example-nonposix/", "file://"+wd("")+"/example-nonposix/", + "docs/", "file://"+wd("")+"/docs/", + "internal/", "file://"+wd("")+"/internal/", + "pkg/", "file://"+wd("")+"/pkg/", + "third_party/", "file://"+wd("")+"/third_party/", + )), ActionDirectories().Invoke(Context{Value: ""}).Filter("vendor/"), ) @@ -134,7 +142,14 @@ func TestActionDirectories(t *testing.T) { "internal/", style.Of(style.Blue, style.Bold), "pkg/", style.Of(style.Blue, style.Bold), "third_party/", style.Of(style.Blue, style.Bold), - ).NoSpace('/').Tag("directories").Invoke(Context{}).Prefix("./"), + ).NoSpace('/').Tag("directories").Invoke(Context{}).Prefix("./").UidF(uid.Map( + "./example/", "file://"+wd("")+"/example/", + "./example-nonposix/", "file://"+wd("")+"/example-nonposix/", + "./docs/", "file://"+wd("")+"/docs/", + "./internal/", "file://"+wd("")+"/internal/", + "./pkg/", "file://"+wd("")+"/pkg/", + "./third_party/", "file://"+wd("")+"/third_party/", + )), ActionDirectories().Invoke(Context{Value: "./"}).Filter("./vendor/"), ) @@ -142,14 +157,19 @@ func TestActionDirectories(t *testing.T) { ActionStyledValues( "_test/", style.Of(style.Blue, style.Bold), "cmd/", style.Of(style.Blue, style.Bold), - ).NoSpace('/').Tag("directories").Invoke(Context{}).Prefix("example/"), + ).NoSpace('/').Tag("directories").Invoke(Context{}).Prefix("example/").UidF(uid.Map( + "example/_test/", "file://"+wd("")+"/example/_test/", + "example/cmd/", "file://"+wd("")+"/example/cmd/", + )), ActionDirectories().Invoke(Context{Value: "example/"}), ) assertEqual(t, ActionStyledValues( "cmd/", style.Of(style.Blue, style.Bold), - ).NoSpace('/').Tag("directories").Invoke(Context{}).Prefix("example/"), + ).NoSpace('/').Tag("directories").Invoke(Context{}).Prefix("example/").UidF(uid.Map( + "example/cmd/", "file://"+wd("")+"/example/cmd/", + )), ActionDirectories().Invoke(Context{Value: "example/cm"}), ) } @@ -164,7 +184,15 @@ func TestActionFiles(t *testing.T) { "internal/", style.Of(style.Blue, style.Bold), "pkg/", style.Of(style.Blue, style.Bold), "third_party/", style.Of(style.Blue, style.Bold), - ).NoSpace('/').Tag("files").Invoke(Context{}), + ).NoSpace('/').Tag("files").Invoke(Context{}).UidF(uid.Map( + "README.md", "file://"+wd("")+"/README.md", + "example/", "file://"+wd("")+"/example/", + "example-nonposix/", "file://"+wd("")+"/example-nonposix/", + "docs/", "file://"+wd("")+"/docs/", + "internal/", "file://"+wd("")+"/internal/", + "pkg/", "file://"+wd("")+"/pkg/", + "third_party/", "file://"+wd("")+"/third_party/", + )), ActionFiles(".md").Invoke(Context{Value: ""}).Filter("vendor/"), ) @@ -175,7 +203,13 @@ func TestActionFiles(t *testing.T) { "cmd/", style.Of(style.Blue, style.Bold), "main.go", style.Default, "main_test.go", style.Default, - ).NoSpace('/').Tag("files").Invoke(Context{}).Prefix("example/"), + ).NoSpace('/').Tag("files").Invoke(Context{}).Prefix("example/").UidF(uid.Map( + "example/README.md", "file://"+wd("example")+"/README.md", + "example/_test/", "file://"+wd("example")+"/_test/", + "example/cmd/", "file://"+wd("example")+"/cmd/", + "example/main.go", "file://"+wd("example")+"/main.go", + "example/main_test.go", "file://"+wd("example")+"/main_test.go", + )), ActionFiles().Invoke(Context{Value: "example/"}).Filter("example/example"), ) } @@ -197,7 +231,10 @@ func TestActionFilesChdir(t *testing.T) { ActionStyledValues( "action.go", style.Default, "snippet.go", style.Default, - ).NoSpace('/').Tag("files").Invoke(Context{}).Prefix("elvish/"), + ).NoSpace('/').Tag("files").Invoke(Context{}).Prefix("elvish/").UidF(uid.Map( + "elvish/action.go", "file://"+wd("internal/shell")+"/elvish/action.go", + "elvish/snippet.go", "file://"+wd("internal/shell")+"/elvish/snippet.go", + )), ActionFiles().Chdir("internal/shell").Invoke(Context{Value: "elvish/"}), ) diff --git a/defaultActions.go b/defaultActions.go index e5925e90..86d40aa0 100644 --- a/defaultActions.go +++ b/defaultActions.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "os" "os/exec" "strings" @@ -14,6 +15,7 @@ import ( "github.com/carapace-sh/carapace/internal/env" "github.com/carapace-sh/carapace/internal/export" "github.com/carapace-sh/carapace/internal/man" + "github.com/carapace-sh/carapace/internal/uid" "github.com/carapace-sh/carapace/pkg/match" "github.com/carapace-sh/carapace/pkg/style" "github.com/carapace-sh/carapace/third_party/github.com/acarl005/stripansi" @@ -132,18 +134,34 @@ func ActionExecute(cmd *cobra.Command) Action { // ActionDirectories completes directories. func ActionDirectories() Action { - return actionPath([]string{""}, true). - MultiParts("/"). - StyleF(style.ForPath). - Tag("directories") + return ActionCallback(func(c Context) Action { + return actionPath([]string{""}, true). + MultiParts("/"). + StyleF(style.ForPath). + UidF(func(s string) (*url.URL, error) { // TODO duplicated from ActionFiles + abs, err := c.Abs(s) + if err != nil { + return nil, err + } + return url.Parse("file://" + abs) + }) + }).Tag("directories") } // ActionFiles completes files with optional suffix filtering. func ActionFiles(suffix ...string) Action { - return actionPath(suffix, false). - MultiParts("/"). - StyleF(style.ForPath). - Tag("files") + return ActionCallback(func(c Context) Action { + return actionPath(suffix, false). + MultiParts("/"). + StyleF(style.ForPath). + UidF(func(s string) (*url.URL, error) { + abs, err := c.Abs(s) + if err != nil { + return nil, err + } + return url.Parse("file://" + abs) + }) + }).Tag("files") } // ActionValues completes arbitrary keywords (values). @@ -438,7 +456,10 @@ func ActionExecutables(dirs ...string) Action { for i := len(dirs) - 1; i >= 0; i-- { batch = append(batch, actionDirectoryExecutables(dirs[i], c.Value, manDescriptions)) } - return batch.ToA() + return batch.ToA(). + UidF(func(s string) (*url.URL, error) { + return &url.URL{Scheme: "cmd", Host: s}, nil + }) }).Tag("executables") } @@ -458,7 +479,9 @@ func actionDirectoryExecutables(dir string, prefix string, manDescriptions map[s } } } - return ActionStyledValuesDescribed(vals...) + return ActionStyledValuesDescribed(vals...).UidF(func(s string) (*url.URL, error) { + return url.Parse(fmt.Sprintf("file://%v/%v", dir, s)) // TODO trim slash suffix from dir | backslash path possible? (windows) + }) } return ActionValues() }) @@ -524,7 +547,20 @@ func ActionCommands(cmd *cobra.Command) Action { } } } - return batch.ToA() + return batch.ToA().UidF(func(s string) (*url.URL, error) { + uid := uid.Command(cmd) + if subCommand, _, err := cmd.Find([]string{s}); err == nil { + s = subCommand.Name() // alias -> actual name + } + + switch uid.Path { + case "": + uid.Path = s + default: + uid.Path = uid.Path + "/" + s + } + return uid, nil + }) }) } diff --git a/defaultActions_test.go b/defaultActions_test.go index be760165..4e6f663a 100644 --- a/defaultActions_test.go +++ b/defaultActions_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/carapace-sh/carapace/internal/uid" "github.com/spf13/cobra" ) @@ -33,13 +34,27 @@ func TestActionImport(t *testing.T) { } func TestActionFlags(t *testing.T) { - cmd := &cobra.Command{} + cmd := &cobra.Command{Use: "actionFlags"} cmd.Flags().BoolP("alpha", "a", false, "") cmd.Flags().BoolP("beta", "b", false, "") cmd.Flag("alpha").Changed = true a := actionFlags(cmd).Invoke(Context{Value: "-a"}) - assertEqual(t, ActionValuesDescribed("b", "", "h", "help for this command").Tag("shorthand flags").NoSpace('b', 'h').Invoke(Context{}).Prefix("-a"), a) + assertEqual( + t, + ActionValuesDescribed( + "b", "", + "h", "help for actionFlags", + ).Tag("shorthand flags"). + NoSpace('b', 'h'). + Invoke(Context{}). + Prefix("-a"). + UidF(uid.Map( + "-ab", "cmd://actionFlags?flag=beta", + "-ah", "cmd://actionFlags?flag=help", + )), + a, + ) } func TestActionExecCommandEnv(t *testing.T) { diff --git a/internal/common/value.go b/internal/common/value.go index 6a804d4c..501f048a 100644 --- a/internal/common/value.go +++ b/internal/common/value.go @@ -20,6 +20,7 @@ type RawValue struct { Description string `json:"description,omitempty"` Style string `json:"style,omitempty"` Tag string `json:"tag,omitempty"` + Uid string `json:"uid,omitempty"` } // TrimmedDescription returns the trimmed description. diff --git a/internal/env/env.go b/internal/env/env.go index 75f1b5ab..8a2748f7 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -11,6 +11,7 @@ import ( const ( CARAPACE_COVERDIR = "CARAPACE_COVERDIR" // coverage directory for sandbox tests + CARAPACE_EXPERIMENTAL = "CARAPACE_EXPERIMENTAL" // enable experimental features CARAPACE_HIDDEN = "CARAPACE_HIDDEN" // show hidden commands/flags CARAPACE_LENIENT = "CARAPACE_LENIENT" // allow unknown flags CARAPACE_LOG = "CARAPACE_LOG" // enable logging @@ -25,6 +26,10 @@ func ColorDisabled() bool { return os.Getenv(NO_COLOR) != "" || os.Getenv(CLICOLOR) == "0" } +func Experimental() bool { + return os.Getenv(CARAPACE_EXPERIMENTAL) != "" +} + func Lenient() bool { return os.Getenv(CARAPACE_LENIENT) != "" } diff --git a/internal/log/log.go b/internal/log/log.go index 394b0bfd..f97142e8 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -2,7 +2,7 @@ package log import ( "fmt" - "io/ioutil" + "io" "log" "os" @@ -11,7 +11,7 @@ import ( "github.com/carapace-sh/carapace/pkg/ps" ) -var LOG = log.New(ioutil.Discard, "", log.Flags()) +var LOG = log.New(io.Discard, "", log.Flags()) func init() { if !env.Log() { diff --git a/internal/shell/shell.go b/internal/shell/shell.go index f3b53863..6859432a 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -2,6 +2,7 @@ package shell import ( "fmt" + "os/exec" "sort" "strings" @@ -90,6 +91,14 @@ func Value(shell string, value string, meta common.Meta, values common.RawValues } sort.Sort(common.ByDisplay(filtered)) + if env.Experimental() { + if _, err := exec.LookPath("tabdance"); err == nil { + return f(value, meta, filtered) + } + } + for index := range filtered { + filtered[index].Uid = "" + } return f(value, meta, filtered) } return "" diff --git a/internal/uid/uid.go b/internal/uid/uid.go index 98b76427..04a98acb 100644 --- a/internal/uid/uid.go +++ b/internal/uid/uid.go @@ -2,31 +2,43 @@ package uid import ( + "net/url" "os" "path/filepath" "strings" + "github.com/carapace-sh/carapace/internal/pflagfork" "github.com/spf13/cobra" ) // Command creates a uid for given command. -func Command(cmd *cobra.Command) string { - names := make([]string, 0) - current := cmd - for { - names = append(names, current.Name()) - current = current.Parent() - if current == nil { - break - } +func Command(cmd *cobra.Command) *url.URL { + path := []string{cmd.Name()} + for parent := cmd.Parent(); parent != nil; parent = parent.Parent() { + path = append(path, parent.Name()) + } + reverse(path) // TODO slices.Reverse + return &url.URL{ + Scheme: "cmd", + Host: path[0], + Path: strings.Join(path[1:], "/"), } +} - reverse := make([]string, len(names)) - for i, entry := range names { - reverse[len(names)-i-1] = entry +// reverse reverses the elements of the slice in place. +func reverse(s []string) { + for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { + s[i], s[j] = s[j], s[i] } +} - return "_" + strings.Join(reverse, "__") +// Flag creates a uid for given flag. +func Flag(cmd *cobra.Command, flag *pflagfork.Flag) *url.URL { + uid := Command(cmd) + values := uid.Query() + values.Set("flag", flag.Name) + uid.RawQuery = values.Encode() + return uid } // Executable returns the name of the executable. @@ -39,3 +51,20 @@ func Executable() string { return filepath.Base(executable) } } + +// Map maps values to uids to simplify testing. +// +// Map( +// "go.mod", "file://path/to/go.mod", +// "go.sum", "file://path/to/go.sum", +// ) +func Map(uids ...string) func(s string) (*url.URL, error) { + return func(s string) (*url.URL, error) { + for i := 0; i < len(uids); i += 2 { + if uids[i] == s { + return url.Parse(uids[i+1]) + } + } + return &url.URL{}, nil + } +} diff --git a/internal/uid/uid_test.go b/internal/uid/uid_test.go index 03eabd55..c92d3876 100644 --- a/internal/uid/uid_test.go +++ b/internal/uid/uid_test.go @@ -22,9 +22,9 @@ func TestUidCommand(t *testing.T) { root.AddCommand(sub1) sub1.AddCommand(sub2) - assert.Equal(t, "_root", Command(root)) - assert.Equal(t, "_root__sub1", Command(sub1)) - assert.Equal(t, "_root__sub1__sub2", Command(sub2)) + assert.Equal(t, "cmd://root", Command(root).String()) + assert.Equal(t, "cmd://root/sub1", Command(sub1).String()) + assert.Equal(t, "cmd://root/sub1/sub2", Command(sub2).String()) } func TestExecutable(t *testing.T) { diff --git a/internalActions.go b/internalActions.go index 5469db55..03a38ff1 100644 --- a/internalActions.go +++ b/internalActions.go @@ -1,13 +1,14 @@ package carapace import ( - "io/ioutil" + "net/url" "os" "path/filepath" "strings" "github.com/carapace-sh/carapace/internal/env" "github.com/carapace-sh/carapace/internal/pflagfork" + "github.com/carapace-sh/carapace/internal/uid" "github.com/carapace-sh/carapace/pkg/style" "github.com/carapace-sh/carapace/pkg/util" "github.com/spf13/cobra" @@ -33,7 +34,7 @@ func actionPath(fileSuffixes []string, dirOnly bool) Action { } actualFolder := filepath.ToSlash(filepath.Dir(abs)) - files, err := ioutil.ReadDir(actualFolder) + files, err := os.ReadDir(actualFolder) if err != nil { return ActionMessage(err.Error()) } @@ -46,7 +47,10 @@ func actionPath(fileSuffixes []string, dirOnly bool) Action { continue } - resolvedFile := file + resolvedFile, err := file.Info() + if err != nil { + return ActionMessage(err.Error()) + } if resolved, err := filepath.EvalSymlinks(actualFolder + file.Name()); err == nil { if stat, err := os.Stat(resolved); err == nil { resolvedFile = stat @@ -103,7 +107,8 @@ func actionFlags(cmd *cobra.Command) Action { return // abort shorthand flag series if a previous one is not bool or count and requires an argument (no default value) } } - batch = append(batch, ActionStyledValuesDescribed(f.Shorthand, f.Usage, f.Style()).Tag("shorthand flags")) + batch = append(batch, ActionStyledValuesDescribed(f.Shorthand, f.Usage, f.Style()).Tag("shorthand flags"). + UidF(func(s string) (*url.URL, error) { return uid.Flag(cmd, f), nil })) if f.IsOptarg() { nospace = append(nospace, []rune(f.Shorthand)[0]) } @@ -111,13 +116,16 @@ func actionFlags(cmd *cobra.Command) Action { } else { switch f.Mode() { case pflagfork.NameAsShorthand: - batch = append(batch, ActionStyledValuesDescribed("-"+f.Name, f.Usage, f.Style()).Tag("longhand flags")) + batch = append(batch, ActionStyledValuesDescribed("-"+f.Name, f.Usage, f.Style()).Tag("longhand flags"). + UidF(func(s string) (*url.URL, error) { return uid.Flag(cmd, f), nil })) case pflagfork.Default: - batch = append(batch, ActionStyledValuesDescribed("--"+f.Name, f.Usage, f.Style()).Tag("longhand flags")) + batch = append(batch, ActionStyledValuesDescribed("--"+f.Name, f.Usage, f.Style()).Tag("longhand flags"). + UidF(func(s string) (*url.URL, error) { return uid.Flag(cmd, f), nil })) } if f.Shorthand != "" && f.ShorthandDeprecated == "" { - batch = append(batch, ActionStyledValuesDescribed("-"+f.Shorthand, f.Usage, f.Style()).Tag("shorthand flags")) + batch = append(batch, ActionStyledValuesDescribed("-"+f.Shorthand, f.Usage, f.Style()).Tag("shorthand flags"). + UidF(func(s string) (*url.URL, error) { return uid.Flag(cmd, f), nil })) } } }) diff --git a/invokedAction.go b/invokedAction.go index 7e55baee..52ff8e8b 100644 --- a/invokedAction.go +++ b/invokedAction.go @@ -1,6 +1,7 @@ package carapace import ( + "net/url" "strings" "github.com/carapace-sh/carapace/internal/common" @@ -70,6 +71,18 @@ func (ia InvokedAction) Suffix(suffix string) InvokedAction { return ia } +// UidF TODO experimental +func (ia InvokedAction) UidF(f func(s string) (*url.URL, error)) InvokedAction { + for index, v := range ia.action.rawValues { + url, err := f(v.Value) + if err != nil { + return ActionMessage(err.Error()).Invoke(Context{}) + } + ia.action.rawValues[index].Uid = url.String() + } + return ia +} + // ToA casts an InvokedAction to Action. func (ia InvokedAction) ToA() Action { return ia.action @@ -113,6 +126,7 @@ func (ia InvokedAction) ToMultiPartsA(dividers ...string) Action { Description: val.Description, Style: val.Style, Tag: val.Tag, + Uid: val.Uid, } } else { uniqueVals[v] = common.RawValue{ @@ -121,6 +135,7 @@ func (ia InvokedAction) ToMultiPartsA(dividers ...string) Action { Description: "", Style: "", Tag: val.Tag, + Uid: val.Uid, } } }