Repository: rsteube/carapace (go)

Issue:
ActionMultiParts: implicit nospace
implicit `nospace` might be possible (e.g when delimiter is empty) by adding a duplicate with space suffix when there is only one value

- add `nospace` bool to Action
- update util functions like `Merge` to keep the state

Pull request description:
implicit nospace
fix #251


Patch:
diff --git a/action.go b/action.go
index d7db37e..1577c87 100644
--- a/action.go
+++ b/action.go
@@ -28,17 +28,9 @@ import (
 
 // Action indicates how to complete a flag or positional argument
 type Action struct {
-	rawValues  []common.RawValue
-	bash       func(callbackValue string) string
-	elvish     func(callbackValue string) string
-	fish       func(callbackValue string) string
-	ion        func(callbackValue string) string
-	nushell    func(callbackValue string) string
-	oil        func(callbackValue string) string
-	powershell func(callbackValue string) string
-	xonsh      func(callbackValue string) string
-	zsh        func(callbackValue string) string
-	callback   CompletionCallback
+	rawValues []common.RawValue
+	callback  CompletionCallback
+	nospace   bool
 }
 
 type ActionMap map[string]Action
@@ -122,7 +114,12 @@ func (a InvokedAction) Merge(others ...InvokedAction) InvokedAction {
 	for _, c := range uniqueRawValues {
 		rawValues = append(rawValues, c)
 	}
-	return InvokedAction(actionRawValues(rawValues...))
+	return InvokedAction(actionRawValues(rawValues...).noSpace(a.nospace))
+}
+
+func (a Action) noSpace(state bool) Action {
+	a.nospace = a.nospace || state
+	return a
 }
 
 func (a InvokedAction) Filter(values []string) InvokedAction {
@@ -136,7 +133,7 @@ func (a InvokedAction) Filter(values []string) InvokedAction {
 			filtered = append(filtered, rawValue)
 		}
 	}
-	return InvokedAction(actionRawValues(filtered...))
+	return InvokedAction(actionRawValues(filtered...).noSpace(a.nospace))
 }
 
 func (a InvokedAction) Prefix(prefix string) InvokedAction {
@@ -177,45 +174,40 @@ func (a InvokedAction) ToMultiPartsA(divider string) Action {
 			vals = append(vals, val, description)
 		}
 
-		return ActionValuesDescribed(vals...)
+		return ActionValuesDescribed(vals...).noSpace(true)
 	})
 }
 
 func (a Action) nestedAction(c Context, maxDepth int) Action {
 	if a.rawValues == nil && a.callback != nil && maxDepth > 0 {
-		return a.callback(c).nestedAction(c, maxDepth-1)
+		return a.callback(c).nestedAction(c, maxDepth-1).noSpace(a.nospace)
 	} else {
 		return a
 	}
 }
 
 func (a InvokedAction) value(shell string, callbackValue string) string { // TODO use context instead?
-	var f func(callbackValue string) string
 	switch shell {
 	case "bash":
-		f = a.bash
+		return bash.ActionRawValues(callbackValue, a.nospace, a.rawValues...)
 	case "fish":
-		f = a.fish
+		return fish.ActionRawValues(callbackValue, a.nospace, a.rawValues...)
 	case "elvish":
-		f = a.elvish
+		return elvish.ActionRawValues(callbackValue, a.nospace, a.rawValues...)
 	case "ion":
-		f = a.ion
+		return ion.ActionRawValues(callbackValue, a.nospace, a.rawValues)
 	case "nushell":
-		f = a.nushell
+		return nushell.ActionRawValues(callbackValue, a.nospace, a.rawValues)
 	case "oil":
-		f = a.oil
+		return oil.ActionRawValues(callbackValue, a.nospace, a.rawValues...)
 	case "powershell":
-		f = a.powershell
+		return powershell.ActionRawValues(callbackValue, a.nospace, a.rawValues...)
 	case "xonsh":
-		f = a.xonsh
+		return xonsh.ActionRawValues(callbackValue, a.nospace, a.rawValues...)
 	case "zsh":
-		f = a.zsh
-	}
-
-	if f == nil {
+		return zsh.ActionRawValues(callbackValue, a.nospace, a.rawValues...)
+	default:
 		return ""
-	} else {
-		return f(callbackValue)
 	}
 }
 
