diff --git a/Makefile b/Makefile index d5d162c..b89ff04 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,7 @@ SRC_FILES := \ $(SRC_DIR)/highlight.zsh \ $(SRC_DIR)/widgets.zsh \ $(SRC_DIR)/strategies/*.zsh \ + $(SRC_DIR)/fetch.zsh \ $(SRC_DIR)/async.zsh \ $(SRC_DIR)/start.zsh diff --git a/README.md b/README.md index 2636936..4507c6b 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 -Set `ZSH_AUTOSUGGEST_STRATEGY` to choose the strategy for generating suggestions. There are currently two 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: -- `default`: Chooses the most recent match. -- `match_prev_cmd`: 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`. +- `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. + +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/integrations/zle_input_stack_spec.rb b/spec/integrations/zle_input_stack_spec.rb index 8a2c990..12cfbc7 100644 --- a/spec/integrations/zle_input_stack_spec.rb +++ b/spec/integrations/zle_input_stack_spec.rb @@ -2,7 +2,7 @@ describe 'using `zle -U`' do let(:before_sourcing) do -> do session. - run_command('_zsh_autosuggest_strategy_test() { sleep 1; _zsh_autosuggest_strategy_default "$1" }'). + run_command('_zsh_autosuggest_strategy_test() { sleep 1; _zsh_autosuggest_strategy_history "$1" }'). run_command('foo() { zle -U - "echo hello" }; zle -N foo; bindkey ^B foo') end end diff --git a/spec/options/strategy_spec.rb b/spec/options/strategy_spec.rb index c9f01e1..378d01e 100644 --- a/spec/options/strategy_spec.rb +++ b/spec/options/strategy_spec.rb @@ -1,20 +1,45 @@ describe 'a suggestion for a given prefix' do - let(:options) { ['_zsh_autosuggest_strategy_default() { suggestion="echo foo" }'] } + let(:history_strategy) { '_zsh_autosuggest_strategy_history() { suggestion="history" }' } + let(:foobar_strategy) { '_zsh_autosuggest_strategy_foobar() { [[ "foobar baz" = $1* ]] && suggestion="foobar baz" }' } + let(:foobaz_strategy) { '_zsh_autosuggest_strategy_foobaz() { [[ "foobaz bar" = $1* ]] && suggestion="foobaz bar" }' } - it 'is determined by calling the default strategy function' do - session.send_string('e') - wait_for { session.content }.to eq('echo foo') + let(:options) { [ history_strategy ] } + + it 'by default is determined by calling the `history` strategy function' do + session.send_string('h') + wait_for { session.content }.to eq('history') end - context 'when ZSH_AUTOSUGGEST_STRATEGY is set' do + context 'when ZSH_AUTOSUGGEST_STRATEGY is set to an array' do let(:options) { [ - '_zsh_autosuggest_strategy_custom() { suggestion="echo foo" }', - 'ZSH_AUTOSUGGEST_STRATEGY=custom' + foobar_strategy, + foobaz_strategy, + 'ZSH_AUTOSUGGEST_STRATEGY=(foobar foobaz)' ] } - it 'is determined by calling the specified strategy function' do - session.send_string('e') - wait_for { session.content }.to eq('echo foo') + it 'is determined by the first strategy function to return a suggestion' do + session.send_string('foo') + wait_for { session.content }.to eq('foobar baz') + + session.send_string('baz') + wait_for { session.content }.to eq('foobaz bar') + end + end + + context 'when ZSH_AUTOSUGGEST_STRATEGY is set to a string' do + let(:options) { [ + foobar_strategy, + foobaz_strategy, + 'ZSH_AUTOSUGGEST_STRATEGY="foobar foobaz"' + ] } + + it 'is determined by the first strategy function to return a suggestion' do + session.send_string('foo') + wait_for { session.content }.to eq('foobar baz') + + session.send_string('baz') + wait_for { session.content }.to eq('foobaz bar') 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/spec/strategies/default_spec.rb b/spec/strategies/history_spec.rb similarity index 85% rename from spec/strategies/default_spec.rb rename to spec/strategies/history_spec.rb index 89321f3..f8ae526 100644 --- a/spec/strategies/default_spec.rb +++ b/spec/strategies/history_spec.rb @@ -1,6 +1,6 @@ require 'strategies/special_characters_helper' -describe 'the default suggestion strategy' do +describe 'the `history` suggestion strategy' do it 'suggests the last matching history entry' do with_history('ls foo', 'ls bar', 'echo baz') do session.send_string('ls') diff --git a/spec/strategies/match_prev_cmd_spec.rb b/spec/strategies/match_prev_cmd_spec.rb index f1596ba..5a143b8 100644 --- a/spec/strategies/match_prev_cmd_spec.rb +++ b/spec/strategies/match_prev_cmd_spec.rb @@ -1,6 +1,6 @@ require 'strategies/special_characters_helper' -describe 'the match_prev_cmd strategy' do +describe 'the `match_prev_cmd` strategy' do let(:options) { ['ZSH_AUTOSUGGEST_STRATEGY=match_prev_cmd'] } it 'suggests the last matching history entry after the previous command' do diff --git a/src/config.zsh b/src/config.zsh index 597307f..4598191 100644 --- a/src/config.zsh +++ b/src/config.zsh @@ -11,7 +11,9 @@ ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE='fg=8' # Prefix to use when saving original versions of bound widgets ZSH_AUTOSUGGEST_ORIGINAL_WIDGET_PREFIX=autosuggest-orig- -ZSH_AUTOSUGGEST_STRATEGY=default +# Strategies to use to fetch a suggestion +# Will try each strategy in order until a suggestion is returned +ZSH_AUTOSUGGEST_STRATEGY=(history) # Widgets that clear the suggestion ZSH_AUTOSUGGEST_CLEAR_WIDGETS=( @@ -68,4 +70,7 @@ ZSH_AUTOSUGGEST_IGNORE_WIDGETS=( ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE= # Pty name for calculating autosuggestions asynchronously -ZSH_AUTOSUGGEST_ASYNC_PTY_NAME=zsh_autosuggest_pty +ZSH_AUTOSUGGEST_ASYNC_PTY_NAME=zsh_autosuggest_async_pty + +# Pty name for capturing completions for completion suggestion strategy +ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME=zsh_autosuggest_completion_pty diff --git a/src/fetch.zsh b/src/fetch.zsh new file mode 100644 index 0000000..1517018 --- /dev/null +++ b/src/fetch.zsh @@ -0,0 +1,24 @@ + +#--------------------------------------------------------------------# +# Fetch Suggestion # +#--------------------------------------------------------------------# +# Loops through all specified strategies and returns a suggestion +# from the first strategy to provide one. +# + +_zsh_autosuggest_fetch_suggestion() { + typeset -g suggestion + local -a strategies + local strategy + + # Ensure we are working with an array + strategies=(${=ZSH_AUTOSUGGEST_STRATEGY}) + + for strategy in $strategies; do + # Try to get a suggestion from this strategy + _zsh_autosuggest_strategy_$strategy "$1" + + # Break once we've found a suggestion + [[ -n "$suggestion" ]] && break + done +} diff --git a/src/strategies/completion.zsh b/src/strategies/completion.zsh new file mode 100644 index 0000000..8bbd7c8 --- /dev/null +++ b/src/strategies/completion.zsh @@ -0,0 +1,124 @@ + +#--------------------------------------------------------------------# +# Completion Suggestion Strategy # +#--------------------------------------------------------------------# +# Fetches suggestions from zsh's completion engine +# Based on https://github.com/Valodim/zsh-capture-completion +# + +_zsh_autosuggest_capture_setup() { + zmodload zsh/zutil # For `zparseopts` + + # Ensure completions have been initialized + if ! whence compdef >/dev/null; then + autoload -Uz compinit && compinit + fi + + # There is a bug in zpty module (fixed in zsh/master) 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. + zshexit() { + kill -KILL $$ + sleep 1 # Block for long enough for the signal to come through + } + + # Never group stuff! + zstyle ':completion:*' list-grouped false + + # No list separator, this saves some stripping later on + zstyle ':completion:*' list-separator '' + + # Override compadd (this is our hook) + compadd () { + setopt localoptions norcexpandparam + + # Just delegate and leave if any of -O, -A or -D are given + if [[ ${@[1,(i)(-|--)]} == *-(O|A|D)\ * ]]; then + builtin compadd "$@" + return $? + fi + + # Capture completions by injecting -A parameter into the compadd call. + # This takes care of matching for us. + typeset -a __hits + builtin compadd -A __hits "$@" + + # Exit if no completion results + [[ -n $__hits ]] || return + + # Extract prefixes and suffixes from compadd call. we can't do zsh's cool + # -r remove-func magic, but it's better than nothing. + typeset -A apre hpre hsuf asuf + zparseopts -E P:=apre p:=hpre S:=asuf s:=hsuf + + # Print the first match + echo -nE - $'\0'$IPREFIX$apre$hpre$__hits[1]$dsuf$hsuf$asuf$'\0' + } +} + +_zsh_autosuggest_capture_widget() { + _zsh_autosuggest_capture_setup + + zle complete-word +} + +zle -N autosuggest-capture-completion _zsh_autosuggest_capture_widget + +_zsh_autosuggest_capture_buffer() { + local BUFFERCONTENT="$1" + + _zsh_autosuggest_capture_setup + + zmodload zsh/parameter # 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 BUFFERCONTENT +} + +_zsh_autosuggest_capture_completion() { + typeset -g completion + local line REPLY + + # Zle will be inactive if we are in async mode + if zle; then + zpty $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME zle autosuggest-capture-completion + else + zpty $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME _zsh_autosuggest_capture_buffer "\$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 + completion="${${${(M)line:#*$'\0'*$'\0'*}#*$'\0'}%%$'\0'*}" + + # Destroy the pty + zpty -d $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME +} + +_zsh_autosuggest_strategy_completion() { + typeset -g suggestion + local completion + + # Fetch the first completion result + _zsh_autosuggest_capture_completion "$1" + + # Add the completion string to the buffer to build the full suggestion + local -i i=1 + while [[ "$completion" != "${1[$i,-1]}"* ]]; do ((i++)); done + suggestion="${1[1,$i-1]}$completion" +} diff --git a/src/strategies/default.zsh b/src/strategies/history.zsh similarity index 89% rename from src/strategies/default.zsh rename to src/strategies/history.zsh index 0e85fb5..a2755a5 100644 --- a/src/strategies/default.zsh +++ b/src/strategies/history.zsh @@ -1,12 +1,12 @@ #--------------------------------------------------------------------# -# Default Suggestion Strategy # +# History Suggestion Strategy # #--------------------------------------------------------------------# # Suggests the most recent history item that matches the given # prefix. # -_zsh_autosuggest_strategy_default() { +_zsh_autosuggest_strategy_history() { # Reset options to defaults and enable LOCAL_OPTIONS emulate -L zsh diff --git a/src/widgets.zsh b/src/widgets.zsh index 4cd8ca8..746944d 100644 --- a/src/widgets.zsh +++ b/src/widgets.zsh @@ -97,7 +97,7 @@ _zsh_autosuggest_fetch() { _zsh_autosuggest_async_request "$BUFFER" else local suggestion - _zsh_autosuggest_strategy_$ZSH_AUTOSUGGEST_STRATEGY "$BUFFER" + _zsh_autosuggest_fetch_suggestion "$BUFFER" _zsh_autosuggest_suggest "$suggestion" fi } diff --git a/zsh-autosuggestions.zsh b/zsh-autosuggestions.zsh index 4c14666..cad2847 100644 --- a/zsh-autosuggestions.zsh +++ b/zsh-autosuggestions.zsh @@ -47,7 +47,9 @@ ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE='fg=8' # Prefix to use when saving original versions of bound widgets ZSH_AUTOSUGGEST_ORIGINAL_WIDGET_PREFIX=autosuggest-orig- -ZSH_AUTOSUGGEST_STRATEGY=default +# Strategies to use to fetch a suggestion +# Will try each strategy in order until a suggestion is returned +ZSH_AUTOSUGGEST_STRATEGY=(history) # Widgets that clear the suggestion ZSH_AUTOSUGGEST_CLEAR_WIDGETS=( @@ -104,7 +106,10 @@ ZSH_AUTOSUGGEST_IGNORE_WIDGETS=( ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE= # Pty name for calculating autosuggestions asynchronously -ZSH_AUTOSUGGEST_ASYNC_PTY_NAME=zsh_autosuggest_pty +ZSH_AUTOSUGGEST_ASYNC_PTY_NAME=zsh_autosuggest_async_pty + +# Pty name for capturing completions for completion suggestion strategy +ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME=zsh_autosuggest_completion_pty #--------------------------------------------------------------------# # Utility Functions # @@ -378,7 +383,7 @@ _zsh_autosuggest_fetch() { _zsh_autosuggest_async_request "$BUFFER" else local suggestion - _zsh_autosuggest_strategy_$ZSH_AUTOSUGGEST_STRATEGY "$BUFFER" + _zsh_autosuggest_fetch_suggestion "$BUFFER" _zsh_autosuggest_suggest "$suggestion" fi } @@ -494,13 +499,137 @@ zle -N autosuggest-disable _zsh_autosuggest_widget_disable zle -N autosuggest-toggle _zsh_autosuggest_widget_toggle #--------------------------------------------------------------------# -# Default Suggestion Strategy # +# Completion Suggestion Strategy # +#--------------------------------------------------------------------# +# Fetches suggestions from zsh's completion engine +# Based on https://github.com/Valodim/zsh-capture-completion +# + +_zsh_autosuggest_capture_setup() { + zmodload zsh/zutil # For `zparseopts` + + # Ensure completions have been initialized + if ! whence compdef >/dev/null; then + autoload -Uz compinit && compinit + fi + + # There is a bug in zpty module (fixed in zsh/master) 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. + zshexit() { + kill -KILL $$ + sleep 1 # Block for long enough for the signal to come through + } + + # Never group stuff! + zstyle ':completion:*' list-grouped false + + # No list separator, this saves some stripping later on + zstyle ':completion:*' list-separator '' + + # Override compadd (this is our hook) + compadd () { + setopt localoptions norcexpandparam + + # Just delegate and leave if any of -O, -A or -D are given + if [[ ${@[1,(i)(-|--)]} == *-(O|A|D)\ * ]]; then + builtin compadd "$@" + return $? + fi + + # Capture completions by injecting -A parameter into the compadd call. + # This takes care of matching for us. + typeset -a __hits + builtin compadd -A __hits "$@" + + # Exit if no completion results + [[ -n $__hits ]] || return + + # Extract prefixes and suffixes from compadd call. we can't do zsh's cool + # -r remove-func magic, but it's better than nothing. + typeset -A apre hpre hsuf asuf + zparseopts -E P:=apre p:=hpre S:=asuf s:=hsuf + + # Print the first match + echo -nE - $'\0'$IPREFIX$apre$hpre$__hits[1]$dsuf$hsuf$asuf$'\0' + } +} + +_zsh_autosuggest_capture_widget() { + _zsh_autosuggest_capture_setup + + zle complete-word +} + +zle -N autosuggest-capture-completion _zsh_autosuggest_capture_widget + +_zsh_autosuggest_capture_buffer() { + local BUFFERCONTENT="$1" + + _zsh_autosuggest_capture_setup + + zmodload zsh/parameter # 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 BUFFERCONTENT +} + +_zsh_autosuggest_capture_completion() { + typeset -g completion + local line REPLY + + # Zle will be inactive if we are in async mode + if zle; then + zpty $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME zle autosuggest-capture-completion + else + zpty $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME _zsh_autosuggest_capture_buffer "\$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 + completion="${${${(M)line:#*$'\0'*$'\0'*}#*$'\0'}%%$'\0'*}" + + # Destroy the pty + zpty -d $ZSH_AUTOSUGGEST_COMPLETIONS_PTY_NAME +} + +_zsh_autosuggest_strategy_completion() { + typeset -g suggestion + local completion + + # Fetch the first completion result + _zsh_autosuggest_capture_completion "$1" + + # Add the completion string to the buffer to build the full suggestion + local -i i=1 + while [[ "$completion" != "${1[$i,-1]}"* ]]; do ((i++)); done + suggestion="${1[1,$i-1]}$completion" +} + +#--------------------------------------------------------------------# +# History Suggestion Strategy # #--------------------------------------------------------------------# # Suggests the most recent history item that matches the given # prefix. # -_zsh_autosuggest_strategy_default() { +_zsh_autosuggest_strategy_history() { # Reset options to defaults and enable LOCAL_OPTIONS emulate -L zsh @@ -577,6 +706,30 @@ _zsh_autosuggest_strategy_match_prev_cmd() { typeset -g suggestion="$history[$histkey]" } +#--------------------------------------------------------------------# +# Fetch Suggestion # +#--------------------------------------------------------------------# +# Loops through all specified strategies and returns a suggestion +# from the first strategy to provide one. +# + +_zsh_autosuggest_fetch_suggestion() { + typeset -g suggestion + local -a strategies + local strategy + + # Ensure we are working with an array + strategies=(${=ZSH_AUTOSUGGEST_STRATEGY}) + + for strategy in $strategies; do + # Try to get a suggestion from this strategy + _zsh_autosuggest_strategy_$strategy "$1" + + # Break once we've found a suggestion + [[ -n "$suggestion" ]] && break + done +} + #--------------------------------------------------------------------# # Async # #--------------------------------------------------------------------#