Repository: rsteube/carapace
Pull request #639 (2022-12-06 20:32:43)

zsh: tag support
related #584


Patch:
diff --git a/action.go b/action.go
index 5b3f9f8..0d92df7 100644
--- a/action.go
+++ b/action.go
@@ -32,7 +32,7 @@ func (a Action) Cache(timeout time.Duration, keys ...pkgcache.Key) Action {
 		a.callback = func(c Context) Action {
 			if cacheFile, err := cache.File(file, line, keys...); err == nil {
 				if rawValues, err := cache.Load(cacheFile, timeout); err == nil {
-					return Action{rawValues: rawValues}
+					return actionRawValues(rawValues...)
 				}
 				invokedAction := (Action{callback: cachedCallback}).Invoke(c)
 				if invokedAction.meta.Messages.IsEmpty() {
@@ -66,7 +66,11 @@ func (a Action) nestedAction(c Context, maxDepth int) Action {
 	}
 	if a.rawValues == nil && a.callback != nil {
 		result := a.callback(c).nestedAction(c, maxDepth-1)
-		result.meta.Merge(a.meta)
+		if usage := a.meta.Usage; usage != "" {
+			result.meta.Usage = usage
+		}
+		result.meta.Nospace.Merge(a.meta.Nospace)
+		result.meta.Messages.Merge(a.meta.Messages)
 		return result
 	}
 	return a
diff --git a/defaultActions.go b/defaultActions.go
index 582fd60..85f3524 100644
--- a/defaultActions.go
+++ b/defaultActions.go
@@ -62,10 +62,11 @@ func ActionImport(output []byte) Action {
 		if err := json.Unmarshal(output, &e); err != nil {
 			return ActionMessage(err.Error())
 		}
-		return Action{
-			rawValues: e.RawValues,
-			meta:      e.Meta,
-		}
+		a := actionRawValues(e.RawValues...)
+		a.meta.Nospace = e.Nospace
+		a.meta.Usage = e.Usage
+		a.meta.Messages = e.Messages
+		return a
 	})
 }
 
@@ -129,7 +130,7 @@ func ActionValues(values ...string) Action {
 		for _, val := range values {
 			vals = append(vals, common.RawValue{Value: val, Display: val, Description: "", Style: style.Default})
 		}
-		return Action{rawValues: vals}
+		return actionRawValues(vals...)
 	})
 }
 
@@ -144,7 +145,7 @@ func ActionStyledValues(values ...string) Action {
 		for i := 0; i < len(values); i += 2 {
 			vals = append(vals, common.RawValue{Value: values[i], Display: values[i], Description: "", Style: values[i+1]})
 		}
-		return Action{rawValues: vals}
+		return actionRawValues(vals...)
 	})
 }
 
@@ -159,7 +160,7 @@ func ActionValuesDescribed(values ...string) Action {
 		for i := 0; i < len(values); i += 2 {
 			vals = append(vals, common.RawValue{Value: values[i], Display: values[i], Description: values[i+1], Style: style.Default})
 		}
-		return Action{rawValues: vals}
+		return actionRawValues(vals...)
 	})
 }
 
@@ -174,7 +175,7 @@ func ActionStyledValuesDescribed(values ...string) Action {
 		for i := 0; i < len(values); i += 3 {
 			vals = append(vals, common.RawValue{Value: values[i], Display: values[i], Description: values[i+1], Style: values[i+2]})
 		}
-		return Action{rawValues: vals}
+		return actionRawValues(vals...)
 	})
 }
 
