diff --git a/example/cmd/_test/zsh.sh b/example/cmd/_test/zsh.sh index d95f9346d..0b6672108 100644 --- a/example/cmd/_test/zsh.sh +++ b/example/cmd/_test/zsh.sh @@ -2,16 +2,20 @@ function _example_completion { local words=${words[@]:0:$CURRENT} local IFS=$'\n' - + # shellcheck disable=SC2086,SC2154,SC2155 + local completion_input if echo ${words}"''" | xargs echo 2>/dev/null > /dev/null; then - local lines="$(echo ${words}"''" | CARAPACE_COMPLINE="${words}" CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs example _carapace zsh )" - elif echo ${words} | sed "s/\$/'/" | xargs echo 2>/dev/null > /dev/null; then - local lines="$(echo ${words} | sed "s/\$/'/" | CARAPACE_COMPLINE="${words}" CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs example _carapace zsh)" + completion_input="${words}''" + elif echo "${words[1,-2]} ${words[-1]}'" | xargs echo 2>/dev/null > /dev/null; then + completion_input="${words[1,-2]} ${words[-1]}'" else - local lines="$(echo ${words} | sed 's/$/"/' | CARAPACE_COMPLINE="${words}" CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs example _carapace zsh)" + completion_input="${words[1,-2]} ${words[-1]}\\" fi - + + local lines + lines="$(echo "${completion_input}" | CARAPACE_COMPLINE="${words}" CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs example _carapace zsh)" + local zstyle message data IFS=$'\001' read -r -d '' zstyle message data <<<"${lines}" # shellcheck disable=SC2154 @@ -19,14 +23,30 @@ function _example_completion { zstyle ":completion:${curcontext}:*" group-name '' [ -z "$message" ] || _message -r "${message}" - local block tag displays values displaysArr valuesArr + local block tag suffix displays values + local -A tags 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 '' + IFS=$'\003' read -r -d '' tag suffix displays values <<<"${block}" + + local -a displaysArr=("${(f@)displays}") + local -a valuesArr=("${(f@)values}") + + local -a describe_args + if [[ -z ${tags[$tag]} ]]; then + describe_args=(-t "${tag}" "${tag}") + tags[$tag]=1 + else + describe_args=(-t "${tag}" "") + fi + + local separators=" /,.':@=" + if [[ "$suffix" == "" ]]; then + _describe "${describe_args[@]}" displaysArr valuesArr -Q -S ' ' -r "${separators}0-9a-zA-Z" + elif [[ "$separators" == *"$suffix"* ]]; then + _describe "${describe_args[@]}" displaysArr valuesArr -Q -S "$suffix" -r ' ' + else + _describe "${describe_args[@]}" displaysArr valuesArr -Q -S "$suffix" -r "0-9a-zA-Z" + fi done <<<"${data}" } compquote '' 2>/dev/null && _example_completion diff --git a/internal/common/suffix.go b/internal/common/suffix.go index 615f820e9..661dc8871 100644 --- a/internal/common/suffix.go +++ b/internal/common/suffix.go @@ -54,6 +54,8 @@ func (sm *SuffixMatcher) UnmarshalJSON(data []byte) (err error) { 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) 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/zsh/action.go b/internal/shell/zsh/action.go index 0247c8aaa..e10712b96 100644 --- a/internal/shell/zsh/action.go +++ b/internal/shell/zsh/action.go @@ -4,10 +4,13 @@ import ( "fmt" "regexp" "strings" + "time" + "unicode" shlex "github.com/carapace-sh/carapace-shlex" "github.com/carapace-sh/carapace/internal/common" "github.com/carapace-sh/carapace/internal/env" + "github.com/carapace-sh/carapace/internal/log" ) var sanitizer = strings.NewReplacer( @@ -17,7 +20,7 @@ var sanitizer = strings.NewReplacer( ) var quotingReplacer = strings.NewReplacer( - `'`, `'\''`, + `'`, `'''`, ) var quotingEscapingReplacer = strings.NewReplacer( @@ -92,6 +95,11 @@ const ( // ActionRawValues formats values for zsh func ActionRawValues(currentWord string, meta common.Meta, values common.RawValues) string { + start := time.Now() + defer func() { + log.LOG.Printf("zsh action processing took %v", time.Since(start)) + }() + splitted, err := shlex.Split(env.Compline()) state := DEFAULT_STATE if err == nil { @@ -100,63 +108,104 @@ func ActionRawValues(currentWord string, meta common.Meta, values common.RawValu switch { case regexp.MustCompile(`^'$|^'.*[^']$`).MatchString(rawValue): state = QUOTING_STATE - case regexp.MustCompile(`^"$|^".*[^"]$`).MatchString(rawValue): + case regexp.MustCompile(`^"$|^\".*[^\"]$`).MatchString(rawValue): state = QUOTING_ESCAPING_STATE - case regexp.MustCompile(`^".*"$`).MatchString(rawValue): + case regexp.MustCompile(`^\".*\"$`).MatchString(rawValue): state = FULL_QUOTING_ESCAPING_STATE case regexp.MustCompile(`^'.*'$`).MatchString(rawValue): state = FULL_QUOTING_STATE } } - 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 { - value := sanitizer.Replace(val.Value) - - switch state { - case QUOTING_ESCAPING_STATE: - value = quotingEscapingReplacer.Replace(value) - value = describeReplacer.Replace(value) - value = value + `"` - case QUOTING_STATE: - value = quotingReplacer.Replace(value) - value = describeReplacer.Replace(value) - value = value + `'` - case FULL_QUOTING_ESCAPING_STATE: - value = quotingEscapingReplacer.Replace(value) - value = describeReplacer.Replace(value) - case FULL_QUOTING_STATE: - value = quotingReplacer.Replace(value) - value = describeReplacer.Replace(value) - default: - value = quoteValue(value) - value = describeReplacer.Replace(value) - } + tagGroups := make([]string, 0) + values.EachTag(func(tag string, tagValues common.RawValues) { + for suffix, suffixedValues := range groupValuesBySuffix(tagValues, meta, state) { + vals := make([]string, len(suffixedValues)) + displays := make([]string, len(suffixedValues)) + + for index, val := range suffixedValues { + value := sanitizer.Replace(val.Value) + if suffix != " " && suffix != "" { + value = strings.TrimSuffix(value, suffix) + } - if !meta.Nospace.Matches(val.Value) { switch state { - case FULL_QUOTING_ESCAPING_STATE, FULL_QUOTING_STATE: // nospace workaround + case QUOTING_ESCAPING_STATE: + value = quotingEscapingReplacer.Replace(value) + value = describeReplacer.Replace(value) + value = value + `"` + case QUOTING_STATE: + value = quotingReplacer.Replace(value) + value = describeReplacer.Replace(value) + value = value + `'` + case FULL_QUOTING_ESCAPING_STATE: + value = quotingEscapingReplacer.Replace(value) + value = describeReplacer.Replace(value) + case FULL_QUOTING_STATE: + value = quotingReplacer.Replace(value) + value = describeReplacer.Replace(value) default: - value += " " + value = quoteValue(value) + value = describeReplacer.Replace(value) + } + + display := sanitizer.Replace(val.Display) + display = describeReplacer.Replace(display) // TODO check if this needs to be applied to description as well + description := sanitizer.Replace(val.Description) + + vals[index] = value + if strings.TrimSpace(description) == "" { + displays[index] = display + } else { + displays[index] = fmt.Sprintf("%v:%v", display, description) } } + tagGroups = append(tagGroups, strings.Join([]string{tag, suffix, strings.Join(displays, "\n"), strings.Join(vals, "\n")}, "\003")) + } + }) + return fmt.Sprintf("%v\001%v\001%v\002", zstyles{values}.Format(), message{meta}.Format(), strings.Join(tagGroups, "\002")) +} - display := sanitizer.Replace(val.Display) - display = describeReplacer.Replace(display) // TODO check if this needs to be applied to description as well - description := sanitizer.Replace(val.Description) +func groupValuesBySuffix(values common.RawValues, meta common.Meta, state state) map[string]common.RawValues { + groups := make(map[string]common.RawValues) + for _, val := range values { + suffix := "" + var removableSuffix bool + + // If the last character is not an alphanumeric character, we assume that + // this character should be removed if the user inserts either a space, the + // same character or any type separator character ( /,.:@=) + if len(val.Value) > 0 { + lastChar := val.Value[len(val.Value)-1:] + removableSuffix = !isAlphaNumeric(rune(lastChar[len(lastChar)-1])) + } - vals[index] = value + // If we have matched the value suffix against carapace-registered suffixes, + // and the suffix is not an alphanumeric, then we should register the suffix + // as removable by ZSH (ie. ZSH will handle automatic insert/erase) + if meta.Nospace.Matches(val.Value) && len(val.Value) > 0 && removableSuffix { + lastChar := val.Value[len(val.Value)-1:] + suffix = lastChar + } - if strings.TrimSpace(description) == "" { - displays[index] = display - } else { - displays[index] = fmt.Sprintf("%v:%v", display, description) + if suffix == "" { + if !meta.Nospace.Matches(val.Value) { + switch state { + case FULL_QUOTING_ESCAPING_STATE, FULL_QUOTING_STATE: // nospace workaround + default: + suffix = " " + } } } - 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") + + if _, ok := groups[suffix]; !ok { + groups[suffix] = make(common.RawValues, 0) + } + groups[suffix] = append(groups[suffix], val) + } + return groups +} + +func isAlphaNumeric(suffix rune) bool { + return unicode.IsDigit(suffix) || unicode.IsNumber(suffix) || unicode.IsLetter(suffix) } diff --git a/internal/shell/zsh/snippet.go b/internal/shell/zsh/snippet.go index 7295151ee..286045820 100644 --- a/internal/shell/zsh/snippet.go +++ b/internal/shell/zsh/snippet.go @@ -4,8 +4,9 @@ package zsh import ( "fmt" - "github.com/carapace-sh/carapace/pkg/uid" "github.com/spf13/cobra" + + "github.com/carapace-sh/carapace/pkg/uid" ) // Snippet creates the zsh completion script @@ -14,16 +15,20 @@ func Snippet(cmd *cobra.Command) string { function _%v_completion { local words=${words[@]:0:$CURRENT} local IFS=$'\n' - + # shellcheck disable=SC2086,SC2154,SC2155 + local completion_input if echo ${words}"''" | xargs echo 2>/dev/null > /dev/null; then - local lines="$(echo ${words}"''" | CARAPACE_COMPLINE="${words}" CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs %v _carapace zsh )" - elif echo ${words} | sed "s/\$/'/" | xargs echo 2>/dev/null > /dev/null; then - local lines="$(echo ${words} | sed "s/\$/'/" | CARAPACE_COMPLINE="${words}" CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs %v _carapace zsh)" + completion_input="${words}''" + elif echo "${words[1,-2]} ${words[-1]}'" | xargs echo 2>/dev/null > /dev/null; then + completion_input="${words[1,-2]} ${words[-1]}'" else - local lines="$(echo ${words} | sed 's/$/"/' | CARAPACE_COMPLINE="${words}" CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs %v _carapace zsh)" + completion_input="${words[1,-2]} ${words[-1]}\\" fi - + + local lines + lines="$(echo "${completion_input}" | CARAPACE_COMPLINE="${words}" CARAPACE_ZSH_HASH_DIRS="$(hash -d)" xargs %v _carapace zsh)" + local zstyle message data IFS=$'\001' read -r -d '' zstyle message data <<<"${lines}" # shellcheck disable=SC2154 @@ -31,17 +36,33 @@ function _%v_completion { zstyle ":completion:${curcontext}:*" group-name '' [ -z "$message" ] || _message -r "${message}" - local block tag displays values displaysArr valuesArr + local block tag suffix displays values + local -A tags 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 '' + IFS=$'\003' read -r -d '' tag suffix displays values <<<"${block}" + + local -a displaysArr=("${(f@)displays}") + local -a valuesArr=("${(f@)values}") + + local -a describe_args + if [[ -z ${tags[$tag]} ]]; then + describe_args=(-t "${tag}" "${tag}") + tags[$tag]=1 + else + describe_args=(-t "${tag}" "") + fi + + local separators=" /,.':@=" + if [[ "$suffix" == "" ]]; then + _describe "${describe_args[@]}" displaysArr valuesArr -Q -S ' ' -r "${separators}0-9a-zA-Z" + elif [[ "$separators" == *"$suffix"* ]]; then + _describe "${describe_args[@]}" displaysArr valuesArr -Q -S "$suffix" -r ' ' + else + _describe "${describe_args[@]}" displaysArr valuesArr -Q -S "$suffix" -r "0-9a-zA-Z" + fi done <<<"${data}" } compquote '' 2>/dev/null && _%v_completion compdef _%v_completion %v -`, cmd.Name(), cmd.Name(), uid.Executable(), uid.Executable(), uid.Executable(), cmd.Name(), cmd.Name(), cmd.Name()) +`, cmd.Name(), cmd.Name(), uid.Executable(), cmd.Name(), cmd.Name(), cmd.Name()) }