Repository: rsteube/carapace (go)

Issue:
conditional nospace
`nospace` should be conditional.
Maybe by using a `string` instead of `bool` providing a character sequence (suffixes) that force `nospace`.
Merge should combine these uniquely.
`*` should be handled specially and match all.
Caveat: might not be configurable separately in each shell and increases script complexity.

related #598 

Pull request:
conditional nospace
fix #599

Patch:
diff --git a/action.go b/action.go
index 712c1e4..6bb3b52 100644
--- a/action.go
+++ b/action.go
@@ -14,7 +14,7 @@ import (
 type Action struct {
 	rawValues []common.RawValue
 	callback  CompletionCallback
-	nospace   bool
+	nospace   common.SuffixMatcher
 	skipcache bool
 }
 
@@ -66,14 +66,17 @@ func (a Action) nestedAction(c Context, maxDepth int) Action {
 		return ActionMessage("maximum recursion depth exceeded")
 	}
 	if a.rawValues == nil && a.callback != nil {
-		return a.callback(c).nestedAction(c, maxDepth-1).noSpace(a.nospace).skipCache(a.skipcache)
+		return a.callback(c).nestedAction(c, maxDepth-1).noSpace(string(a.nospace)).skipCache(a.skipcache)
 	}
 	return a
 }
 
-// NoSpace disables space suffix
-func (a Action) NoSpace() Action {
-	return a.noSpace(true)
+// NoSpace disables space suffix for given characters (or all if none are given).
+func (a Action) NoSpace(suffixes ...rune) Action {
+	if len(suffixes) == 0 {
+		return a.noSpace("*")
+	}
+	return a.noSpace(string(suffixes))
 }
 
 // Style sets the style
@@ -160,8 +163,8 @@ func (a Action) Suppress(expr ...string) Action {
 	})
 }
 
