Repository: rsteube/carapace (go)

Issue:
colored display values
Add `Color` to `RawValue` to support styled display values as elvish does during file completion:

![2022-03-31-100327_987x67_scrot](https://user-images.githubusercontent.com/9090290/161064028-8c3ccb23-d5ce-4c57-86ff-a986ad246e29.png)

Add `ActionValuesC` and `ActionValuesDescribedC` for color support:
```go
ActionValuesC(
  "first", "default",
  "second", "green",
)

ActionValuesDescribedC(
   "first", "description of first", "default",
   "second", "description of second", "blue", 
)
```

Pull request description:
added support for colored display values
fix #446 

Patch:
diff --git a/action.go b/action.go
index 7fa4ec0..2fd1012 100644
--- a/action.go
+++ b/action.go
@@ -100,6 +100,17 @@ func (a Action) NoSpace() Action {
 	return a.noSpace(true)
 }
 
+// Style sets the style
+func (a Action) Style(style string) Action {
+	return ActionCallback(func(c Context) Action {
+		invoked := a.Invoke(c)
+		for index := range invoked.rawValues {
+			invoked.rawValues[index].Style = style
+		}
+		return invoked.ToA()
+	})
+}
+
 // Chdir changes the current working directory to the named directory during invocation.
 func (a Action) Chdir(dir string) Action {
 	return ActionCallback(func(c Context) Action {
diff --git a/defaultActions.go b/defaultActions.go
index bafeae9..742776a 100644
--- a/defaultActions.go
+++ b/defaultActions.go
@@ -13,6 +13,7 @@ import (
 
 	"github.com/rsteube/carapace/internal/common"
 	"github.com/rsteube/carapace/internal/export"
+	"github.com/rsteube/carapace/pkg/style"
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
 )
@@ -137,52 +138,84 @@ func actionPath(fileSuffixes []string, dirOnly bool) Action {
 			!strings.HasSuffix(c.CallbackValue, "/") &&
 			strings.HasPrefix(filepath.Base(c.CallbackValue), ".")
 
-		vals := make([]string, 0, len(files))
+		vals := make([]string, 0, len(files)*2)
 		for _, file := range files {
 			if !showHidden && strings.HasPrefix(file.Name(), ".") {
 				continue
 			}
 
-			if file.IsDir() {
-				vals = append(vals, folder+file.Name()+"/")
+			resolvedFile := file
+			if resolved, err := filepath.EvalSymlinks(folder + file.Name()); err == nil {
+				if stat, err := os.Stat(resolved); err == nil {
+					resolvedFile = stat
+				}
+			}
+
+			if resolvedFile.IsDir() {
+				vals = append(vals, folder+file.Name()+"/", style.ForPath(folder+file.Name()+"/"))
 			} else if !dirOnly {
 				if len(fileSuffixes) == 0 {
 					fileSuffixes = []string{""}
 				}
 				for _, suffix := range fileSuffixes {
 					if strings.HasSuffix(file.Name(), suffix) {
-						vals = append(vals, folder+file.Name())
+						vals = append(vals, folder+file.Name(), style.ForPath(folder+file.Name()))
 						break
 					}
 				}
 			}
 		}
 		if strings.HasPrefix(c.CallbackValue, "./") {
-			return ActionValues(vals...).Invoke(Context{}).Prefix("./").ToA()
+			return ActionStyledValues(vals...).Invoke(Context{}).Prefix("./").ToA()
 		}
-		return ActionValues(vals...)
+		return ActionStyledValues(vals...)
 	})
 }
 
 // ActionValues completes arbitrary keywords (values)
 func ActionValues(values ...string) Action {
 	return ActionCallback(func(c Context) Action {
-		vals := make([]string, len(values)*2)
+		vals := make([]common.RawValue, 0)
+		for _, val := range values {
+			vals = append(vals, common.RawValue{Value: val, Display: val, Description: "", Style: style.Default})
+		}
+		return actionRawValues(vals...)
+	})
+}
+
+// ActionStyledValues is like ActionValues but also accepts a style
+func ActionStyledValues(values ...string) Action {
+	return ActionCallback(func(c Context) Action {
+		vals := make([]common.RawValue, 0)
 		for index, val := range values {
-			vals[index*2] = val
-			vals[(index*2)+1] = ""
+			if index%2 == 0 {
+				vals = append(vals, common.RawValue{Value: val, Display: val, Description: "", Style: values[index+1]})
+			}
 		}
-		return ActionValuesDescribed(vals...)
+		return actionRawValues(vals...)
 	})
 }
 
 // ActionValuesDescribed completes arbitrary key (values) with an additional description (value, description pairs)
 func ActionValuesDescribed(values ...string) Action {
 	return ActionCallback(func(c Context) Action {
-		vals := make([]common.RawValue, len(values)/2)
+		vals := make([]common.RawValue, 0)
 		for index, val := range values {
 			if index%2 == 0 {
-				vals[index/2] = common.RawValue{Value: val, Display: val, Description: values[index+1]}
+				vals = append(vals, common.RawValue{Value: val, Display: val, Description: values[index+1], Style: style.Default})
+			}
+		}
+		return actionRawValues(vals...)
+	})
+}
+
+// ActionStyledValuesDescribed is like ActionValues but also accepts a style
+func ActionStyledValuesDescribed(values ...string) Action {
+	return ActionCallback(func(c Context) Action {
+		vals := make([]common.RawValue, 0)
+		for index, val := range values {
+			if index%3 == 0 {
+				vals = append(vals, common.RawValue{Value: val, Display: val, Description: values[index+1], Style: values[index+2]})
 			}
 		}
 		return actionRawValues(vals...)
diff --git a/example/cmd/action.go b/example/cmd/action.go
index a88d0e1..aa65b87 100644
--- a/example/cmd/action.go
+++ b/example/cmd/action.go
@@ -4,6 +4,7 @@ import (
 	"github.com/rsteube/carapace"
 	"github.com/rsteube/carapace/example/cmd/action/net"
 	"github.com/rsteube/carapace/example/cmd/action/os"
+	"github.com/rsteube/carapace/pkg/style"
 	"github.com/spf13/cobra"
 )
 
@@ -19,6 +20,7 @@ func init() {
 
 	actionCmd.Flags().CountP("count", "c", "count flag")
 	actionCmd.Flags().StringP("files", "f", "", "files flag")
+	actionCmd.Flags().String("filtered_files", "", "files flag")
 	actionCmd.Flags().String("directories", "", "files flag")
 	actionCmd.Flags().StringP("groups", "g", "", "groups flag")
 	actionCmd.Flags().StringP("message", "m", "", "message flag")
@@ -27,6 +29,8 @@ func init() {
 	actionCmd.Flags().StringP("users", "u", "", "users flag")
 	actionCmd.Flags().StringP("values", "v", "", "values flag")
 	actionCmd.Flags().StringP("values_described", "d", "", "values with description flag")
+	actionCmd.Flags().String("styled_values", "", "styled values flag")
+	actionCmd.Flags().String("styled_values_described", "", "styled values with description flag")
 	//actionCmd.Flags().StringS("shorthandonly", "s", "", "shorthandonly flag")
 	actionCmd.Flags().StringP("kill", "k", "", "kill signals")
 	actionCmd.Flags().StringP("optarg", "o", "", "optional arg with default value blue")
@@ -34,18 +38,23 @@ func init() {
 
 	carapace.Gen(actionCmd).FlagCompletion(carapace.ActionMap{
 		"files": carapace.ActionCallback(func(c carapace.Context) carapace.Action {
+			return carapace.ActionFiles().Chdir(actionCmd.Flag("directories").Value.String())
+		}),
+		"filtered_files": carapace.ActionCallback(func(c carapace.Context) carapace.Action {
 			return carapace.ActionFiles(".go", "go.mod", ".txt").Chdir(actionCmd.Flag("directories").Value.String())
 		}),
-		"directories":      carapace.ActionDirectories(),
-		"groups":           os.ActionGroups(),
-		"message":          carapace.ActionMessage("message example"),
-		"net_interfaces":   net.ActionNetInterfaces(),
-		"usergroup":        os.ActionUserGroup(),
-		"users":            os.ActionUsers(),
-		"values":           carapace.ActionValues("values", "example"),
-		"values_described": carapace.ActionValuesDescribed("values", "valueDescription", "example", "exampleDescription"),
-		"kill":             os.ActionKillSignals(),
-		"optarg":           carapace.ActionValues("blue", "red", "green", "yellow"),
+		"directories":             carapace.ActionDirectories(),
+		"groups":                  os.ActionGroups(),
+		"message":                 carapace.ActionMessage("message example"),
+		"net_interfaces":          net.ActionNetInterfaces(),
+		"usergroup":               os.ActionUserGroup(),
+		"users":                   os.ActionUsers(),
+		"values":                  carapace.ActionValues("values", "example"),
+		"values_described":        carapace.ActionValuesDescribed("values", "valueDescription", "example", "exampleDescription"),
+		"styled_values":           carapace.ActionStyledValues("values", style.Green, "example", style.Cyan),
+		"styled_values_described": carapace.ActionStyledValuesDescribed("values", "valueDescription", style.Blue, "example", "exampleDescription", style.Red),
+		"kill":                    os.ActionKillSignals(),
+		"optarg":                  carapace.ActionValues("blue", "red", "green", "yellow"),
 	})
 
 	carapace.Gen(actionCmd).PositionalCompletion(
diff --git a/internal/assert/assert.go b/internal/assert/assert.go
index ae1a7ba..65c6afa 100644
--- a/internal/assert/assert.go
+++ b/internal/assert/assert.go
@@ -21,7 +21,7 @@ func Equal(t *testing.T, expected string, actual string) {
 		_ = ioutil.WriteFile(expectedFile.Name(), []byte(expected), os.ModePerm)
 		_ = ioutil.WriteFile(actualFile.Name(), []byte(actual), os.ModePerm)
 		output, _ := exec.Command("diff", "--color=always", expectedFile.Name(), actualFile.Name()).Output()
-		_, file, line, _ := runtime.Caller(1)
+		_, file, line, _ := runtime.Caller(2)
 		t.Errorf("%v:%v:\n%v", filepath.Base(file), line, string(output))
 	}
 }
diff --git a/internal/common/value.go b/internal/common/value.go
index e1afa43..10158be 100644
--- a/internal/common/value.go
+++ b/internal/common/value.go
@@ -1,13 +1,18 @@
 // Package common code
 package common
 
-import "strings"
+import (
+	"strings"
+
+	"github.com/rsteube/carapace/pkg/style"
+)
 
 // RawValue represents a completion candidate
 type RawValue struct {
 	Value       string
 	Display     string
 	Description string
+	Style       string
 }
 
 // TrimmedDescription returns the trimmed description
@@ -28,7 +33,7 @@ type RawValues []RawValue
 func RawValuesFrom(values ...string) RawValues {
 	rawValues := make([]RawValue, len(values))
 	for index, val := range values {
-		rawValues[index] = RawValue{Value: val, Display: val}
+		rawValues[index] = RawValue{Value: val, Display: val, Style: style.Default}
 	}
 	return rawValues
 }
diff --git a/internal/elvish/action.go b/internal/elvish/action.go
index 7c79bce..f94709e 100644
--- a/internal/elvish/action.go
+++ b/internal/elvish/action.go
@@ -26,6 +26,7 @@ type complexCandidate struct {
 	Display     string
 	Description string
 	CodeSuffix  string
+	Style       string
 }
 
 // ActionRawValues formats values for elvish
@@ -37,7 +38,10 @@ func ActionRawValues(currentWord string, nospace bool, values common.RawValues)
 
 	vals := make([]complexCandidate, len(values))
 	for index, val := range sanitize(values) {
-		vals[index] = complexCandidate{Value: val.Value, Display: val.Display, Description: val.Description, CodeSuffix: suffix}
+		if val.Style == "" {
+			val.Style = "default"
+		}
+		vals[index] = complexCandidate{Value: val.Value, Display: val.Display, Description: val.Description, CodeSuffix: suffix, Style: val.Style}
 	}
 	m, _ := json.Marshal(vals)
 	return string(m)
diff --git a/internal/elvish/snippet.go b/internal/elvish/snippet.go
index ce37f87..68fd980 100644
--- a/internal/elvish/snippet.go
+++ b/internal/elvish/snippet.go
@@ -12,9 +12,9 @@ func Snippet(cmd *cobra.Command) string {
 	return fmt.Sprintf(`set edit:completion:arg-completer[%v] = {|@arg|
     %v _carapace elvish (all $arg) | from-json | all (one) | each {|c| 
         if (eq $c[Description] "") {
-            edit:complex-candidate $c[Value] &display=$c[Display] &code-suffix=$c[CodeSuffix]
+            edit:complex-candidate $c[Value] &display=(styled $c[Display] $c[Style]) &code-suffix=$c[CodeSuffix]
         } else {
-            edit:complex-candidate $c[Value] &display=$c[Display]" ("(styled $c[Description] magenta)")" &code-suffix=$c[CodeSuffix]
+            edit:complex-candidate $c[Value] &display=(styled $c[Display] $c[Style])" ("(styled $c[Description] magenta)")" &code-suffix=$c[CodeSuffix]
         }
     }
 }
diff --git a/invokedAction.go b/invokedAction.go
index 0977fa5..a762029 100644
--- a/invokedAction.go
+++ b/invokedAction.go
@@ -93,24 +93,36 @@ func (a InvokedAction) ToA() Action {
 //   b := a.ToMultiPartsA("/") // completes segments separately (first one is ["A/", "B/", "C"])
 func (a InvokedAction) ToMultiPartsA(divider string) Action {
 	return ActionMultiParts(divider, func(c Context) Action {
-		uniqueVals := make(map[string]string)
+		uniqueVals := make(map[string]common.RawValue)
 		for _, val := range a.rawValues {
 			if strings.HasPrefix(val.Value, strings.Join(c.Parts, divider)) {
 				if splitted := strings.Split(val.Value, divider); len(splitted) > len(c.Parts) {
 					if len(splitted) == len(c.Parts)+1 {
-						uniqueVals[splitted[len(c.Parts)]] = val.Description
+						v := splitted[len(c.Parts)]
+						uniqueVals[v] = common.RawValue{
+							Value:       v,
+							Display:     v,
+							Description: val.Description,
+							Style:       val.Style,
+						}
 					} else {
-						uniqueVals[splitted[len(c.Parts)]+divider] = ""
+						v := splitted[len(c.Parts)] + divider
+						uniqueVals[v] = common.RawValue{
+							Value:       v,
+							Display:     v,
+							Description: "",
+							Style:       val.Style,
+						}
 					}
 				}
 			}
 		}
 
-		vals := make([]string, 0, len(uniqueVals)*2)
-		for val, description := range uniqueVals {
-			vals = append(vals, val, description)
+		vals := make([]common.RawValue, 0)
+		for _, val := range uniqueVals {
+			vals = append(vals, val)
 		}
-		return ActionValuesDescribed(vals...).noSpace(true)
+		return actionRawValues(vals...).noSpace(true)
 	})
 }
 
diff --git a/pkg/style/style.go b/pkg/style/style.go
new file mode 100644
index 0000000..13e5a11
--- /dev/null
+++ b/pkg/style/style.go
@@ -0,0 +1,70 @@
+// Package style provide display coloring
+package style
+
+import (
+	"mime"
+	"os"
+	"strings"
+)
+
+// TODO full style support: e.g. "bg-bright-black bright-cyan bold underlined"
+// https://elv.sh/ref/builtin.html#styled
+var (
+	Default string = "default"
+	Red     string = "red"
+	Green   string = "green"
+	Yellow  string = "yellow"
+	Blue    string = "blue"
+	Magenta string = "magenta"
+	Cyan    string = "cyan"
+
+	BrightBlack string = "bright-black"
+	//BrightRed     Color = "bright-red"
+	//BrightGreen   Color = "bright-green"
+	//BrightYellow  Color = "bright-yellow"
+	//BrightBlue    Color = "bright-blue"
+	//BrightMagenta Color = "bright-magenta"
+	//BrightCyan    Color = "bright-cyan"
+	//BrightWhite   Color = "bright-white"
+)
+
+// ForPath returns the style for given path
+func ForPath(path string) string {
+	if strings.HasPrefix(path, "~/") {
+		home, err := os.UserHomeDir()
+		if err != nil {
+			return Default
+		}
+		path = strings.Replace(path, "~/", home+"/", 1)
+	}
+
+	stat, err := os.Lstat(path)
+	if err != nil {
+		return Default
+	}
+	if stat.IsDir() {
+		return Blue
+	}
+
+	if stat.Mode()&os.ModeSymlink == os.ModeSymlink {
+		if stat, err := os.Stat(path); err == nil && stat.IsDir() {
+			return Cyan
+		}
+		return Yellow
+	} else if stat.Mode()&0111 == 0111 { // any executable
+		return Green
+	}
+
+	if stat.Mode()&os.ModeSymlink == os.ModeSymlink {
+		return Yellow
+	}
+
+	if index := strings.LastIndex(path, "."); index != -1 {
+		if mime := mime.TypeByExtension(path[index:]); strings.HasPrefix(mime, "image") {
+			return Magenta
+		} else if strings.HasPrefix(mime, "application") {
+			return Red
+		}
+	}
+	return Default
+}


Test patch:
diff --git a/action_test.go b/action_test.go
index 0686515..7b07815 100644
--- a/action_test.go
+++ b/action_test.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/rsteube/carapace/internal/assert"
 	"github.com/rsteube/carapace/internal/common"
+	"github.com/rsteube/carapace/pkg/style"
 )
 
 func assertEqual(t *testing.T, expected, actual InvokedAction) {
@@ -102,34 +103,34 @@ func TestNoSpace(t *testing.T) {
 
 func TestActionDirectories(t *testing.T) {
 	assertEqual(t,
-		ActionValues("example/", "docs/", "internal/", "pkg/").noSpace(true).Invoke(Context{}),
+		ActionStyledValues("example/", style.Blue, "docs/", style.Blue, "internal/", style.Blue, "pkg/", style.Blue).noSpace(true).Invoke(Context{}),
 		ActionDirectories().Invoke(Context{CallbackValue: ""}).Filter([]string{"vendor/"}),
 	)
 
 	assertEqual(t,
-		ActionValues("example/", "docs/", "internal/", "pkg/").noSpace(true).Invoke(Context{}).Prefix("./"),
+		ActionStyledValues("example/", style.Blue, "docs/", style.Blue, "internal/", style.Blue, "pkg/", style.Blue).noSpace(true).Invoke(Context{}).Prefix("./"),
 		ActionDirectories().Invoke(Context{CallbackValue: "./"}).Filter([]string{"./vendor/"}),
 	)
 
 	assertEqual(t,
-		ActionValues("_test/", "cmd/").noSpace(true).Invoke(Context{}).Prefix("example/"),
+		ActionStyledValues("_test/", style.Blue, "cmd/", style.Blue).noSpace(true).Invoke(Context{}).Prefix("example/"),
 		ActionDirectories().Invoke(Context{CallbackValue: "example/"}),
 	)
 
 	assertEqual(t,
-		ActionValues("_test/", "cmd/").noSpace(true).Invoke(Context{}).Prefix("example/"),
+		ActionStyledValues("_test/", style.Blue, "cmd/", style.Blue).noSpace(true).Invoke(Context{}).Prefix("example/"),
 		ActionDirectories().Invoke(Context{CallbackValue: "example/cm"}),
 	)
 }
 
 func TestActionFiles(t *testing.T) {
 	assertEqual(t,
-		ActionValues("README.md", "example/", "docs/", "internal/", "pkg/").noSpace(true).Invoke(Context{}),
+		ActionStyledValues("README.md", style.Default, "example/", style.Blue, "docs/", style.Blue, "internal/", style.Blue, "pkg/", style.Blue).noSpace(true).Invoke(Context{}),
 		ActionFiles(".md").Invoke(Context{CallbackValue: ""}).Filter([]string{"vendor/"}),
 	)
 
 	assertEqual(t,
-		ActionValues("_test/", "cmd/", "main.go", "main_test.go").noSpace(true).Invoke(Context{}).Prefix("example/"),
+		ActionStyledValues("_test/", style.Blue, "cmd/", style.Blue, "main.go", style.Default, "main_test.go", style.Default).noSpace(true).Invoke(Context{}).Prefix("example/"),
 		ActionFiles().Invoke(Context{CallbackValue: "example/"}).Filter([]string{"example/example"}),
 	)
 }
diff --git a/carapace_test.go b/carapace_test.go
index 3c88986..0d9c34c 100644
--- a/carapace_test.go
+++ b/carapace_test.go
@@ -227,7 +227,7 @@ func TestComplete(t *testing.T) {
 	cmd.Flags().BoolP("a", "1", false, "")
 	cmd.Flags().BoolP("b", "2", false, "")
 
-	if s, err := complete(cmd, []string{"elvish", "_", "test", "-1"}); err != nil || s != `[{"Value":"-12","Display":"2","Description":"","CodeSuffix":""}]` {
+	if s, err := complete(cmd, []string{"elvish", "_", "test", "-1"}); err != nil || s != `[{"Value":"-12","Display":"2","Description":"","CodeSuffix":"","Style":"default"}]` {
 		t.Error(s)
 	}
 }
@@ -243,7 +243,7 @@ func TestCompleteOptarg(t *testing.T) {
 		"opt": ActionValuesDescribed("value", "description"),
 	})
 
-	if s, err := complete(cmd, []string{"elvish", "_", "test", "--opt="}); err != nil || s != `[{"Value":"--opt=value","Display":"value","Description":"description","CodeSuffix":" "}]` {
+	if s, err := complete(cmd, []string{"elvish", "_", "test", "--opt="}); err != nil || s != `[{"Value":"--opt=value","Display":"value","Description":"description","CodeSuffix":" ","Style":"default"}]` {
 		t.Error(s)
 	}
 }
diff --git a/defaultActions_test.go b/defaultActions_test.go
index e93bebe..23508ce 100644
--- a/defaultActions_test.go
+++ b/defaultActions_test.go
@@ -16,12 +16,14 @@ func TestActionImport(t *testing.T) {
     {
       "Value": "positional1",
       "Display": "positional1",
-      "Description": ""
+      "Description": "",
+      "Style": "default"
     },
     {
       "Value": "p1",
       "Display": "p1",
-      "Description": ""
+      "Description": "",
+      "Style": "default"
     }
   ]
 }`
diff --git a/example/_test/invoke_elvish b/example/_test/invoke_elvish
index ef09b1e..566b93a 100755
--- a/example/_test/invoke_elvish
+++ b/example/_test/invoke_elvish
@@ -2,6 +2,7 @@
 set timeout 10
 set CMDLINE [lindex $argv 0]
 log_user 0
+match_max -d 5000
 spawn elvish -norc
 send "eval (example _carapace elvish|slurp);echo EXPECT_START; \$edit:completion:arg-completer\[example\] $CMDLINE'' | each {|c| echo \$c }; echo EXPECT_END"
 send "\r"
diff --git a/example/_test/invoke_xonsh b/example/_test/invoke_xonsh
index 250e151..7f1267c 100755
--- a/example/_test/invoke_xonsh
+++ b/example/_test/invoke_xonsh
@@ -2,6 +2,7 @@
 set timeout 10
 set CMDLINE [lindex $argv 0]
 log_user 0
+match_max -d 5000
 
 # prevent banner by creating a fake .xonshrc 
 set ::env(HOME) /tmp/carapace-fakehome
diff --git a/example/cmd/_test/elvish.elv b/example/cmd/_test/elvish.elv
index 391f40e..96f3e1a 100644
--- a/example/cmd/_test/elvish.elv
+++ b/example/cmd/_test/elvish.elv
@@ -1,9 +1,9 @@
 set edit:completion:arg-completer[example] = {|@arg|
     example _carapace elvish (all $arg) | from-json | all (one) | each {|c| 
         if (eq $c[Description] "") {
-            edit:complex-candidate $c[Value] &display=$c[Display] &code-suffix=$c[CodeSuffix]
+            edit:complex-candidate $c[Value] &display=(styled $c[Display] $c[Style]) &code-suffix=$c[CodeSuffix]
         } else {
-            edit:complex-candidate $c[Value] &display=$c[Display]" ("(styled $c[Description] magenta)")" &code-suffix=$c[CodeSuffix]
+            edit:complex-candidate $c[Value] &display=(styled $c[Display] $c[Style])" ("(styled $c[Description] magenta)")" &code-suffix=$c[CodeSuffix]
         }
     }
 }