@@ -232,14 +224,14 @@ func ActionBool() Action {
 // ActionDirectories completes directories
 func ActionDirectories() Action {
 	return ActionCallback(func(c Context) Action {
-		return actionPath([]string{""}, true).Invoke(c).ToMultiPartsA("/")
+		return actionPath([]string{""}, true).Invoke(c).ToMultiPartsA("/").noSpace(true)
 	})
 }
 
 // ActionFiles completes files with optional suffix filtering
 func ActionFiles(suffix ...string) Action {
 	return ActionCallback(func(c Context) Action {
-		return actionPath(suffix, false).Invoke(c).ToMultiPartsA("/")
+		return actionPath(suffix, false).Invoke(c).ToMultiPartsA("/").noSpace(true)
 	})
 }
 
@@ -324,16 +316,7 @@ func ActionValuesDescribed(values ...string) Action {
 
 func actionRawValues(rawValues ...common.RawValue) Action {
 	return Action{
-		rawValues:  rawValues,
-		bash:       func(callbackValue string) string { return bash.ActionRawValues(callbackValue, rawValues...) },
-		elvish:     func(callbackValue string) string { return elvish.ActionRawValues(callbackValue, rawValues...) },
-		fish:       func(callbackValue string) string { return fish.ActionRawValues(callbackValue, rawValues...) },
-		ion:        func(callbackValue string) string { return ion.ActionRawValues(callbackValue, rawValues) },
-		nushell:    func(callbackValue string) string { return nushell.ActionRawValues(callbackValue, rawValues) },
-		oil:        func(callbackValue string) string { return oil.ActionRawValues(callbackValue, rawValues...) },
-		powershell: func(callbackValue string) string { return powershell.ActionRawValues(callbackValue, rawValues...) },
-		xonsh:      func(callbackValue string) string { return xonsh.ActionRawValues(callbackValue, rawValues...) },
-		zsh:        func(callbackValue string) string { return zsh.ActionRawValues(callbackValue, rawValues...) },
+		rawValues: rawValues,
 	}
 }
 
@@ -343,10 +326,8 @@ var skipCache bool
 // ActionMessage displays a help messages in places where no completions can be generated
 func ActionMessage(msg string) Action {
 	return ActionCallback(func(c Context) Action {
-		return ActionCallback(func(c Context) Action {
-			skipCache = true                                                                          // TODO find a better solution - any call to ActionMessage i assumed to be an error for now
-			return ActionValuesDescribed("_", "", "ERR", msg).Invoke(c).Prefix(c.CallbackValue).ToA() // needs to be prefixed with current callback value to not be filtered out
-		})
+		skipCache = true                                                                                        // TODO find a better solution - any call to ActionMessage i assumed to be an error for now
+		return ActionValuesDescribed("_", "", "ERR", msg).Invoke(c).Prefix(c.CallbackValue).ToA().noSpace(true) // needs to be prefixed with current callback value to not be filtered out
 	})
 }
 
@@ -367,7 +348,7 @@ func ActionMultiParts(divider string, callback func(c Context) Action) Action {
 		}
 		c.Parts = parts
 
-		return callback(c).Invoke(c).Prefix(prefix).ToA()
+		return callback(c).Invoke(c).Prefix(prefix).ToA().noSpace(true)
 	})
 }
 