-func (a Action) noSpace(state bool) Action {
-	a.nospace = a.nospace || state
+func (a Action) noSpace(suffixes string) Action {
+	a.nospace = a.nospace.Add(suffixes)
 	return a
 }
 
diff --git a/compat.go b/compat.go
index f007fd7..912cee2 100644
--- a/compat.go
+++ b/compat.go
@@ -40,7 +40,7 @@ func cobraValuesFor(action InvokedAction) []string {
 
 func cobraDirectiveFor(action InvokedAction) cobra.ShellCompDirective {
 	directive := cobra.ShellCompDirectiveNoFileComp
-	if action.nospace {
+	if action.nospace != "" {
 		directive = directive | cobra.ShellCompDirectiveNoSpace
 	}
 	return directive
diff --git a/defaultActions.go b/defaultActions.go
index a108b0e..4fd64ba 100644
--- a/defaultActions.go
+++ b/defaultActions.go
@@ -68,9 +68,7 @@ func ActionImport(output []byte) Action {
 			return ActionMessage(err.Error())
 		}
 		a := actionRawValues(e.RawValues...)
-		if e.Nospace {
-			a = a.NoSpace()
-		}
+		a = a.NoSpace([]rune(e.Nospace)...)
 		return a
 	})
 }
@@ -107,7 +105,7 @@ func ActionExecute(cmd *cobra.Command) Action {
 // ActionDirectories completes directories
 func ActionDirectories() Action {
 	return ActionCallback(func(c Context) Action {
-		return actionPath([]string{""}, true).Invoke(c).ToMultiPartsA("/").noSpace(true).StyleF(func(s string) string {
+		return actionPath([]string{""}, true).Invoke(c).ToMultiPartsA("/").StyleF(func(s string) string {
 			if abs, err := c.Abs(s); err == nil {
 				return style.ForPath(abs)
 			}
@@ -119,7 +117,7 @@ func ActionDirectories() Action {
 // 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("/").noSpace(true).StyleF(func(s string) string {
+		return actionPath(suffix, false).Invoke(c).ToMultiPartsA("/").StyleF(func(s string) string {
 			if abs, err := c.Abs(s); err == nil {
 				return style.ForPath(abs)
 			}
@@ -254,7 +252,7 @@ func ActionMessage(msg string, a ...interface{}) Action {
 		}
 		return ActionStyledValuesDescribed("_", "", style.Default, "ERR", msg, style.Carapace.Error).
 			Invoke(c).Prefix(c.CallbackValue).ToA(). // needs to be prefixed with current callback value to not be filtered out
-			noSpace(true).skipCache(true)
+			noSpace("*").skipCache(true)
 	})
 }
 
@@ -276,7 +274,11 @@ func ActionMultiParts(divider string, callback func(c Context) Action) Action {
 		}
 		c.Parts = parts
 
-		return callback(c).Invoke(c).Prefix(prefix).ToA().noSpace(true)
+		nospace := "*"
+		if runes := []rune(divider); len(runes) > 0 {
+			nospace = string(runes[len(runes)-1])
+		}
+		return callback(c).Invoke(c).Prefix(prefix).ToA().noSpace(nospace)
 	})
 }
 
@@ -335,7 +337,7 @@ func actionFlags(cmd *cobra.Command) Action {
 		})
 
 		if isShorthandSeries {
-			return ActionValuesDescribed(vals...).Invoke(c).Prefix(c.CallbackValue).ToA().noSpace(true)
+			return ActionValuesDescribed(vals...).Invoke(c).Prefix(c.CallbackValue).ToA().noSpace("*")
 		}
 		for i := 0; i < len(vals); i = i + 2 { // TODO experimental - hardcoded multiparts completion if flags are "grouped" with `.`
 			if strings.Contains(vals[i], ".") {
diff --git a/internal/common/suffix.go b/internal/common/suffix.go
new file mode 100644
index 0000000..bccc627
--- /dev/null
+++ b/internal/common/suffix.go
@@ -0,0 +1,38 @@
+package common
+
+import (
+	"sort"
+	"strings"
+)
+
+type SuffixMatcher string
+
+func (sm SuffixMatcher) Add(s string) SuffixMatcher {
+	if strings.Contains(string(sm), "*") || strings.Contains(s, "*") {
+		return SuffixMatcher("*")
+	}
+
+	unique := []rune(sm)
+	for _, r := range []rune(s) {
+		if !strings.Contains(string(sm), string(r)) {
+			unique = append(unique, r)
+		}
+	}
+	sort.Sort(ByRune(unique))
+	return SuffixMatcher(unique)
+}
+
+func (sm SuffixMatcher) Matches(s string) bool {
+	for _, r := range []rune(sm) {
+		if r == '*' || strings.HasSuffix(s, string(r)) {
+			return true
+		}
+	}
+	return false
+}
+
+type ByRune []rune
+
+func (r ByRune) Len() int           { return len(r) }
+func (r ByRune) Swap(i, j int)      { r[i], r[j] = r[j], r[i] }
+func (r ByRune) Less(i, j int) bool { return r[i] < r[j] }
diff --git a/internal/shell/bash/action.go b/internal/shell/bash/action.go
index 05c6eed..7e88a6d 100644
--- a/internal/shell/bash/action.go
+++ b/internal/shell/bash/action.go
@@ -71,7 +71,7 @@ func commonValuePrefix(values ...common.RawValue) (prefix string) {
 const nospaceIndicator = "\001"
 
 // ActionRawValues formats values for bash
-func ActionRawValues(currentWord string, nospace bool, values common.RawValues) string {
+func ActionRawValues(currentWord string, nospace common.SuffixMatcher, values common.RawValues) string {
 	filtered := make([]common.RawValue, 0)
 
 	lastSegment := currentWord // last segment of currentWord split by COMP_WORDBREAKS
@@ -103,7 +103,7 @@ func ActionRawValues(currentWord string, nospace bool, values common.RawValues)
 
 	vals := make([]string, len(filtered))
 	for index, val := range filtered {
-		if nospace && !strings.HasSuffix(val.Value, nospaceIndicator) {
+		if nospace.Matches(val.Value) {
 			val.Value = val.Value + nospaceIndicator
 		}
 
diff --git a/internal/shell/bash_ble/action.go b/internal/shell/bash_ble/action.go
index e4f62a5..a55aec7 100644
--- a/internal/shell/bash_ble/action.go
+++ b/internal/shell/bash_ble/action.go
@@ -8,15 +8,14 @@ import (
 )
 
 // ActionRawValues formats values for bash_ble
-func ActionRawValues(currentWord string, nospace bool, values common.RawValues) string {
-	suffix := " "
-	if nospace {
-		suffix = ""
-	}
-
+func ActionRawValues(currentWord string, nospace common.SuffixMatcher, values common.RawValues) string {
 	filtered := values.FilterPrefix(currentWord)
 	vals := make([]string, len(filtered))
 	for index, val := range filtered {
+		suffix := " "
+		if nospace.Matches(val.Value) {
+			suffix = ""
+		}
 		vals[index] = fmt.Sprintf("%v\t%v\x1c%v\x1c%v\x1c%v", val.Value, val.Display, "", suffix, val.TrimmedDescription())
 	}
 	return strings.Join(vals, "\n")
diff --git a/internal/shell/elvish/action.go b/internal/shell/elvish/action.go
index 6da5f69..a974cfc 100644
--- a/internal/shell/elvish/action.go
+++ b/internal/shell/elvish/action.go
@@ -33,12 +33,7 @@ type complexCandidate struct {
 }
 
 // ActionRawValues formats values for elvish
-func ActionRawValues(currentWord string, nospace bool, values common.RawValues) string {
-	suffix := " "
-	if nospace {
-		suffix = ""
-	}
-
+func ActionRawValues(currentWord string, nospace common.SuffixMatcher, values common.RawValues) string {
 	valueStyle := "default"
 	if s := style.Carapace.Value; s != "" && ui.ParseStyling(s) != nil {
 		valueStyle = s
@@ -51,6 +46,11 @@ func ActionRawValues(currentWord string, nospace bool, values common.RawValues)
 
 	vals := make([]complexCandidate, len(values))
 	for index, val := range sanitize(values) {
+		suffix := " "
+		if nospace.Matches(val.Value) {
+			suffix = ""
+		}
+
 		if val.Style == "" || ui.ParseStyling(val.Style) == nil {
 			val.Style = valueStyle
 		}
diff --git a/internal/shell/export/action.go b/internal/shell/export/action.go
index 971e20a..c19a9dc 100644
--- a/internal/shell/export/action.go
+++ b/internal/shell/export/action.go
@@ -10,11 +10,11 @@ import (
 
 type Export struct {
 	Version   string
-	Nospace   bool
+	Nospace   common.SuffixMatcher
 	RawValues common.RawValues
 }
 
-func ActionRawValues(currentWord string, nospace bool, values common.RawValues) string {
+func ActionRawValues(currentWord string, nospace common.SuffixMatcher, values common.RawValues) string {
 	sort.Sort(common.ByValue(values))
 	m, _ := json.Marshal(Export{
 		Version:   version(),
diff --git a/internal/shell/fish/action.go b/internal/shell/fish/action.go
index edfa3a0..ec159c2 100644
--- a/internal/shell/fish/action.go
+++ b/internal/shell/fish/action.go
@@ -13,7 +13,7 @@ var sanitizer = strings.NewReplacer(
 )
 
 // ActionRawValues formats values for fish
-func ActionRawValues(currentWord string, nospace bool, values common.RawValues) string {
+func ActionRawValues(currentWord string, nospace common.SuffixMatcher, values common.RawValues) 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.TrimmedDescription()))
diff --git a/internal/shell/ion/action.go b/internal/shell/ion/action.go
index 894ccfb..4c45c25 100644
--- a/internal/shell/ion/action.go
+++ b/internal/shell/ion/action.go
@@ -29,13 +29,13 @@ type suggestion struct {
 }
 
 // ActionRawValues formats values for ion
-func ActionRawValues(currentWord string, nospace bool, values common.RawValues) string {
+func ActionRawValues(currentWord string, nospace common.SuffixMatcher, values common.RawValues) string {
 	filtered := values.FilterPrefix(currentWord)
 	sort.Sort(common.ByDisplay(filtered))
 
 	vals := make([]suggestion, len(filtered))
 	for index, val := range sanitize(filtered) {
-		if !nospace {
+		if !nospace.Matches(val.Value) {
 			val.Value = val.Value + " "
 		}
 
diff --git a/internal/shell/nushell/action.go b/internal/shell/nushell/action.go
index 4f1c67a..2380eae 100644
--- a/internal/shell/nushell/action.go
+++ b/internal/shell/nushell/action.go
@@ -29,7 +29,7 @@ func sanitize(values []common.RawValue) []common.RawValue {
 }
 
 // ActionRawValues formats values for nushell
-func ActionRawValues(currentWord string, nospace bool, values common.RawValues) string {
+func ActionRawValues(currentWord string, nospace common.SuffixMatcher, values common.RawValues) string {
 	filtered := values.FilterPrefix(currentWord)
 	sort.Sort(common.ByDisplay(filtered))
 
@@ -39,7 +39,7 @@ func ActionRawValues(currentWord string, nospace bool, values common.RawValues)
 			val.Value = fmt.Sprintf("'%v'", val.Value)
 		}
 
-		if !nospace {
+		if !nospace.Matches(val.Value) {
 			val.Value = val.Value + " "
 		}
 
diff --git a/internal/shell/oil/action.go b/internal/shell/oil/action.go
index 04b16d1..e39a119 100644
--- a/internal/shell/oil/action.go
+++ b/internal/shell/oil/action.go
@@ -15,7 +15,7 @@ var sanitizer = strings.NewReplacer(
 const nospaceIndicator = "\001"
 
 // ActionRawValues formats values for oil
-func ActionRawValues(currentWord string, nospace bool, values common.RawValues) string {
+func ActionRawValues(currentWord string, nospace common.SuffixMatcher, values common.RawValues) string {
 	filtered := make([]common.RawValue, 0)
 
 	for _, r := range values {
@@ -27,7 +27,7 @@ func ActionRawValues(currentWord string, nospace bool, values common.RawValues)
 
 	vals := make([]string, len(filtered))
 	for index, val := range filtered {
-		if nospace && !strings.HasSuffix(val.Value, nospaceIndicator) {
+		if nospace.Matches(val.Value) {
 			val.Value = val.Value + nospaceIndicator
 		}
 
diff --git a/internal/shell/powershell/action.go b/internal/shell/powershell/action.go
index 3315a81..3069cd8 100644
--- a/internal/shell/powershell/action.go
+++ b/internal/shell/powershell/action.go
@@ -31,7 +31,7 @@ func ensureNotEmpty(s string) string {
 }
 
 // ActionRawValues formats values for powershell
-func ActionRawValues(currentWord string, nospace bool, values common.RawValues) string {
+func ActionRawValues(currentWord string, nospace common.SuffixMatcher, values common.RawValues) string {
 	filtered := values.FilterPrefix(currentWord)
 	sort.Sort(common.ByDisplay(filtered))
 
@@ -54,7 +54,7 @@ func ActionRawValues(currentWord string, nospace bool, values common.RawValues)
 				val.Value = fmt.Sprintf("'%v'", val.Value)
 			}
 
-			if !nospace {
+			if !nospace.Matches(val.Value) {
 				val.Value = val.Value + " "
 			}
 
diff --git a/internal/shell/shell.go b/internal/shell/shell.go
index 0540ed5..f0710ad 100644
--- a/internal/shell/shell.go
+++ b/internal/shell/shell.go
@@ -55,8 +55,8 @@ func Snippet(cmd *cobra.Command, shell string) (string, error) {
 	return "", fmt.Errorf("expected one of '%v' [was: %v]", strings.Join(expected, "', '"), shell)
 }
 
-func Value(shell string, callbackValue string, nospace bool, values common.RawValues) string { // TODO use context instead?
-	shellFuncs := map[string]func(currentWord string, nospace bool, values common.RawValues) string{
+func Value(shell string, callbackValue string, nospace common.SuffixMatcher, values common.RawValues) string { // TODO use context instead?
+	shellFuncs := map[string]func(currentWord string, nospace common.SuffixMatcher, values common.RawValues) string{
 		"bash":       bash.ActionRawValues,
 		"bash-ble":   bash_ble.ActionRawValues,
 		"fish":       fish.ActionRawValues,
diff --git a/internal/shell/tcsh/action.go b/internal/shell/tcsh/action.go
index e9ce33e..f3c2c8f 100644
--- a/internal/shell/tcsh/action.go
+++ b/internal/shell/tcsh/action.go
@@ -68,7 +68,7 @@ func commonValuePrefix(values ...common.RawValue) (prefix string) {
 }
 
 // ActionRawValues formats values for bash
-func ActionRawValues(currentWord string, nospace bool, values common.RawValues) string {
+func ActionRawValues(currentWord string, nospace common.SuffixMatcher, values common.RawValues) string {
 	filtered := make([]common.RawValue, 0)
 
 	lastSegment := currentWord // last segment of currentWord split by COMP_WORDBREAKS
diff --git a/internal/shell/xonsh/action.go b/internal/shell/xonsh/action.go
index 67fa316..372fb14 100644
--- a/internal/shell/xonsh/action.go
+++ b/internal/shell/xonsh/action.go
@@ -21,7 +21,7 @@ type richCompletion struct {
 }
 
 // ActionRawValues formats values for xonsh
-func ActionRawValues(currentWord string, nospace bool, values common.RawValues) string {
+func ActionRawValues(currentWord string, nospace common.SuffixMatcher, values common.RawValues) string {
 	filtered := make([]common.RawValue, 0)
 
 	for _, r := range values {
@@ -42,7 +42,7 @@ func ActionRawValues(currentWord string, nospace bool, values common.RawValues)
 			}
 		}
 
-		if !nospace {
+		if !nospace.Matches(val.Value) {
 			val.Value = val.Value + " "
 		}
 
diff --git a/internal/shell/zsh/action.go b/internal/shell/zsh/action.go
index a1ada13..5e9e59b 100644
--- a/internal/shell/zsh/action.go
+++ b/internal/shell/zsh/action.go
@@ -48,7 +48,7 @@ func quoteValue(s string) string {
 }
 
 // ActionRawValues formats values for zsh
-func ActionRawValues(currentWord string, nospace bool, values common.RawValues) string {
+func ActionRawValues(currentWord string, nospace common.SuffixMatcher, values common.RawValues) string {
 	filtered := make([]common.RawValue, 0)
 
 	maxLength := 0
@@ -78,7 +78,7 @@ func ActionRawValues(currentWord string, nospace bool, values common.RawValues)
 	for index, val := range filtered {
 		val.Value = sanitizer.Replace(val.Value)
 		val.Value = quoteValue(val.Value)
-		if nospace {
+		if nospace.Matches(val.Value) {
 			val.Value = val.Value + "\001"
 		}
 		val.Display = sanitizer.Replace(val.Display)
diff --git a/invokedAction.go b/invokedAction.go
index 08f25ee..3989f56 100644
--- a/invokedAction.go
+++ b/invokedAction.go
@@ -29,7 +29,7 @@ func (a InvokedAction) Filter(values []string) InvokedAction {
 			filtered = append(filtered, rawValue)
 		}
 	}
-	return InvokedAction{actionRawValues(filtered...).noSpace(a.nospace).skipCache(a.skipcache)}
+	return InvokedAction{actionRawValues(filtered...).noSpace(string(a.nospace)).skipCache(a.skipcache)}
 }
 
 // Merge merges InvokedActions (existing values are overwritten)
@@ -45,7 +45,7 @@ func (a InvokedAction) Merge(others ...InvokedAction) InvokedAction {
 		for _, c := range other.rawValues {
 			uniqueRawValues[c.Value] = c
 		}
-		nospace = nospace || other.nospace
+		nospace = nospace.Add(string(other.nospace))
 		skipcache = skipcache || other.skipcache
 	}
 
@@ -53,7 +53,7 @@ func (a InvokedAction) Merge(others ...InvokedAction) InvokedAction {
 	for _, c := range uniqueRawValues {
 		rawValues = append(rawValues, c)
 	}
-	return InvokedAction{actionRawValues(rawValues...).noSpace(nospace).skipCache(skipcache)}
+	return InvokedAction{actionRawValues(rawValues...).noSpace(string(nospace)).skipCache(skipcache)}
 }
 
 // Prefix adds a prefix to values (only the ones inserted, not the display values)
@@ -143,7 +143,17 @@ func (a InvokedAction) ToMultiPartsA(dividers ...string) Action {
 		for _, val := range uniqueVals {
 			vals = append(vals, val)
 		}
-		return actionRawValues(vals...).noSpace(true)
+
+		var sm common.SuffixMatcher
+		for _, divider := range dividers {
+			if runes := []rune(divider); len(runes) == 0 {
+				sm = sm.Add("*")
+				break
+			} else {
+				sm = sm.Add(string(runes[len(runes)-1]))
+			}
+		}
+		return actionRawValues(vals...).noSpace(string(sm))
 	})
 }
 


Test patch:
diff --git a/action_test.go b/action_test.go
index f1983b2..dafd77f 100644
--- a/action_test.go
+++ b/action_test.go
@@ -50,7 +50,7 @@ func TestActionCallback(t *testing.T) {
 	expected := InvokedAction{
 		Action{
 			rawValues: common.RawValuesFrom("a", "b", "c"),
-			nospace:   false,
+			nospace:   "",
 			skipcache: false,
 		},
 	}
@@ -104,11 +104,11 @@ func TestNoSpace(t *testing.T) {
 			Suffix("").
 			ToA()
 	})
-	if a.nospace {
-		t.Fatal("uninvoked nospace should be false")
+	if a.nospace != "" {
+		t.Fatal("uninvoked nospace should be empty")
 	}
-	if !a.Invoke(Context{}).nospace {
-		t.Fatal("invoked nospace should be true")
+	if a.Invoke(Context{}).nospace != "*" {
+		t.Fatal("invoked nospace should be `*`")
 	}
 }
 
@@ -120,7 +120,7 @@ func TestActionDirectories(t *testing.T) {
 			"internal/", style.Of("fg-default", "bg-default", style.Blue, style.Bold),
 			"pkg/", style.Of("fg-default", "bg-default", style.Blue, style.Bold),
 			"third_party/", style.Of("fg-default", "bg-default", style.Blue, style.Bold),
-		).noSpace(true).Invoke(Context{}),
+		).noSpace("/").Invoke(Context{}),
 		ActionDirectories().Invoke(Context{CallbackValue: ""}).Filter([]string{"vendor/"}),
 	)
 
@@ -131,7 +131,7 @@ func TestActionDirectories(t *testing.T) {
 			"internal/", style.Of("fg-default", "bg-default", style.Blue, style.Bold),
 			"pkg/", style.Of("fg-default", "bg-default", style.Blue, style.Bold),
 			"third_party/", style.Of("fg-default", "bg-default", style.Blue, style.Bold),
-		).noSpace(true).Invoke(Context{}).Prefix("./"),
+		).noSpace("/").Invoke(Context{}).Prefix("./"),
 		ActionDirectories().Invoke(Context{CallbackValue: "./"}).Filter([]string{"./vendor/"}),
 	)
 
@@ -139,14 +139,14 @@ func TestActionDirectories(t *testing.T) {
 		ActionStyledValues(
 			"_test/", style.Of("fg-default", "bg-default", style.Blue, style.Bold),
 			"cmd/", style.Of("fg-default", "bg-default", style.Blue, style.Bold),
-		).noSpace(true).Invoke(Context{}).Prefix("example/"),
+		).noSpace("/").Invoke(Context{}).Prefix("example/"),
 		ActionDirectories().Invoke(Context{CallbackValue: "example/"}),
 	)
 
 	assertEqual(t,
 		ActionStyledValues(
 			"cmd/", style.Of("fg-default", "bg-default", style.Blue, style.Bold),
-		).noSpace(true).Invoke(Context{}).Prefix("example/"),
+		).noSpace("/").Invoke(Context{}).Prefix("example/"),
 		ActionDirectories().Invoke(Context{CallbackValue: "example/cm"}),
 	)
 }
@@ -160,7 +160,7 @@ func TestActionFiles(t *testing.T) {
 			"internal/", style.Of("fg-default", "bg-default", style.Blue, style.Bold),
 			"pkg/", style.Of("fg-default", "bg-default", style.Blue, style.Bold),
 			"third_party/", style.Of("fg-default", "bg-default", style.Blue, style.Bold),
-		).noSpace(true).Invoke(Context{}),
+		).noSpace("/").Invoke(Context{}),
 		ActionFiles(".md").Invoke(Context{CallbackValue: ""}).Filter([]string{"vendor/"}),
 	)
 
@@ -171,7 +171,7 @@ func TestActionFiles(t *testing.T) {
 			"cmd/", style.Of("fg-default", "bg-default", style.Blue, style.Bold),
 			"main.go", style.Of("fg-default", "bg-default"),
 			"main_test.go", style.Of("fg-default", "bg-default"),
-		).noSpace(true).Invoke(Context{}).Prefix("example/"),
+		).noSpace("/").Invoke(Context{}).Prefix("example/"),
 		ActionFiles().Invoke(Context{CallbackValue: "example/"}).Filter([]string{"example/example"}),
 	)
 }
@@ -180,12 +180,12 @@ func TestActionFilesChdir(t *testing.T) {
 	oldWd, _ := os.Getwd()
 
 	assertEqual(t,
-		ActionStyledValuesDescribed("ERR", fmt.Sprintf("open %v: no such file or directory", wd("nonexistent")), style.Carapace.Error, "_", "", style.Default).noSpace(true).Style("fg-default bg-default").Invoke(Context{}),
+		ActionStyledValuesDescribed("ERR", fmt.Sprintf("open %v: no such file or directory", wd("nonexistent")), style.Carapace.Error, "_", "", style.Default).noSpace("/").Style("fg-default bg-default").Invoke(Context{}),
 		ActionFiles(".md").Chdir("nonexistent").Invoke(Context{CallbackValue: ""}),
 	)
 
 	assertEqual(t,
-		ActionStyledValuesDescribed("ERR", fmt.Sprintf("readdirent %v/go.mod: not a directory", wd("")), style.Carapace.Error, "_", "", style.Default).noSpace(true).Style("fg-default bg-default").Invoke(Context{}),
+		ActionStyledValuesDescribed("ERR", fmt.Sprintf("readdirent %v/go.mod: not a directory", wd("")), style.Carapace.Error, "_", "", style.Default).noSpace("/").Style("fg-default bg-default").Invoke(Context{}),
 		ActionFiles(".md").Chdir("go.mod").Invoke(Context{CallbackValue: ""}),
 	)
 
@@ -193,7 +193,7 @@ func TestActionFilesChdir(t *testing.T) {
 		ActionStyledValues(
 			"action.go", style.Of("fg-default", "bg-default"),
 			"snippet.go", style.Of("fg-default", "bg-default"),
-		).noSpace(true).Invoke(Context{}).Prefix("elvish/"),
+		).noSpace("/").Invoke(Context{}).Prefix("elvish/"),
 		ActionFiles().Chdir("internal/shell").Invoke(Context{CallbackValue: "elvish/"}),
 	)
 
@@ -204,7 +204,7 @@ func TestActionFilesChdir(t *testing.T) {
 
 func TestActionMessage(t *testing.T) {
 	assertEqual(t,
-		ActionStyledValuesDescribed("_", "", style.Default, "ERR", "example message", style.Carapace.Error).noSpace(true).skipCache(true).Invoke(Context{}).Prefix("docs/"),
+		ActionStyledValuesDescribed("_", "", style.Default, "ERR", "example message", style.Carapace.Error).noSpace("/").skipCache(true).Invoke(Context{}).Prefix("docs/"),
 		ActionMessage("example message").Invoke(Context{CallbackValue: "docs/"}),
 	)
 }
@@ -215,13 +215,13 @@ func TestActionMessageSuppress(t *testing.T) {
 			ActionMessage("example message").Suppress("example"),
 			ActionValues("test"),
 		).ToA().Invoke(Context{}),
-		ActionValues("test").noSpace(true).skipCache(true).Invoke(Context{}),
+		ActionValues("test").noSpace("/").skipCache(true).Invoke(Context{}),
 	)
 }
 
 func TestActionExecCommand(t *testing.T) {
 	assertEqual(t,
-		ActionMessage("go unknown: unknown command").noSpace(true).skipCache(true).Invoke(Context{}).Prefix("docs/"),
+		ActionMessage("go unknown: unknown command").noSpace("/").skipCache(true).Invoke(Context{}).Prefix("docs/"),
 		ActionExecCommand("go", "unknown")(func(output []byte) Action { return ActionValues() }).Invoke(Context{CallbackValue: "docs/"}),
 	)
 
diff --git a/batch_test.go b/batch_test.go
index 1a86813..d9d5324 100644
--- a/batch_test.go
+++ b/batch_test.go
@@ -15,7 +15,7 @@ func TestBatch(t *testing.T) {
 	expected := InvokedAction{
 		Action{
 			rawValues: common.RawValuesFrom("A", "B", "C", "D"),
-			nospace:   false,
+			nospace:   common.SuffixMatcher(""),
 			skipcache: false,
 		},
 	}
@@ -30,7 +30,7 @@ func TestBatchSingle(t *testing.T) {
 	expected := InvokedAction{
 		Action{
 			rawValues: common.RawValuesFrom("A", "B"),
-			nospace:   false,
+			nospace:   common.SuffixMatcher(""),
 			skipcache: false,
 		},
 	}
@@ -43,7 +43,7 @@ func TestBatchNone(t *testing.T) {
 	expected := InvokedAction{
 		Action{
 			rawValues: common.RawValuesFrom(),
-			nospace:   false,
+			nospace:   common.SuffixMatcher(""),
 			skipcache: false,
 		},
 	}
@@ -60,7 +60,7 @@ func TestBatchToA(t *testing.T) {
 	expected := InvokedAction{
 		Action{
 			rawValues: common.RawValuesFrom("A", "B", "C", "D"),
-			nospace:   false,
+			nospace:   common.SuffixMatcher(""),
 			skipcache: false,
 		},
 	}
diff --git a/defaultActions_test.go b/defaultActions_test.go
index c834d28..30f4837 100644
--- a/defaultActions_test.go
+++ b/defaultActions_test.go
@@ -11,7 +11,7 @@ func TestActionImport(t *testing.T) {
 	s := `
 {
   "Version": "unknown",
-  "Nospace": true,
+  "Nospace": "*",
   "RawValues": [
     {
       "Value": "positional1",