From c1910348c703dd6f0726a056a37850eddf1029a9 Mon Sep 17 00:00:00 2001 From: Eric Freese Date: Fri, 23 Nov 2018 10:18:15 -0700 Subject: [PATCH] Implement completion suggestion strategy (#111) Based on https://github.com/Valodim/zsh-capture-completion `zpty -r` with a pattern seems to have some funky behavior on older versions, giving unpredictable results Don't use `-s` option to `zmodload`. It is not available in zsh versions older than 5.3 If running in sync mode and a completion takes a long time, the user can ^C out of it. We need to use `always` in the strategy function or the pty will not be destroyed in this case and the next time we go to create it, it will fail, making the shell unusable. User can have many different completion styles set that will modify what they've already typed. These styles will result in suggestions that don't match what the user has already typed. We try our best to unset some of the more problematic ones, but add some code to fetch to invalidate suggestions that don't match what the user's already typed. --- Makefile | 1 - README.md | 7 +- spec/integrations/client_zpty_spec.rb | 14 +++ spec/strategies/completion_spec.rb | 26 ++++++ src/config.zsh | 4 + src/fetch.zsh | 5 +- src/setup.zsh | 10 -- src/start.zsh | 1 + src/strategies/completion.zsh | 109 ++++++++++++++++++++++ zsh-autosuggestions.zsh | 129 +++++++++++++++++++++++--- 10 files changed, 281 insertions(+), 25 deletions(-) create mode 100644 spec/integrations/client_zpty_spec.rb create mode 100644 spec/strategies/completion_spec.rb delete mode 100644 src/setup.zsh create mode 100644 src/strategies/completion.zsh diff --git a/Makefile b/Makefile index 93b8d94..f6d13a7 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,6 @@ SRC_DIR := ./src SRC_FILES := \ - $(SRC_DIR)/setup.zsh \ $(SRC_DIR)/config.zsh \ $(SRC_DIR)/util.zsh \ $(SRC_DIR)/bind.zsh \ diff --git a/README.md b/README.md index f5a57e0..1435c99 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ _[Fish](http://fishshell.com/)-like fast/unobtrusive autosuggestions for zsh._ -It suggests commands as you type, based on command history. +It suggests commands as you type. Requirements: Zsh v4.3.11 or later @@ -39,10 +39,13 @@ Set `ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE` to configure the style that the suggestion ### Suggestion Strategy -`ZSH_AUTOSUGGEST_STRATEGY` is an array that specifies how suggestions should be generated. The strategies in the array are tried successively until a suggestion is found. There are currently two built-in strategies to choose from: +`ZSH_AUTOSUGGEST_STRATEGY` is an array that specifies how suggestions should be generated. The strategies in the array are tried successively until a suggestion is found. There are currently three built-in strategies to choose from: - `history`: Chooses the most recent match from history. - `match_prev_cmd`: Like `history`, but chooses the most recent match whose preceding history item matches the most recently executed command ([more info](src/strategies/match_prev_cmd.zsh)). Note that this strategy won't work as expected with ZSH options that don't preserve the history order such as `HIST_IGNORE_ALL_DUPS` or `HIST_EXPIRE_DUPS_FIRST`. +- `completion`: (experimental) Chooses a suggestion based on what tab-completion would suggest. (requires `zpty` module) + +For example, setting `ZSH_AUTOSUGGEST_STRATEGY=(history completion)` will first try to find a suggestion from your history, but, if it can't find a match, will find a suggestion from the completion engine. ### Widget Mapping diff --git a/spec/integrations/client_zpty_spec.rb b/spec/integrations/client_zpty_spec.rb new file mode 100644 index 0000000..b8abb37 --- /dev/null +++ b/spec/integrations/client_zpty_spec.rb @@ -0,0 +1,14 @@ +describe 'a running zpty command' do + let(:before_sourcing) { -> { session.run_command('zmodload zsh/zpty && zpty -b kitty cat') } } + + context 'when using `completion` strategy' do + let(:options) { ["ZSH_AUTOSUGGEST_STRATEGY=completion"] } + + it 'is not affected' do + session.send_keys('a').send_keys('C-h') + session.run_command('zpty -t kitty; echo $?') + + wait_for { session.content }.to end_with("\n0") + end + end +end diff --git a/spec/strategies/completion_spec.rb b/spec/strategies/completion_spec.rb new file mode 100644 index 0000000..bd2c72d --- /dev/null +++ b/spec/strategies/completion_spec.rb @@ -0,0 +1,26 @@ +describe 'the `completion` suggestion strategy' do + let(:options) { ['ZSH_AUTOSUGGEST_STRATEGY=completion'] } + let(:before_sourcing) do + -> do + session. + run_command('autoload compinit && compinit'). + run_command('_foo() { compadd bar }'). + run_command('compdef _foo baz') + end + end + + it 'suggests the first completion result' do + session.send_string('baz ') + wait_for { session.content }.to eq('baz bar') + end + + context 'when async mode is enabled' do + let(:options) { ['ZSH_AUTOSUGGEST_USE_ASYNC=true', 'ZSH_AUTOSUGGEST_STRATEGY=completion'] } + + it 'suggests the first completion result' do + session.send_string('baz ') + wait_for { session.content }.to eq('baz bar') + end + end +end + diff --git a/src/config.zsh b/src/config.zsh index 2f46aed..1ab878b 100644 --- a/src/config.zsh +++ b/src/config.zsh @@ -85,3 +85,7 @@ typeset -g ZSH_AUTOSUGGEST_ORIGINAL_WIDGET_PREFIX=autosuggest-orig- yank-pop ) } + +# Pty name for capturing completions for completion suggestion strategy +(( ! ${+ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME} )) && +typeset -g ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME=zsh_autosuggest_completion_pty diff --git a/src/fetch.zsh b/src/fetch.zsh index 1517018..fef2715 100644 --- a/src/fetch.zsh +++ b/src/fetch.zsh @@ -18,7 +18,10 @@ _zsh_autosuggest_fetch_suggestion() { # Try to get a suggestion from this strategy _zsh_autosuggest_strategy_$strategy "$1" - # Break once we've found a suggestion + # Ensure the suggestion matches the prefix + [[ "$suggestion" != "$1"* ]] && unset suggestion + + # Break once we've found a valid suggestion [[ -n "$suggestion" ]] && break done } diff --git a/src/setup.zsh b/src/setup.zsh deleted file mode 100644 index c74489f..0000000 --- a/src/setup.zsh +++ /dev/null @@ -1,10 +0,0 @@ - -#--------------------------------------------------------------------# -# Setup # -#--------------------------------------------------------------------# - -# Precmd hooks for initializing the library and starting pty's -autoload -Uz add-zsh-hook - -# Asynchronous suggestions are generated in a pty -zmodload zsh/zpty diff --git a/src/start.zsh b/src/start.zsh index 0125ab8..5991039 100644 --- a/src/start.zsh +++ b/src/start.zsh @@ -19,4 +19,5 @@ _zsh_autosuggest_start() { } # Start the autosuggestion widgets on the next precmd +autoload -Uz add-zsh-hook add-zsh-hook precmd _zsh_autosuggest_start diff --git a/src/strategies/completion.zsh b/src/strategies/completion.zsh new file mode 100644 index 0000000..bf4ed9a --- /dev/null +++ b/src/strategies/completion.zsh @@ -0,0 +1,109 @@ + +#--------------------------------------------------------------------# +# Completion Suggestion Strategy # +#--------------------------------------------------------------------# +# Fetches a suggestion from the completion engine +# + +_zsh_autosuggest_capture_postcompletion() { + # Always insert the first completion into the buffer + compstate[insert]=1 + + # Don't list completions + unset compstate[list] +} + +_zsh_autosuggest_capture_completion_widget() { + local -a +h comppostfuncs + comppostfuncs=(_zsh_autosuggest_capture_postcompletion) + + # Only capture completions at the end of the buffer + CURSOR=$#BUFFER + + # Run the original widget wrapping `.complete-word` so we don't + # recursively try to fetch suggestions, since our pty is forked + # after autosuggestions is initialized. + zle -- ${(k)widgets[(r)completion:.complete-word:_main_complete]} + + # The completion has been added, print the buffer as the suggestion + echo -nE - $'\0'$BUFFER$'\0' +} + +zle -N autosuggest-capture-completion _zsh_autosuggest_capture_completion_widget + +_zsh_autosuggest_capture_setup() { + # There is a bug in zpty module in older zsh versions by which a + # zpty that exits will kill all zpty processes that were forked + # before it. Here we set up a zsh exit hook to SIGKILL the zpty + # process immediately, before it has a chance to kill any other + # zpty processes. + if ! is-at-least 5.4; then + zshexit() { + kill -KILL $$ + sleep 1 # Block for long enough for the signal to come through + } + fi + + # Try to avoid any suggestions that wouldn't match the prefix + zstyle ':completion:*' matcher-list '' + zstyle ':completion:*' path-completion false + zstyle ':completion:*' max-errors 0 not-numeric + + bindkey '^I' autosuggest-capture-completion +} + +_zsh_autosuggest_capture_completion_sync() { + _zsh_autosuggest_capture_setup + + zle autosuggest-capture-completion +} + +_zsh_autosuggest_capture_completion_async() { + _zsh_autosuggest_capture_setup + + zmodload zsh/parameter 2>/dev/null || return # For `$functions` + + # Make vared completion work as if for a normal command line + # https://stackoverflow.com/a/7057118/154703 + autoload +X _complete + functions[_original_complete]=$functions[_complete] + _complete () { + unset 'compstate[vared]' + _original_complete "$@" + } + + # Open zle with buffer set so we can capture completions for it + vared 1 +} + +_zsh_autosuggest_strategy_completion() { + typeset -g suggestion + local line REPLY + + # Exit if we don't have completions + whence compdef >/dev/null || return + + # Exit if we don't have zpty + zmodload zsh/zpty 2>/dev/null || return + + # Zle will be inactive if we are in async mode + if zle; then + zpty $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME _zsh_autosuggest_capture_completion_sync + else + zpty $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME _zsh_autosuggest_capture_completion_async "\$1" + zpty -w $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME $'\t' + fi + + { + # The completion result is surrounded by null bytes, so read the + # content between the first two null bytes. + zpty -r $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME line '*'$'\0''*'$'\0' + + # On older versions of zsh, we sometimes get extra bytes after the + # second null byte, so trim those off the end + suggestion="${${${(M)line:#*$'\0'*$'\0'*}#*$'\0'}%%$'\0'*}" + } always { + # Destroy the pty + zpty -d $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME + } +} diff --git a/zsh-autosuggestions.zsh b/zsh-autosuggestions.zsh index 7be3415..cebdc3e 100644 --- a/zsh-autosuggestions.zsh +++ b/zsh-autosuggestions.zsh @@ -25,16 +25,6 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR # OTHER DEALINGS IN THE SOFTWARE. -#--------------------------------------------------------------------# -# Setup # -#--------------------------------------------------------------------# - -# Precmd hooks for initializing the library and starting pty's -autoload -Uz add-zsh-hook - -# Asynchronous suggestions are generated in a pty -zmodload zsh/zpty - #--------------------------------------------------------------------# # Global Configuration Variables # #--------------------------------------------------------------------# @@ -122,6 +112,10 @@ typeset -g ZSH_AUTOSUGGEST_ORIGINAL_WIDGET_PREFIX=autosuggest-orig- ) } +# Pty name for capturing completions for completion suggestion strategy +(( ! ${+ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME} )) && +typeset -g ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME=zsh_autosuggest_completion_pty + #--------------------------------------------------------------------# # Utility Functions # #--------------------------------------------------------------------# @@ -486,6 +480,115 @@ _zsh_autosuggest_partial_accept() { zle -N autosuggest-toggle _zsh_autosuggest_widget_toggle } +#--------------------------------------------------------------------# +# Completion Suggestion Strategy # +#--------------------------------------------------------------------# +# Fetches a suggestion from the completion engine +# + +_zsh_autosuggest_capture_postcompletion() { + # Always insert the first completion into the buffer + compstate[insert]=1 + + # Don't list completions + unset compstate[list] +} + +_zsh_autosuggest_capture_completion_widget() { + local -a +h comppostfuncs + comppostfuncs=(_zsh_autosuggest_capture_postcompletion) + + # Only capture completions at the end of the buffer + CURSOR=$#BUFFER + + # Run the original widget wrapping `.complete-word` so we don't + # recursively try to fetch suggestions, since our pty is forked + # after autosuggestions is initialized. + zle -- ${(k)widgets[(r)completion:.complete-word:_main_complete]} + + # The completion has been added, print the buffer as the suggestion + echo -nE - $'\0'$BUFFER$'\0' +} + +zle -N autosuggest-capture-completion _zsh_autosuggest_capture_completion_widget + +_zsh_autosuggest_capture_setup() { + # There is a bug in zpty module in older zsh versions by which a + # zpty that exits will kill all zpty processes that were forked + # before it. Here we set up a zsh exit hook to SIGKILL the zpty + # process immediately, before it has a chance to kill any other + # zpty processes. + if ! is-at-least 5.4; then + zshexit() { + kill -KILL $$ + sleep 1 # Block for long enough for the signal to come through + } + fi + + # Try to avoid any suggestions that wouldn't match the prefix + zstyle ':completion:*' matcher-list '' + zstyle ':completion:*' path-completion false + zstyle ':completion:*' max-errors 0 not-numeric + + bindkey '^I' autosuggest-capture-completion +} + +_zsh_autosuggest_capture_completion_sync() { + _zsh_autosuggest_capture_setup + + zle autosuggest-capture-completion +} + +_zsh_autosuggest_capture_completion_async() { + _zsh_autosuggest_capture_setup + + zmodload zsh/parameter 2>/dev/null || return # For `$functions` + + # Make vared completion work as if for a normal command line + # https://stackoverflow.com/a/7057118/154703 + autoload +X _complete + functions[_original_complete]=$functions[_complete] + _complete () { + unset 'compstate[vared]' + _original_complete "$@" + } + + # Open zle with buffer set so we can capture completions for it + vared 1 +} + +_zsh_autosuggest_strategy_completion() { + typeset -g suggestion + local line REPLY + + # Exit if we don't have completions + whence compdef >/dev/null || return + + # Exit if we don't have zpty + zmodload zsh/zpty 2>/dev/null || return + + # Zle will be inactive if we are in async mode + if zle; then + zpty $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME _zsh_autosuggest_capture_completion_sync + else + zpty $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME _zsh_autosuggest_capture_completion_async "\$1" + zpty -w $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME $'\t' + fi + + { + # The completion result is surrounded by null bytes, so read the + # content between the first two null bytes. + zpty -r $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME line '*'$'\0''*'$'\0' + + # On older versions of zsh, we sometimes get extra bytes after the + # second null byte, so trim those off the end + suggestion="${${${(M)line:#*$'\0'*$'\0'*}#*$'\0'}%%$'\0'*}" + } always { + # Destroy the pty + zpty -d $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME + } +} + #--------------------------------------------------------------------# # History Suggestion Strategy # #--------------------------------------------------------------------# @@ -589,7 +692,10 @@ _zsh_autosuggest_fetch_suggestion() { # Try to get a suggestion from this strategy _zsh_autosuggest_strategy_$strategy "$1" - # Break once we've found a suggestion + # Ensure the suggestion matches the prefix + [[ "$suggestion" != "$1"* ]] && unset suggestion + + # Break once we've found a valid suggestion [[ -n "$suggestion" ]] && break done } @@ -683,4 +789,5 @@ _zsh_autosuggest_start() { } # Start the autosuggestion widgets on the next precmd +autoload -Uz add-zsh-hook add-zsh-hook precmd _zsh_autosuggest_start