@@ -418,7 +399,7 @@ func actionFlags(cmd *cobra.Command) Action {
 		if isShorthandSeries {
 			matches := re.FindStringSubmatch(c.CallbackValue)
 			parts := strings.Split(matches[1], "")
-			return ActionValuesDescribed(vals...).Invoke(c).Filter(parts).Prefix(c.CallbackValue).ToA()
+			return ActionValuesDescribed(vals...).Invoke(c).Filter(parts).Prefix(c.CallbackValue).ToA().noSpace(true)
 		} else {
 			return ActionValuesDescribed(vals...)
 		}
diff --git a/internal/bash/action.go b/internal/bash/action.go
index e965f7f..abea695 100644
--- a/internal/bash/action.go
+++ b/internal/bash/action.go
@@ -76,7 +76,9 @@ func commonValuePrefix(values ...common.RawValue) (prefix string) {
 	return
 }
 
-func ActionRawValues(callbackValue string, values ...common.RawValue) string {
+const nospaceIndicator = "\001"
+
+func ActionRawValues(callbackValue string, nospace bool, values ...common.RawValue) string {
 	filtered := make([]common.RawValue, 0)
 
 	lastSegment := callbackValue // last segment of callbackValue split by COMP_WORDBREAKS
@@ -99,7 +101,7 @@ func ActionRawValues(callbackValue string, values ...common.RawValue) string {
 		// When all display values have the same prefix bash will insert is as partial completion (which skips prefixes/formatting).
 		if valuePrefix := commonValuePrefix(filtered...); lastSegment != valuePrefix {
 			// replace values with common value prefix (`\001` is removed in snippet and compopt nospace will be set)
-			filtered = common.RawValuesFrom(commonValuePrefix(filtered...) + "\001")
+			filtered = common.RawValuesFrom(commonValuePrefix(filtered...) + nospaceIndicator)
 		} else {
 			// prevent insertion of partial display values by prefixing one with space
 			filtered[0].Display = " " + filtered[0].Display
@@ -108,6 +110,10 @@ func ActionRawValues(callbackValue string, values ...common.RawValue) string {
 
 	vals := make([]string, len(filtered))
 	for index, val := range filtered {
+		if nospace && !strings.HasSuffix(val.Value, nospaceIndicator) {
+			val.Value = val.Value + nospaceIndicator
+		}
+
 		if len(filtered) == 1 {
 			vals[index] = quoter.Replace(sanitizer.Replace(val.Value))
 		} else {
diff --git a/internal/elvish/action.go b/internal/elvish/action.go
index af18533..ed180e4 100644
--- a/internal/elvish/action.go
+++ b/internal/elvish/action.go
@@ -23,18 +23,24 @@ func sanitize(values []common.RawValue) []common.RawValue {
 }
 
 type complexCandidate struct {
-	Value   string
-	Display string
+	Value      string
+	Display    string
+	CodeSuffix string
 }
 
-func ActionRawValues(callbackValue string, values ...common.RawValue) string {
+func ActionRawValues(callbackValue string, nospace bool, values ...common.RawValue) string {
+	suffix := " "
+	if nospace {
+		suffix = ""
+	}
+
 	vals := make([]complexCandidate, len(values))
 	for index, val := range sanitize(values) {
 		// TODO have a look at this again later: seems elvish does a good job quoting any problematic characterS so the sanitize step was removed
 		if val.Description == "" {
-			vals[index] = complexCandidate{Value: val.Value, Display: val.Display}
+			vals[index] = complexCandidate{Value: val.Value, Display: val.Display, CodeSuffix: suffix}
 		} else {
-			vals[index] = complexCandidate{Value: val.Value, Display: fmt.Sprintf(`%v (%v)`, val.Display, val.Description)}
+			vals[index] = complexCandidate{Value: val.Value, Display: fmt.Sprintf(`%v (%v)`, val.Display, val.Description), CodeSuffix: suffix}
 		}
 	}
 	m, _ := json.Marshal(vals)
diff --git a/internal/elvish/snippet.go b/internal/elvish/snippet.go
index e676040..3dc833e 100644
--- a/internal/elvish/snippet.go
+++ b/internal/elvish/snippet.go
@@ -8,7 +8,7 @@ import (
 
 func Snippet(cmd *cobra.Command) string {
 	return fmt.Sprintf(`edit:completion:arg-completer[%v] = [@arg]{
-    %v _carapace elvish _ (all $arg) | from-json | all (one) | each [c]{ edit:complex-candidate $c[Value] &display=$c[Display] }
+    %v _carapace elvish _ (all $arg) | from-json | all (one) | each [c]{ edit:complex-candidate $c[Value] &display=$c[Display] &code-suffix=$c[CodeSuffix] }
 }
 `, cmd.Name(), uid.Executable())
 }
diff --git a/internal/fish/action.go b/internal/fish/action.go
index a5c7e7a..a3b06d9 100644
--- a/internal/fish/action.go
+++ b/internal/fish/action.go
@@ -12,7 +12,7 @@ var sanitizer = strings.NewReplacer(
 	"\t", ``,
 )
 
-func ActionRawValues(callbackValues string, values ...common.RawValue) string {
+func ActionRawValues(callbackValues string, nospace bool, values ...common.RawValue) string {
 	vals := make([]string, len(values))
 	for index, val := range values {
 		vals[index] = fmt.Sprintf("%v\t%v", sanitizer.Replace(val.Value), sanitizer.Replace(val.Description))
diff --git a/internal/ion/action.go b/internal/ion/action.go
index 53e28cc..e8207da 100644
--- a/internal/ion/action.go
+++ b/internal/ion/action.go
@@ -28,12 +28,16 @@ type suggestion struct {
 	Display string
 }
 
-func ActionRawValues(callbackValue string, values common.RawValues) string {
+func ActionRawValues(callbackValue string, nospace bool, values common.RawValues) string {
 	filtered := values.FilterPrefix(callbackValue)
 	sort.Sort(common.ByDisplay(filtered))
 
 	vals := make([]suggestion, len(filtered))
 	for index, val := range sanitize(filtered) {
+		if !nospace {
+			val.Value = val.Value + " "
+		}
+
 		if val.Description == "" {
 			vals[index] = suggestion{Value: val.Value, Display: val.Display}
 		} else {
diff --git a/internal/nushell/action.go b/internal/nushell/action.go
index 50be79f..d3b8629 100644
--- a/internal/nushell/action.go
+++ b/internal/nushell/action.go
@@ -28,12 +28,16 @@ type suggestion struct {
 	Display string
 }
 
-func ActionRawValues(callbackValue string, values common.RawValues) string {
+func ActionRawValues(callbackValue string, nospace bool, values common.RawValues) string {
 	filtered := values.FilterPrefix(callbackValue)
 	sort.Sort(common.ByDisplay(filtered))
 
 	vals := make([]suggestion, len(filtered))
 	for index, val := range sanitize(filtered) {
+		if !nospace {
+			val.Value = val.Value + " "
+		}
+
 		if val.Description == "" {
 			vals[index] = suggestion{Value: val.Value, Display: val.Display}
 		} else {
diff --git a/internal/oil/action.go b/internal/oil/action.go
index d2d0ff8..84a9998 100644
--- a/internal/oil/action.go
+++ b/internal/oil/action.go
@@ -20,7 +20,7 @@ func Sanitize(values ...string) []string {
 	return sanitized
 }
 
-func ActionRawValues(callbackValue string, values ...common.RawValue) string {
+func ActionRawValues(callbackValue string, nospace bool, values ...common.RawValue) string {
 	filtered := make([]common.RawValue, 0)
 
 	for _, r := range values {
diff --git a/internal/powershell/action.go b/internal/powershell/action.go
index 8ada722..d68d2da 100644
--- a/internal/powershell/action.go
+++ b/internal/powershell/action.go
@@ -37,7 +37,7 @@ func ensureNotEmpty(s string) string {
 	return s
 }
 
-func ActionRawValues(callbackValue string, values ...common.RawValue) string {
+func ActionRawValues(callbackValue string, nospace bool, values ...common.RawValue) string {
 	filtered := common.ByValues(values).Filter(callbackValue)
 	sort.Sort(common.ByDisplay(filtered))
 
@@ -50,6 +50,10 @@ func ActionRawValues(callbackValue string, values ...common.RawValue) string {
 				val.Value = fmt.Sprintf("'%v'", val.Value)
 			}
 
+			if !nospace {
+				val.Value = val.Value + " "
+			}
+
 			vals = append(vals, completionResult{
 				CompletionText: val.Value,
 				ListItemText:   ensureNotEmpty(sanitizer.Replace(val.Display)),
diff --git a/internal/xonsh/action.go b/internal/xonsh/action.go
index 08bda51..851d415 100644
--- a/internal/xonsh/action.go
+++ b/internal/xonsh/action.go
@@ -28,7 +28,7 @@ type richCompletion struct {
 	Description string
 }
 
-func ActionRawValues(callbackValue string, values ...common.RawValue) string {
+func ActionRawValues(callbackValue string, nospace bool, values ...common.RawValue) string {
 	filtered := make([]common.RawValue, 0)
 
 	for _, r := range values {
@@ -48,6 +48,11 @@ func ActionRawValues(callbackValue string, values ...common.RawValue) string {
 				val.Value = fmt.Sprintf("'%v'", val.Value)
 			}
 		}
+
+		if !nospace {
+			val.Value = val.Value + " "
+		}
+
 		vals[index] = richCompletion{Value: val.Value, Display: val.Display, Description: val.Description}
 	}
 	m, _ := json.Marshal(vals)
diff --git a/internal/zsh/action.go b/internal/zsh/action.go
index cedb216..2fb4f49 100644
--- a/internal/zsh/action.go
+++ b/internal/zsh/action.go
@@ -22,7 +22,7 @@ func Sanitize(values ...string) []string {
 	return sanitized
 }
 
-func ActionRawValues(callbackValue string, values ...common.RawValue) string {
+func ActionRawValues(callbackValue string, nospace bool, values ...common.RawValue) string {
 	filtered := make([]common.RawValue, 0)
 
 	for _, r := range values {
@@ -34,6 +34,9 @@ func ActionRawValues(callbackValue string, values ...common.RawValue) string {
 	vals := make([]string, len(filtered))
 	for index, val := range filtered {
 		val.Value = sanitizer.Replace(val.Value)
+		if nospace {
+			val.Value = val.Value + "\001"
+		}
 		val.Display = sanitizer.Replace(val.Display)
 		val.Description = sanitizer.Replace(val.Description)
 
diff --git a/internal/zsh/snippet.go b/internal/zsh/snippet.go
index 7f8382f..e6689e8 100644
--- a/internal/zsh/snippet.go
+++ b/internal/zsh/snippet.go
@@ -28,7 +28,13 @@ function _%v_completion {
   local vals=(${c%%%%$'\t'*})
   # shellcheck disable=SC2034,2206
   local descriptions=(${c##*$'\t'})
-  compadd -S '' -d descriptions -a -- vals
+
+  local suffix=' '
+  [[ ${vals[1]} == *$'\001' ]] && suffix=''
+  # shellcheck disable=SC2034,2206
+  vals=(${vals%%%%$'\001'*})
+
+  compadd -S "${suffix}" -d descriptions -a -- vals
 }
 compquote '' 2>/dev/null && _%v_completion
 compdef _%v_completion %v


Test patch:
diff --git a/example/cmd/_test/elvish.elv b/example/cmd/_test/elvish.elv
index 3987d19..2ca903b 100644
--- a/example/cmd/_test/elvish.elv
+++ b/example/cmd/_test/elvish.elv
@@ -1,4 +1,4 @@
 edit:completion:arg-completer[example] = [@arg]{
-    example _carapace elvish _ (all $arg) | from-json | all (one) | each [c]{ edit:complex-candidate $c[Value] &display=$c[Display] }
+    example _carapace elvish _ (all $arg) | from-json | all (one) | each [c]{ edit:complex-candidate $c[Value] &display=$c[Display] &code-suffix=$c[CodeSuffix] }
 }
 
diff --git a/example/cmd/_test/zsh.sh b/example/cmd/_test/zsh.sh
index 2de3b82..ab4b8ce 100644
--- a/example/cmd/_test/zsh.sh
+++ b/example/cmd/_test/zsh.sh
@@ -18,7 +18,13 @@ function _example_completion {
   local vals=(${c%%$'\t'*})
   # shellcheck disable=SC2034,2206
   local descriptions=(${c##*$'\t'})
-  compadd -S '' -d descriptions -a -- vals
+
+  local suffix=' '
+  [[ ${vals[1]} == *$'\001' ]] && suffix=''
+  # shellcheck disable=SC2034,2206
+  vals=(${vals%%$'\001'*})
+
+  compadd -S "${suffix}" -d descriptions -a -- vals
 }
 compquote '' 2>/dev/null && _example_completion
 compdef _example_completion example