diff --git a/docs/src/carapace.md b/docs/src/carapace.md
index 74eb95f..2b4d814 100644
--- a/docs/src/carapace.md
+++ b/docs/src/carapace.md
@@ -6,7 +6,7 @@
 - [Elvish](https://elv.sh/)
 - [Fish](https://fishshell.com/)
 - [Ion](https://doc.redox-os.org/ion-manual/html/) ([experimental](https://github.com/rsteube/carapace/issues/88))
-- [Nushell](https://www.nushell.sh/)
+- [Nushell](https://www.nushell.sh/) ([experimental](https://github.com/rsteube/carapace/issues/89))
 - [Oil](http://www.oilshell.org/)
 - [Powershell](https://microsoft.com/powershell)
 - [Xonsh](https://xon.sh/)
diff --git a/docs/src/carapace/gen/hiddenSubcommand.md b/docs/src/carapace/gen/hiddenSubcommand.md
index ffeeca4..d112a9f 100644
--- a/docs/src/carapace/gen/hiddenSubcommand.md
+++ b/docs/src/carapace/gen/hiddenSubcommand.md
@@ -21,7 +21,7 @@ eval (command _carapace | slurp)
 # fish
 command _carapace | source
 
-# nushell (update config.nu according to output)
+# nushell
 command _carapace nushell
 
 # oil
diff --git a/internal/common/meta.go b/internal/common/meta.go
index 86003cb..5864e47 100644
--- a/internal/common/meta.go
+++ b/internal/common/meta.go
@@ -5,11 +5,3 @@ type Meta struct {
 	Nospace  SuffixMatcher
 	Usage    string
 }
-
-func (m *Meta) Merge(other Meta) {
-	if other.Usage != "" {
-		m.Usage = other.Usage
-	}
-	m.Nospace.Merge(other.Nospace)
-	m.Messages.Merge(other.Messages)
-}
diff --git a/internal/common/value.go b/internal/common/value.go
index 39234b1..f8f9d90 100644
--- a/internal/common/value.go
+++ b/internal/common/value.go
@@ -2,6 +2,7 @@
 package common
 
 import (
+	"sort"
 	"strings"
 
 	"github.com/rsteube/carapace/pkg/style"
@@ -74,6 +75,26 @@ func (r RawValues) FilterPrefix(prefix string) RawValues {
 	return filtered
 }
 
+func (r RawValues) EachTag(f func(tag string, values RawValues)) {
+	tagGroups := make(map[string]RawValues)
+	for _, val := range r {
+		if _, exists := tagGroups[val.Tag]; !exists {
+			tagGroups[val.Tag] = make(RawValues, 0)
+		}
+		tagGroups[val.Tag] = append(tagGroups[val.Tag], val)
+	}
+
+	tags := make([]string, 0)
+	for tag := range tagGroups {
+		tags = append(tags, tag)
+	}
+	sort.Strings(tags)
+
+	for _, tag := range tags {
+		f(tag, tagGroups[tag])
+	}
+}
+
 // ByValue alias to filter by value.
 type ByValue []RawValue
 
diff --git a/internal/shell/export/action.go b/internal/shell/export/action.go
index e2b27cb..e2e6727 100644
--- a/internal/shell/export/action.go
+++ b/internal/shell/export/action.go
@@ -9,8 +9,10 @@ import (
 )
 
 type Export struct {
-	Version string
-	common.Meta
+	Version   string
+	Usage     string
+	Messages  common.Messages
+	Nospace   common.SuffixMatcher
 	RawValues common.RawValues
 }
 
@@ -18,7 +20,9 @@ func ActionRawValues(currentWord string, meta common.Meta, values common.RawValu
 	sort.Sort(common.ByValue(values))
 	m, _ := json.Marshal(Export{
 		Version:   version(),
-		Meta:      meta,
+		Usage:     meta.Usage,
+		Messages:  meta.Messages,
+		Nospace:   meta.Nospace,
 		RawValues: values,
 	})
 	return string(m)
diff --git a/internal/shell/zsh/action.go b/internal/shell/zsh/action.go
index 67cdcc3..4cde528 100644
--- a/internal/shell/zsh/action.go
+++ b/internal/shell/zsh/action.go
@@ -47,32 +47,33 @@ func quoteValue(s string) string {
 
 // ActionRawValues formats values for zsh
 func ActionRawValues(currentWord string, meta common.Meta, values common.RawValues) string {
-	maxLength := 0
-	for _, r := range values {
-		if length := len(r.Display); length > maxLength {
-			maxLength = length
-		}
-	}
 
-	vals := make([]string, len(values))
-	for index, val := range values {
-		val.Value = sanitizer.Replace(val.Value)
-		val.Value = quoteValue(val.Value)
-		val.Value = strings.ReplaceAll(val.Value, `\`, `\\`) // TODO find out why `_describe` needs another backslash
-		val.Value = strings.ReplaceAll(val.Value, `:`, `\:`) // TODO find out why `_describe` needs another backslash
-		if !meta.Nospace.Matches(val.Value) {
-			val.Value = val.Value + " "
-		}
-		val.Display = sanitizer.Replace(val.Display)
-		val.Display = strings.ReplaceAll(val.Display, `\`, `\\`) // TODO find out why `_describe` needs another backslash
-		val.Display = strings.ReplaceAll(val.Display, `:`, `\:`) // TODO find out why `_describe` needs another backslash
-		val.Description = sanitizer.Replace(val.Description)
+	tagGroup := make([]string, 0)
+	values.EachTag(func(tag string, values common.RawValues) {
+		vals := make([]string, len(values))
+		displays := make([]string, len(values))
+		for index, val := range values {
+			val.Value = sanitizer.Replace(val.Value)
+			val.Value = quoteValue(val.Value)
+			val.Value = strings.ReplaceAll(val.Value, `\`, `\\`) // TODO find out why `_describe` needs another backslash
+			val.Value = strings.ReplaceAll(val.Value, `:`, `\:`) // TODO find out why `_describe` needs another backslash
+			if !meta.Nospace.Matches(val.Value) {
+				val.Value = val.Value + " "
+			}
+			val.Display = sanitizer.Replace(val.Display)
+			val.Display = strings.ReplaceAll(val.Display, `\`, `\\`) // TODO find out why `_describe` needs another backslash
+			val.Display = strings.ReplaceAll(val.Display, `:`, `\:`) // TODO find out why `_describe` needs another backslash
+			val.Description = sanitizer.Replace(val.Description)
 
-		if strings.TrimSpace(val.Description) == "" {
-			vals[index] = fmt.Sprintf("%v\t%v", val.Value, val.Display)
-		} else {
-			vals[index] = fmt.Sprintf("%v\t%v:%v", val.Value, val.Display, val.TrimmedDescription())
+			vals[index] = val.Value
+
+			if strings.TrimSpace(val.Description) == "" {
+				displays[index] = val.Display
+			} else {
+				displays[index] = fmt.Sprintf("%v:%v", val.Display, val.Description)
+			}
 		}
-	}
-	return fmt.Sprintf("%v\n%v\n%v", zstyles{values}.Format(), message{meta}.Format(), strings.Join(vals, "\n"))
+		tagGroup = append(tagGroup, strings.Join([]string{tag, strings.Join(displays, "\n"), strings.Join(vals, "\n")}, "\003"))
+	})
+	return fmt.Sprintf("%v\001%v\001%v\001", zstyles{values}.Format(), message{meta}.Format(), strings.Join(tagGroup, "\002")+"\002")
 }
diff --git a/internal/shell/zsh/message.go b/internal/shell/zsh/message.go
index ce7f70b..1a4406a 100644
--- a/internal/shell/zsh/message.go
+++ b/internal/shell/zsh/message.go
@@ -22,9 +22,9 @@ func (m message) Format() string {
 	}
 
 	if len(formatted) > 0 {
-		return strings.Join(formatted, "\t")
+		return strings.Join(formatted, "\n")
 	}
-	return "NONE" // TODO fix empty line issue in script
+	return ""
 }
 
 func (m message) formatMessage(message, _style string) string {
@@ -35,18 +35,6 @@ func (m message) formatMessage(message, _style string) string {
 		"\v", ``,
 		"\f", ``,
 		"\b", ``,
-
-		// TODO verify which are needed
-		`&`, `\&`,
-		`'`, `\'`,
-		`$`, `\$`,
-		`#`, `\#`,
-		`;`, `\;`,
-		`*`, `\*`,
-		`\`, `\\`,
-		`~`, `\~`,
-
-		`\`, `\\`,
 	).Replace(message)
 
 	return fmt.Sprintf("\x1b[%vm%v\x1b[%vm", style.SGR(_style), msg, style.SGR("fg-default"))
diff --git a/internal/shell/zsh/snippet.go b/internal/shell/zsh/snippet.go
index e19b2ff..0a429c4 100644
--- a/internal/shell/zsh/snippet.go
+++ b/internal/shell/zsh/snippet.go
@@ -14,31 +14,30 @@ func Snippet(cmd *cobra.Command) string {
 function _%v_completion {
   local IFS=$'\n'
   
-  # shellcheck disable=SC2207,SC2086,SC2154
+  # shellcheck disable=SC2086,SC2154,SC2155
   if echo ${words}"''" | xargs echo 2>/dev/null > /dev/null; then
-    # shellcheck disable=SC2207,SC2086
-    local lines=($(echo ${words}"''" | CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs %v _carapace zsh ))
+    local lines="$(echo ${words}"''" | CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs %v _carapace zsh )"
   elif echo ${words} | sed "s/\$/'/" | xargs echo 2>/dev/null > /dev/null; then
-    # shellcheck disable=SC2207,SC2086
-    local lines=($(echo ${words} | sed "s/\$/'/" | CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs %v _carapace zsh))
+    local lines="$(echo ${words} | sed "s/\$/'/" | CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs %v _carapace zsh)"
   else
-    # shellcheck disable=SC2207,SC2086
-    local lines=($(echo ${words} | sed 's/$/"/' | CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs %v _carapace zsh))
+    local lines="$(echo ${words} | sed 's/$/"/' | CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs %v _carapace zsh)"
   fi
 
-  zstyle ":completion:${curcontext}:*" list-colors "${lines[1]}"
-
-  local line_break=$'\n'
-  [[ ! "${lines[2]}" == "NONE" ]] && _message -r "${lines[2]//$'\t'/${line_break}}"
+  local zstyle message data
+  IFS=$'\001' read -r -d '' zstyle message data <<<"${lines}"
+  # shellcheck disable=SC2154
+  zstyle ":completion:${curcontext}:*" list-colors "${zstyle}"
+  [ -z "$message" ] || _message -r "${message}"
   
-  # shellcheck disable=SC2034,2206
-  lines=(${lines[@]:2})
-  # shellcheck disable=SC2034,2206
-  local vals=(${lines%%$'\t'*})
-  # shellcheck disable=SC2034,2206
-  local displays=(${lines##*$'\t'})
-
-  _describe -t "sometag" "sometag" displays vals -Q -S ''
+  local block tag displays values displaysArr valuesArr
+  while IFS=$'\002' read -r -d $'\002' block; do
+    IFS=$'\003' read -r -d '' tag displays values <<<"${block}"
+    # shellcheck disable=SC2034
+    IFS=$'\n' read -r -d $'\004' -A displaysArr <<<"${displays}"$'\004'
+    IFS=$'\n' read -r -d $'\004' -A valuesArr <<<"${values}"$'\004'
+  
+    [[ ${#valuesArr[@]} -gt 1 ]] && _describe -t "${tag}" "${tag}" displaysArr valuesArr -Q -S ''
+  done <<<"${data}"
 }
 compquote '' 2>/dev/null && _%v_completion
 compdef _%v_completion %v
diff --git a/internal/shell/zsh/zstyle.go b/internal/shell/zsh/zstyle.go
index f89602c..258c2ba 100644
--- a/internal/shell/zsh/zstyle.go
+++ b/internal/shell/zsh/zstyle.go
@@ -55,19 +55,15 @@ func (z zstyles) Format() string {
 		"~", `\~`,
 	)
 
-	hasAliases := z.hasAliases()
 	formatted := make([]string, 0)
 	if len(z.rawValues) < 1000 { // disable styling for large amount of values (bad performance)
 		for _, val := range z.rawValues {
-			// TODO this might need to be handled differently regarding tags/groups (e.g. done for each tag)
-			pattern := "=(#b)(%v)( * -- *)=0=%v=%v"  // match value with description
-			if val.Description == "" || hasAliases { // different behaviour in `_describe` when values are on the same line
-				pattern = "=(#b)(%v)()=0=%v=%v" // only match value
-			}
-
-			formatted = append(formatted, fmt.Sprintf(pattern, replacer.Replace(val.Display), z.valueSGR(val), z.descriptionSGR()))
+			// match value with description
+			formatted = append(formatted, fmt.Sprintf("=(#b)(%v)( * -- *)=0=%v=%v", replacer.Replace(val.Display), z.valueSGR(val), z.descriptionSGR()))
+			// only match value (also matches aliased completions that are placed on the same line if the space allows it)
+			formatted = append(formatted, fmt.Sprintf("=(#b)(%v)()=0=%v=%v", replacer.Replace(val.Display), z.valueSGR(val), z.descriptionSGR()))
 		}
 	}
-	formatted = append(formatted, fmt.Sprintf("=(#b)(%v)=0=%v", "-- *", z.descriptionSGR()))
+	formatted = append(formatted, fmt.Sprintf("=(#b)(%v)=0=%v", "-- *", z.descriptionSGR())) // match description for aliased completions
 	return strings.Join(formatted, ":")
 }
diff --git a/internalActions.go b/internalActions.go
index 7dba39a..c483152 100644
--- a/internalActions.go
+++ b/internalActions.go
@@ -8,6 +8,7 @@ import (
 	"regexp"
 	"strings"
 
+	"github.com/rsteube/carapace/internal/common"
 	"github.com/rsteube/carapace/internal/pflagfork"
 	"github.com/rsteube/carapace/pkg/style"
 	"github.com/spf13/cobra"
@@ -132,7 +133,7 @@ func actionSubcommands(cmd *cobra.Command) Action {
 				if id := subcommand.GroupID; id != "" {
 					tag = fmt.Sprintf("%v %v", id, tag)
 				} else if len(cmd.Groups()) != 0 {
-					tag = fmt.Sprintf("%v %v", "additional", tag) // implicit group name in cobra
+					tag = "other commands"
 				}
 				batch = append(batch, ActionValuesDescribed(subcommand.Name(), subcommand.Short).Tag(tag))
 				for _, alias := range subcommand.Aliases {
@@ -143,3 +144,9 @@ func actionSubcommands(cmd *cobra.Command) Action {
 		return batch.ToA()
 	})
 }
+
+func actionRawValues(rawValues ...common.RawValue) Action {
+	return Action{
+		rawValues: rawValues,
+	}
+}
diff --git a/invokedAction.go b/invokedAction.go
index 70480da..3309336 100644
--- a/invokedAction.go
+++ b/invokedAction.go
@@ -30,12 +30,18 @@ func (a InvokedAction) Filter(values []string) InvokedAction {
 //	c := a.Merge(b) // ["A", "B", "C"]
 func (a InvokedAction) Merge(others ...InvokedAction) InvokedAction {
 	uniqueRawValues := make(map[string]common.RawValue)
-	var meta common.Meta
+	nospace := a.meta.Nospace
+	usage := a.meta.Usage
+	messages := a.meta.Messages
 	for _, other := range append([]InvokedAction{a}, others...) {
 		for _, c := range other.rawValues {
 			uniqueRawValues[c.Value] = c
 		}
-		meta.Merge(other.meta)
+		nospace.Merge(other.meta.Nospace)
+		if other.meta.Usage != "" {
+			usage = other.meta.Usage
+		}
+		messages.Merge(other.meta.Messages)
 	}
 
 	rawValues := make([]common.RawValue, 0, len(uniqueRawValues))
@@ -43,8 +49,10 @@ func (a InvokedAction) Merge(others ...InvokedAction) InvokedAction {
 		rawValues = append(rawValues, c)
 	}
 
-	invoked := InvokedAction{Action{rawValues: rawValues}}
-	invoked.meta.Merge(meta)
+	invoked := InvokedAction{actionRawValues(rawValues...)}
+	invoked.meta.Usage = usage
+	invoked.meta.Nospace.Merge(nospace)
+	invoked.meta.Messages = messages // TODO verify & optimize
 	return invoked
 }
 
@@ -136,7 +144,7 @@ func (a InvokedAction) ToMultiPartsA(dividers ...string) Action {
 			vals = append(vals, val)
 		}
 
-		a := Action{rawValues: vals}
+		a := actionRawValues(vals...)
 		for _, divider := range dividers {
 			if runes := []rune(divider); len(runes) == 0 {
 				a.meta.Nospace.Add('*')


Test patch:
diff --git a/example/cmd/_test/zsh.sh b/example/cmd/_test/zsh.sh
index 43dfeae..9108b25 100644
--- a/example/cmd/_test/zsh.sh
+++ b/example/cmd/_test/zsh.sh
@@ -2,31 +2,30 @@
 function _example_completion {
   local IFS=$'\n'
   
-  # shellcheck disable=SC2207,SC2086,SC2154
+  # shellcheck disable=SC2086,SC2154,SC2155
   if echo ${words}"''" | xargs echo 2>/dev/null > /dev/null; then
-    # shellcheck disable=SC2207,SC2086
-    local lines=($(echo ${words}"''" | CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs example _carapace zsh ))
+    local lines="$(echo ${words}"''" | CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs example _carapace zsh )"
   elif echo ${words} | sed "s/\$/'/" | xargs echo 2>/dev/null > /dev/null; then
-    # shellcheck disable=SC2207,SC2086
-    local lines=($(echo ${words} | sed "s/\$/'/" | CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs example _carapace zsh))
+    local lines="$(echo ${words} | sed "s/\$/'/" | CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs example _carapace zsh)"
   else
-    # shellcheck disable=SC2207,SC2086
-    local lines=($(echo ${words} | sed 's/$/"/' | CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs example _carapace zsh))
+    local lines="$(echo ${words} | sed 's/$/"/' | CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs example _carapace zsh)"
   fi
 
-  zstyle ":completion:${curcontext}:*" list-colors "${lines[1]}"
-
-  local line_break=$'\n'
-  [[ ! "${lines[2]}" == "NONE" ]] && _message -r "${lines[2]//$'\t'/${line_break}}"
+  local zstyle message data
+  IFS=$'\001' read -r -d '' zstyle message data <<<"${lines}"
+  # shellcheck disable=SC2154
+  zstyle ":completion:${curcontext}:*" list-colors "${zstyle}"
+  [ -z "$message" ] || _message -r "${message}"
   
-  # shellcheck disable=SC2034,2206
-  lines=(${lines[@]:2})
-  # shellcheck disable=SC2034,2206
-  local vals=(${lines%$'\t'*})
-  # shellcheck disable=SC2034,2206
-  local displays=(${lines##*$'\t'})
-
-  _describe -t "sometag" "sometag" displays vals -Q -S ''
+  local block tag displays values displaysArr valuesArr
+  while IFS=$'\002' read -r -d $'\002' block; do
+    IFS=$'\003' read -r -d '' tag displays values <<<"${block}"
+    # shellcheck disable=SC2034
+    IFS=$'\n' read -r -d $'\004' -A displaysArr <<<"${displays}"$'\004'
+    IFS=$'\n' read -r -d $'\004' -A valuesArr <<<"${values}"$'\004'
+  
+    [[ ${#valuesArr[@]} -gt 1 ]] && _describe -t "${tag}" "${tag}" displaysArr valuesArr -Q -S ''
+  done <<<"${data}"
 }
 compquote '' 2>/dev/null && _example_completion
 compdef _example_completion example