diff --git a/.editorconfig b/.editorconfig index 51c4765..b40bc96 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,3 +8,7 @@ indent_size = 4 [*.md] indent_style = space + +[*.rb] +indent_style = space +indent_size = 2 diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index b45eb46..0000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule "vendor/shunit2"] - path = vendor/shunit2 - url = https://github.com/kward/shunit2 -[submodule "vendor/stub.sh"] - path = vendor/stub.sh - url = https://github.com/ericfreese/stub.sh diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..43ae203 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--color +--require spec_helper +--format documentation diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..9e0792f --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,30 @@ +# Rails: +# Enabled: true + +AllCops: + TargetRubyVersion: 2.3 + Include: + - '**/Rakefile' + - '**/config.ru' + - '**/Gemfile' + +Metrics/LineLength: + Max: 120 + +Style/Documentation: + Enabled: false + +Style/DotPosition: + EnforcedStyle: trailing + +Style/FrozenStringLiteralComment: + Enabled: false + +Style/Lambda: + Enabled: false + +Style/MultilineMethodCallIndentation: + EnforcedStyle: indented + +Style/TrailingUnderscoreVariable: + Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..2bf1c1c --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.3.1 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..c8090be --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +source 'https://rubygems.org' + +gem 'rspec' +gem 'rspec-wait' +gem 'pry' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..75b649b --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,37 @@ +GEM + remote: https://rubygems.org/ + specs: + coderay (1.1.1) + diff-lcs (1.3) + method_source (0.8.2) + pry (0.10.4) + coderay (~> 1.1.0) + method_source (~> 0.8.1) + slop (~> 3.4) + rspec (3.5.0) + rspec-core (~> 3.5.0) + rspec-expectations (~> 3.5.0) + rspec-mocks (~> 3.5.0) + rspec-core (3.5.4) + rspec-support (~> 3.5.0) + rspec-expectations (3.5.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.5.0) + rspec-mocks (3.5.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.5.0) + rspec-support (3.5.0) + rspec-wait (0.0.9) + rspec (>= 3, < 4) + slop (3.6.0) + +PLATFORMS + ruby + +DEPENDENCIES + pry + rspec + rspec-wait + +BUNDLED WITH + 1.13.6 diff --git a/LICENSE b/LICENSE index ee52ee2..ad6594e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ Copyright (c) 2013 Thiago de Arruda -Copyright (c) 2016 Eric Freese +Copyright (c) 2016-2017 Eric Freese Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation diff --git a/Makefile b/Makefile index fde3691..c7aaf1d 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,16 @@ SRC_DIR := ./src -VENDOR_DIR := ./vendor SRC_FILES := \ + $(SRC_DIR)/setup.zsh \ $(SRC_DIR)/config.zsh \ + $(SRC_DIR)/util.zsh \ + $(SRC_DIR)/features.zsh \ $(SRC_DIR)/deprecated.zsh \ $(SRC_DIR)/bind.zsh \ $(SRC_DIR)/highlight.zsh \ $(SRC_DIR)/widgets.zsh \ - $(SRC_DIR)/suggestion.zsh \ $(SRC_DIR)/strategies/*.zsh \ + $(SRC_DIR)/async.zsh \ $(SRC_DIR)/start.zsh HEADER_FILES := \ @@ -19,29 +21,16 @@ HEADER_FILES := \ PLUGIN_TARGET := zsh-autosuggestions.zsh -SHUNIT2 := $(VENDOR_DIR)/shunit2/2.1.6 -STUB_SH := $(VENDOR_DIR)/stub.sh/stub.sh - -TEST_PREREQS := \ - $(SHUNIT2) \ - $(STUB_SH) - all: $(PLUGIN_TARGET) $(PLUGIN_TARGET): $(HEADER_FILES) $(SRC_FILES) cat $(HEADER_FILES) | sed -e 's/^/# /g' > $@ cat $(SRC_FILES) >> $@ -$(SHUNIT2): - git submodule update --init vendor/shunit2 - -$(STUB_SH): - git submodule update --init vendor/stub.sh - .PHONY: clean clean: rm $(PLUGIN_TARGET) .PHONY: test -test: all $(TEST_PREREQS) - script/test_runner.zsh $(TESTS) +test: all + bundle exec rspec $(TESTS) diff --git a/README.md b/README.md index 3a5c3f3..85cac70 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,10 @@ Widgets that modify the buffer and are not found in any of these arrays will fet Set `ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE` to an integer value to disable autosuggestion for large buffers. The default is unset, which means that autosuggestion will be tried for any buffer size. Recommended value is 20. This can be useful when pasting large amount of text in the terminal, to avoid triggering autosuggestion for too long strings. +### Disable Asynchronous Mode + +As of `v0.4.0`, suggestions are fetched asynchronously using the `zsh/zpty` module. To disable this behavior and fall back to fetching suggestions synchronously, unset the `ZSH_AUTOSUGGEST_USE_ASYNC` variable. + ### Key Bindings @@ -154,9 +158,9 @@ Pull requests are welcome! If you send a pull request, please: ### Testing -Testing is performed with [`shunit2`](https://github.com/kward/shunit2) (v2.1.6). Documentation can be found [here](http://shunit2.googlecode.com/svn/trunk/source/2.1/doc/shunit2.html). +Tests are written in ruby using the [`rspec`](http://rspec.info/) framework. They use [`tmux`](https://tmux.github.io/) to drive a pseudoterminal, sending simulated keystrokes and making assertions on the terminal content. -The test script lives at `script/test_runner.zsh`. To run the tests, run `make test`. +Test files live in `spec/`. To run the tests, run `make test`. To run a specific test, run `TESTS=spec/some_spec.rb make test`. You can also specify a `zsh` binary to use by setting the `TEST_ZSH_BIN` environment variable (ex: `TEST_ZSH_BIN=/bin/zsh make test`). ## License diff --git a/script/test_runner.zsh b/script/test_runner.zsh deleted file mode 100755 index 0ff4173..0000000 --- a/script/test_runner.zsh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env zsh - -DIR="${0:a:h}" -ROOT_DIR="$DIR/.." -TEST_DIR="$ROOT_DIR/test" - -header() { - local message="$1" - - cat <<-EOF - -#====================================================================# -# $message -#====================================================================# - EOF -} - -# ZSH binary to use -local zsh_bin="zsh" - -while getopts ":z:" opt; do - case $opt in - z) - zsh_bin="$OPTARG" - ;; - \?) - echo "Invalid option: -$OPTARG" >&2 - exit 1 - ;; - :) - echo "Option -$OPTARG requires an argument" >&2 - exit 1 - ;; - esac -done - -shift $((OPTIND -1)) - -# Test suites to run -local -a tests -if [ $#@ -gt 0 ]; then - tests=($@) -else - tests=($TEST_DIR/**/*_test.zsh) -fi - -local -i retval=0 - -for suite in $tests; do - header "${suite#"$ROOT_DIR/"}" - "$zsh_bin" -f "$suite" || retval=$? -done - -exit $retval diff --git a/spec/integrations/client_zpty_spec.rb b/spec/integrations/client_zpty_spec.rb new file mode 100644 index 0000000..fb7bbeb --- /dev/null +++ b/spec/integrations/client_zpty_spec.rb @@ -0,0 +1,10 @@ +describe 'a running zpty command' do + let(:before_sourcing) { -> { session.run_command('zmodload zsh/zpty && zpty -b kitty cat') } } + + it 'is not affected by running zsh-autosuggestions' do + sleep 1 # Give a little time for precmd hooks to run + session.run_command('zpty -t kitty; echo $?') + + wait_for(session.content).to end_with("\n0") + end +end diff --git a/spec/multi_line_spec.rb b/spec/multi_line_spec.rb new file mode 100644 index 0000000..4ff2ae1 --- /dev/null +++ b/spec/multi_line_spec.rb @@ -0,0 +1,13 @@ +describe 'a multi-line suggestion' do + it 'should be displayed on multiple lines' do + with_history(-> { + session.send_string('echo "') + session.send_keys('enter') + session.send_string('"') + session.send_keys('enter') + }) do + session.send_keys('e') + wait_for { session.content }.to eq("echo \"\n\"") + end + end +end diff --git a/spec/options/async_zpty_name_spec.rb b/spec/options/async_zpty_name_spec.rb new file mode 100644 index 0000000..c768f54 --- /dev/null +++ b/spec/options/async_zpty_name_spec.rb @@ -0,0 +1,15 @@ +describe 'the zpty for async suggestions' do + it 'is created with the default name' do + session.run_command('zpty -t zsh_autosuggest_pty &>/dev/null; echo $?') + wait_for { session.content }.to end_with("\n0") + end + + context 'when ZSH_AUTOSUGGEST_ASYNC_PTY_NAME is set' do + let(:options) { ['ZSH_AUTOSUGGEST_ASYNC_PTY_NAME=foo_pty'] } + + it 'is created with the specified name' do + session.run_command('zpty -t foo_pty &>/dev/null; echo $?') + wait_for { session.content }.to end_with("\n0") + end + end +end diff --git a/spec/options/buffer_max_size_spec.rb b/spec/options/buffer_max_size_spec.rb new file mode 100644 index 0000000..29ca8bc --- /dev/null +++ b/spec/options/buffer_max_size_spec.rb @@ -0,0 +1,30 @@ +describe 'a suggestion' do + let(:term_opts) { { width: 200 } } + let(:long_command) { "echo #{'a' * 100}" } + + around do |example| + with_history(long_command) { example.run } + end + + it 'is provided for any buffer length' do + session.send_string(long_command[0...-1]) + wait_for { session.content }.to eq(long_command) + end + + context 'when ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE is specified' do + let(:buffer_max_size) { 10 } + let(:options) { ["ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE=#{buffer_max_size}"] } + + it 'is provided when the buffer is shorter than the specified length' do + session.send_string(long_command[0...(buffer_max_size - 1)]) + wait_for { session.content }.to eq(long_command) + end + + it 'is provided when the buffer is equal to the specified length' do + session.send_string(long_command[0...(buffer_max_size)]) + wait_for { session.content }.to eq(long_command) + end + + it 'is not provided when the buffer is longer than the specified length' + end +end diff --git a/spec/options/highlight_style_spec.rb b/spec/options/highlight_style_spec.rb new file mode 100644 index 0000000..a7e39b3 --- /dev/null +++ b/spec/options/highlight_style_spec.rb @@ -0,0 +1,7 @@ +describe 'a displayed suggestion' do + it 'is shown in the default style' + + describe 'when ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE is set to a zle_highlight string' do + it 'is shown in the specified style' + end +end diff --git a/spec/options/original_widget_prefix_spec.rb b/spec/options/original_widget_prefix_spec.rb new file mode 100644 index 0000000..a4b6e98 --- /dev/null +++ b/spec/options/original_widget_prefix_spec.rb @@ -0,0 +1,7 @@ +describe 'an original zle widget' do + context 'is accessible with the default prefix' + + context 'when ZSH_AUTOSUGGEST_ORIGINAL_WIDGET_PREFIX is set' do + it 'is accessible with the specified prefix' + end +end diff --git a/spec/options/strategy_spec.rb b/spec/options/strategy_spec.rb new file mode 100644 index 0000000..c9f01e1 --- /dev/null +++ b/spec/options/strategy_spec.rb @@ -0,0 +1,20 @@ +describe 'a suggestion for a given prefix' do + let(:options) { ['_zsh_autosuggest_strategy_default() { suggestion="echo foo" }'] } + + it 'is determined by calling the default strategy function' do + session.send_string('e') + wait_for { session.content }.to eq('echo foo') + end + + context 'when ZSH_AUTOSUGGEST_STRATEGY is set' do + let(:options) { [ + '_zsh_autosuggest_strategy_custom() { suggestion="echo foo" }', + 'ZSH_AUTOSUGGEST_STRATEGY=custom' + ] } + + it 'is determined by calling the specified strategy function' do + session.send_string('e') + wait_for { session.content }.to eq('echo foo') + end + end +end diff --git a/spec/options/use_async_spec.rb b/spec/options/use_async_spec.rb new file mode 100644 index 0000000..8b9ebab --- /dev/null +++ b/spec/options/use_async_spec.rb @@ -0,0 +1,7 @@ +describe 'suggestion fetching' do + it 'is performed asynchronously' + + context 'when ZSH_AUTOSUGGEST_USE_ASYNC is unset' do + it 'is performed synchronously' + end +end diff --git a/spec/options/widget_lists_spec.rb b/spec/options/widget_lists_spec.rb new file mode 100644 index 0000000..3e03acf --- /dev/null +++ b/spec/options/widget_lists_spec.rb @@ -0,0 +1,72 @@ +describe 'a zle widget' do + let(:before_sourcing) { -> { session.run_command('my-widget() {}; zle -N my-widget; bindkey ^B my-widget') } } + + context 'when added to ZSH_AUTOSUGGEST_ACCEPT_WIDGETS' do + let(:options) { ['ZSH_AUTOSUGGEST_ACCEPT_WIDGETS=(my-widget)'] } + + it 'accepts the suggestion when invoked' do + with_history('echo hello') do + session.send_string('e') + wait_for { session.content }.to eq('echo hello') + session.send_keys('C-b') + wait_for { session.content(esc_seqs: true) }.to eq('echo hello') + end + end + end + + context 'when added to ZSH_AUTOSUGGEST_CLEAR_WIDGETS' do + let(:options) { ['ZSH_AUTOSUGGEST_CLEAR_WIDGETS=(my-widget)'] } + + it 'clears the suggestion when invoked' do + with_history('echo hello') do + session.send_string('e') + wait_for { session.content }.to eq('echo hello') + session.send_keys('C-b') + wait_for { session.content }.to eq('e') + end + end + end + + context 'when added to ZSH_AUTOSUGGEST_EXECUTE_WIDGETS' do + let(:options) { ['ZSH_AUTOSUGGEST_EXECUTE_WIDGETS=(my-widget)'] } + + it 'executes the suggestion when invoked' do + with_history('echo hello') do + session.send_string('e') + wait_for { session.content }.to eq('echo hello') + session.send_keys('C-b') + wait_for { session.content }.to end_with("\nhello") + end + end + end +end + +describe 'a zle widget that moves the cursor forward' do + let(:before_sourcing) { -> { session.run_command('my-widget() { zle forward-char }; zle -N my-widget; bindkey ^B my-widget') } } + + context 'when added to ZSH_AUTOSUGGEST_PARTIAL_ACCEPT_WIDGETS' do + let(:options) { ['ZSH_AUTOSUGGEST_PARTIAL_ACCEPT_WIDGETS=(my-widget)'] } + + it 'accepts the suggestion as far as the cursor is moved when invoked' do + with_history('echo hello') do + session.send_string('e') + wait_for { session.content }.to start_with('echo hello') + session.send_keys('C-b') + wait_for { session.content(esc_seqs: true) }.to match(/\Aec\e\[[0-9]+mho hello/) + end + end + end +end + +describe 'a builtin zle widget' do + let(:widget) { 'beep' } + + context 'when added to ZSH_AUTOSUGGEST_IGNORE_WIDGETS' do + let(:options) { ["ZSH_AUTOSUGGEST_IGNORE_WIDGETS=(#{widget})"] } + + it 'should not be wrapped with an autosuggest widget' do + session.run_command("echo $widgets[#{widget}]") + wait_for { session.content }.to end_with("\nbuiltin") + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..64115d2 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,50 @@ +require 'pry' +require 'rspec/wait' +require 'terminal_session' + +RSpec.shared_context 'terminal session' do + let(:term_opts) { {} } + let(:session) { TerminalSession.new(term_opts) } + let(:before_sourcing) { -> {} } + let(:options) { [] } + + around do |example| + before_sourcing.call + + session.run_command((['source zsh-autosuggestions.zsh'] + options).join('; ')) + session.clear_screen + + example.run + + session.destroy + end + + def with_history(*commands, &block) + session.run_command('fc -p') + + commands.each do |c| + c.respond_to?(:call) ? c.call : session.run_command(c) + end + + session.clear_screen + + yield block + + session.send_keys('C-c') + session.run_command('fc -P') + end +end + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.wait_timeout = 2 + + config.include_context 'terminal session' +end diff --git a/spec/special_characters_spec.rb b/spec/special_characters_spec.rb new file mode 100644 index 0000000..21f681b --- /dev/null +++ b/spec/special_characters_spec.rb @@ -0,0 +1,55 @@ +describe 'a special character in the buffer' do + it 'should be treated like any other character' do + with_history('echo "hello*"', 'echo "hello."') do + session.send_string('echo "hello*') + wait_for { session.content }.to eq('echo "hello*"') + end + + with_history('echo "hello?"', 'echo "hello."') do + session.send_string('echo "hello?') + wait_for { session.content }.to eq('echo "hello?"') + end + + with_history('echo "hello\nworld"') do + session.send_string('echo "hello\\') + wait_for { session.content }.to eq('echo "hello\nworld"') + end + + with_history('echo "\\\\"') do + session.send_string('echo "\\\\') + wait_for { session.content }.to eq('echo "\\\\"') + end + + with_history('echo ~/foo') do + session.send_string('echo ~') + wait_for { session.content }.to eq('echo ~/foo') + end + + with_history('echo "$(ls foo)"') do + session.send_string('echo "$(') + wait_for { session.content }.to eq('echo "$(ls foo)"') + end + + with_history('echo "$history[123]"') do + session.send_string('echo "$history[') + wait_for { session.content }.to eq('echo "$history[123]"') + session.send_string('123]') + wait_for { session.content }.to eq('echo "$history[123]"') + end + + with_history('echo "#yolo"') do + session.send_string('echo "#') + wait_for { session.content }.to eq('echo "#yolo"') + end + + with_history('echo "#foo"', 'echo $#abc') do + session.send_string('echo "#') + wait_for { session.content }.to eq('echo "#foo"') + end + + with_history('echo "^A"', 'echo "^B"') do + session.send_string('echo "^A') + wait_for { session.content }.to eq('echo "^A"') + end + end +end diff --git a/spec/strategies/default_spec.rb b/spec/strategies/default_spec.rb new file mode 100644 index 0000000..94f3450 --- /dev/null +++ b/spec/strategies/default_spec.rb @@ -0,0 +1,8 @@ +describe 'the default suggestion strategy' do + it 'suggests the last matching history entry' do + with_history('ls foo', 'ls bar', 'echo baz') do + session.send_string('ls') + wait_for { session.content }.to eq('ls bar') + end + end +end diff --git a/spec/strategies/match_prev_cmd_spec.rb b/spec/strategies/match_prev_cmd_spec.rb new file mode 100644 index 0000000..21be712 --- /dev/null +++ b/spec/strategies/match_prev_cmd_spec.rb @@ -0,0 +1,17 @@ +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 + with_history( + 'echo what', + 'ls foo', + 'echo what', + 'ls bar', + 'ls baz', + 'echo what' + ) do + session.send_string('ls') + wait_for { session.content }.to eq('ls bar') + end + end +end diff --git a/spec/terminal_session.rb b/spec/terminal_session.rb new file mode 100644 index 0000000..a089d27 --- /dev/null +++ b/spec/terminal_session.rb @@ -0,0 +1,84 @@ +require 'securerandom' + +class TerminalSession + ZSH_BIN = ENV['TEST_ZSH_BIN'] || 'zsh' + + def initialize(opts = {}) + opts = { + width: 80, + height: 24, + prompt: '', + term: 'xterm-256color', + zsh_bin: ZSH_BIN + }.merge(opts) + + @opts = opts + + cmd="PS1=\"#{opts[:prompt]}\" TERM=#{opts[:term]} #{ZSH_BIN} -f" + tmux_command("new-session -d -x #{opts[:width]} -y #{opts[:height]} '#{cmd}'") + end + + def run_command(command) + send_string(command) + send_keys('enter') + + self + end + + def send_string(str) + tmux_command("send-keys -t 0 -l '#{str.gsub("'", "\\'")}'") + + self + end + + def send_keys(*keys) + tmux_command("send-keys -t 0 #{keys.join(' ')}") + + self + end + + def content(esc_seqs: false) + cmd = 'capture-pane -p -t 0' + cmd += ' -e' if esc_seqs + tmux_command(cmd).strip + end + + def clear_screen + send_keys('C-l') + + i = 0 + until content == opts[:prompt] || i > 20 do + sleep(0.1) + i = i + 1 + end + + self + end + + def destroy + tmux_command('kill-session') + end + + def cursor + tmux_command("display-message -t 0 -p '\#{cursor_x},\#{cursor_y}'"). + strip. + split(','). + map(&:to_i) + end + + private + + attr_reader :opts + + def tmux_socket_name + @tmux_socket_name ||= SecureRandom.hex(6) + end + + def tmux_command(cmd) + out = `tmux -u -L #{tmux_socket_name} #{cmd}` + + raise("tmux error running: '#{cmd}'") unless $?.success? + + out + end +end diff --git a/src/async.zsh b/src/async.zsh new file mode 100644 index 0000000..60055b9 --- /dev/null +++ b/src/async.zsh @@ -0,0 +1,109 @@ + +#--------------------------------------------------------------------# +# Async # +#--------------------------------------------------------------------# + +# Zpty process is spawned running this function +_zsh_autosuggest_async_server() { + emulate -R zsh + + # 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 + } + + # Output only newlines (not carriage return + newline) + stty -onlcr + + + # Silence any error messages + exec 2>/dev/null + + local strategy=$1 + local last_pid + + while IFS='' read -r -d $'\0' query; do + # Kill last bg process + kill -KILL $last_pid &>/dev/null + + # Run suggestion search in the background + ( + local suggestion + _zsh_autosuggest_strategy_$ZSH_AUTOSUGGEST_STRATEGY "$query" + echo -n -E "$suggestion"$'\0' + ) & + + last_pid=$! + done +} + +_zsh_autosuggest_async_request() { + # Write the query to the zpty process to fetch a suggestion + zpty -w -n $ZSH_AUTOSUGGEST_ASYNC_PTY_NAME "${1}"$'\0' +} + +# Called when new data is ready to be read from the pty +# First arg will be fd ready for reading +# Second arg will be passed in case of error +_zsh_autosuggest_async_response() { + setopt LOCAL_OPTIONS EXTENDED_GLOB + + local suggestion + + zpty -rt $ZSH_AUTOSUGGEST_ASYNC_PTY_NAME suggestion '*'$'\0' 2>/dev/null + zle autosuggest-suggest "${suggestion%%$'\0'##}" +} + +_zsh_autosuggest_async_pty_create() { + # With newer versions of zsh, REPLY stores the fd to read from + typeset -h REPLY + + # If we won't get a fd back from zpty, try to guess it + if [ $_ZSH_AUTOSUGGEST_ZPTY_RETURNS_FD -eq 0 ]; then + integer -l zptyfd + exec {zptyfd}>&1 # Open a new file descriptor (above 10). + exec {zptyfd}>&- # Close it so it's free to be used by zpty. + fi + + # Fork a zpty process running the server function + zpty -b $ZSH_AUTOSUGGEST_ASYNC_PTY_NAME "_zsh_autosuggest_async_server _zsh_autosuggest_strategy_$ZSH_AUTOSUGGEST_STRATEGY" + + # Store the fd so we can remove the handler later + if (( REPLY )); then + _ZSH_AUTOSUGGEST_PTY_FD=$REPLY + else + _ZSH_AUTOSUGGEST_PTY_FD=$zptyfd + fi + + # Set up input handler from the zpty + zle -F $_ZSH_AUTOSUGGEST_PTY_FD _zsh_autosuggest_async_response +} + +_zsh_autosuggest_async_pty_destroy() { + if [ -n "$_ZSH_AUTOSUGGEST_PTY_FD" ]; then + # Remove the input handler + zle -F $_ZSH_AUTOSUGGEST_PTY_FD + + # Destroy the zpty + zpty -d $ZSH_AUTOSUGGEST_ASYNC_PTY_NAME &>/dev/null + fi +} + +_zsh_autosuggest_async_pty_recreate() { + _zsh_autosuggest_async_pty_destroy + _zsh_autosuggest_async_pty_create +} + +_zsh_autosuggest_async_start() { + typeset -g _ZSH_AUTOSUGGEST_PTY_FD + + _zsh_autosuggest_async_pty_create + + # We recreate the pty to get a fresh list of history events + add-zsh-hook precmd _zsh_autosuggest_async_pty_recreate +} diff --git a/src/config.zsh b/src/config.zsh index f519f6f..a9f02e6 100644 --- a/src/config.zsh +++ b/src/config.zsh @@ -60,3 +60,9 @@ ZSH_AUTOSUGGEST_IGNORE_WIDGETS=( # Max size of buffer to trigger autosuggestion. Leave undefined for no upper bound. ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE= + +# Use asynchronous mode by default. Unset this variable to use sync mode. +ZSH_AUTOSUGGEST_USE_ASYNC= + +# Pty name for calculating autosuggestions asynchronously +ZSH_AUTOSUGGEST_ASYNC_PTY_NAME=zsh_autosuggest_pty diff --git a/src/features.zsh b/src/features.zsh new file mode 100644 index 0000000..35dfcc3 --- /dev/null +++ b/src/features.zsh @@ -0,0 +1,19 @@ + +#--------------------------------------------------------------------# +# Feature Detection # +#--------------------------------------------------------------------# + +_zsh_autosuggest_feature_detect() { + typeset -g _ZSH_AUTOSUGGEST_ZPTY_RETURNS_FD + typeset -h REPLY + + zpty $ZSH_AUTOSUGGEST_ASYNC_PTY_NAME '{ zshexit() { kill -KILL $$; sleep 1 } }' + + if (( REPLY )); then + _ZSH_AUTOSUGGEST_ZPTY_RETURNS_FD=1 + else + _ZSH_AUTOSUGGEST_ZPTY_RETURNS_FD=0 + fi + + zpty -d $ZSH_AUTOSUGGEST_ASYNC_PTY_NAME +} diff --git a/src/setup.zsh b/src/setup.zsh new file mode 100644 index 0000000..c74489f --- /dev/null +++ b/src/setup.zsh @@ -0,0 +1,10 @@ + +#--------------------------------------------------------------------# +# 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 54f5bb8..49af555 100644 --- a/src/start.zsh +++ b/src/start.zsh @@ -5,9 +5,16 @@ # Start the autosuggestion widgets _zsh_autosuggest_start() { + add-zsh-hook -d precmd _zsh_autosuggest_start + + _zsh_autosuggest_feature_detect _zsh_autosuggest_check_deprecated_config _zsh_autosuggest_bind_widgets + + if [ -n "${ZSH_AUTOSUGGEST_USE_ASYNC+x}" ]; then + _zsh_autosuggest_async_start + fi } -autoload -Uz add-zsh-hook +# Start the autosuggestion widgets on the next precmd add-zsh-hook precmd _zsh_autosuggest_start diff --git a/src/strategies/default.zsh b/src/strategies/default.zsh index 29333f5..60c0494 100644 --- a/src/strategies/default.zsh +++ b/src/strategies/default.zsh @@ -7,5 +7,19 @@ # _zsh_autosuggest_strategy_default() { - fc -lnrm "$1*" 1 2>/dev/null | head -n 1 + # Reset options to defaults and enable LOCAL_OPTIONS + emulate -L zsh + + # Enable globbing flags so that we can use (#m) + setopt EXTENDED_GLOB + + # Escape backslashes and all of the glob operators so we can use + # this string as a pattern to search the $history associative array. + # - (#m) globbing flag enables setting references for match data + local prefix="${1//(#m)[\\*?[\]<>()|^~#]/\\$MATCH}" + + # Get the history items that match + # - (r) subscript flag makes the pattern match on values + suggestion="${history[(r)$prefix*]}" + } diff --git a/src/strategies/match_prev_cmd.zsh b/src/strategies/match_prev_cmd.zsh index bf8bdd9..ee26346 100644 --- a/src/strategies/match_prev_cmd.zsh +++ b/src/strategies/match_prev_cmd.zsh @@ -21,7 +21,7 @@ # `HIST_EXPIRE_DUPS_FIRST`. _zsh_autosuggest_strategy_match_prev_cmd() { - local prefix="$1" + local prefix="${1//(#m)[\\()\[\]|*?~]/\\$MATCH}" # Get all history event numbers that correspond to history # entries that match pattern $prefix* @@ -47,6 +47,6 @@ _zsh_autosuggest_strategy_match_prev_cmd() { fi done - # Echo the matched history entry - echo -E "$history[$histkey]" + # Give back the matched history entry + suggestion="$history[$histkey]" } diff --git a/src/suggestion.zsh b/src/suggestion.zsh deleted file mode 100644 index 31a9f76..0000000 --- a/src/suggestion.zsh +++ /dev/null @@ -1,21 +0,0 @@ - -#--------------------------------------------------------------------# -# Suggestion # -#--------------------------------------------------------------------# - -# Delegate to the selected strategy to determine a suggestion -_zsh_autosuggest_suggestion() { - local escaped_prefix="$(_zsh_autosuggest_escape_command "$1")" - local strategy_function="_zsh_autosuggest_strategy_$ZSH_AUTOSUGGEST_STRATEGY" - - if [ -n "$functions[$strategy_function]" ]; then - echo -E "$($strategy_function "$escaped_prefix")" - fi -} - -_zsh_autosuggest_escape_command() { - setopt localoptions EXTENDED_GLOB - - # Escape special chars in the string (requires EXTENDED_GLOB) - echo -E "${1//(#m)[\\()\[\]|*?~]/\\$MATCH}" -} diff --git a/src/util.zsh b/src/util.zsh new file mode 100644 index 0000000..1f55d36 --- /dev/null +++ b/src/util.zsh @@ -0,0 +1,11 @@ + +#--------------------------------------------------------------------# +# Utility Functions # +#--------------------------------------------------------------------# + +_zsh_autosuggest_escape_command() { + setopt localoptions EXTENDED_GLOB + + # Escape special chars in the string (requires EXTENDED_GLOB) + echo -E "${1//(#m)[\"\'\\()\[\]|*?~]/\\$MATCH}" +} diff --git a/src/widgets.zsh b/src/widgets.zsh index 57f378e..a0a59d5 100644 --- a/src/widgets.zsh +++ b/src/widgets.zsh @@ -19,13 +19,24 @@ _zsh_autosuggest_modify() { local orig_buffer="$BUFFER" local orig_postdisplay="$POSTDISPLAY" - # Clear suggestion while original widget runs + # Clear suggestion while waiting for next one unset POSTDISPLAY # Original widget may modify the buffer _zsh_autosuggest_invoke_original_widget $@ retval=$? + # Optimize if manually typing in the suggestion + if [ $#BUFFER -gt $#orig_buffer ]; then + local added=${BUFFER#$orig_buffer} + + # If the string added matches the beginning of the postdisplay + if [ "$added" = "${orig_postdisplay:0:$#added}" ]; then + POSTDISPLAY="${orig_postdisplay:$#added}" + return $retval + fi + fi + # Don't fetch a new suggestion if the buffer hasn't changed if [ "$BUFFER" = "$orig_buffer" ]; then POSTDISPLAY="$orig_postdisplay" @@ -33,21 +44,37 @@ _zsh_autosuggest_modify() { fi # Get a new suggestion if the buffer is not empty after modification - local suggestion if [ $#BUFFER -gt 0 ]; then - if [ -z "$ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE" -o $#BUFFER -lt "$ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE" ]; then - suggestion="$(_zsh_autosuggest_suggestion "$BUFFER")" + if [ -z "$ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE" -o $#BUFFER -le "$ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE" ]; then + _zsh_autosuggest_fetch fi fi - # Add the suggestion to the POSTDISPLAY - if [ -n "$suggestion" ]; then - POSTDISPLAY="${suggestion#$BUFFER}" - fi - return $retval } +# Fetch a new suggestion based on what's currently in the buffer +_zsh_autosuggest_fetch() { + if zpty -t "$ZSH_AUTOSUGGEST_ASYNC_PTY_NAME" &>/dev/null; then + _zsh_autosuggest_async_request "$BUFFER" + else + local suggestion + _zsh_autosuggest_strategy_$ZSH_AUTOSUGGEST_STRATEGY "$BUFFER" + _zsh_autosuggest_suggest "$suggestion" + fi +} + +# Offer a suggestion +_zsh_autosuggest_suggest() { + local suggestion="$1" + + if [ -n "$suggestion" ] && [ $#BUFFER -gt 0 ]; then + POSTDISPLAY="${suggestion#$BUFFER}" + else + unset POSTDISPLAY + fi +} + # Accept the entire suggestion _zsh_autosuggest_accept() { local -i max_cursor_pos=$#BUFFER @@ -115,7 +142,7 @@ _zsh_autosuggest_partial_accept() { return $retval } -for action in clear modify accept partial_accept execute; do +for action in clear modify fetch suggest accept partial_accept execute; do eval "_zsh_autosuggest_widget_$action() { local -i retval @@ -126,10 +153,14 @@ for action in clear modify accept partial_accept execute; do _zsh_autosuggest_highlight_apply + zle -R + return \$retval }" done +zle -N autosuggest-fetch _zsh_autosuggest_widget_fetch +zle -N autosuggest-suggest _zsh_autosuggest_widget_suggest zle -N autosuggest-accept _zsh_autosuggest_widget_accept zle -N autosuggest-clear _zsh_autosuggest_widget_clear zle -N autosuggest-execute _zsh_autosuggest_widget_execute diff --git a/test/bind_test.zsh b/test/bind_test.zsh deleted file mode 100644 index f73ea7f..0000000 --- a/test/bind_test.zsh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env zsh - -source "${0:a:h}/test_helper.zsh" - -oneTimeSetUp() { - source_autosuggestions -} - -testInvokeOriginalWidgetDefined() { - stub_and_eval \ - zle \ - 'return 1' - - _zsh_autosuggest_invoke_original_widget 'self-insert' - - assertEquals \ - '1' \ - "$?" - - assertTrue \ - 'zle was not invoked' \ - 'stub_called zle' - - restore zle -} - -testInvokeOriginalWidgetUndefined() { - stub_and_eval \ - zle \ - 'return 1' - - _zsh_autosuggest_invoke_original_widget 'some-undefined-widget' - - assertEquals \ - '0' \ - "$?" - - assertFalse \ - 'zle was invoked' \ - 'stub_called zle' - - restore zle -} - -run_tests "$0" diff --git a/test/highlight_test.zsh b/test/highlight_test.zsh deleted file mode 100644 index 7268af8..0000000 --- a/test/highlight_test.zsh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env zsh - -source "${0:a:h}/test_helper.zsh" - -oneTimeSetUp() { - source_autosuggestions -} - -testHighlightDefaultStyle() { - assertEquals \ - 'fg=8' \ - "$ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE" -} - -testHighlightApplyWithSuggestion() { - local orig_style=ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE - ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE='fg=4' - - BUFFER='ec' - POSTDISPLAY='ho hello' - region_highlight=('0 2 fg=1') - - _zsh_autosuggest_highlight_apply - - assertEquals \ - 'highlight did not use correct style' \ - "0 2 fg=1 2 10 $ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE" \ - "$region_highlight" - - assertEquals \ - 'higlight was not saved to be removed later' \ - "2 10 $ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE" \ - "$_ZSH_AUTOSUGGEST_LAST_HIGHLIGHT" - - ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE=orig_style -} - -testHighlightApplyWithoutSuggestion() { - BUFFER='echo hello' - POSTDISPLAY='' - region_highlight=('0 4 fg=1') - - _zsh_autosuggest_highlight_apply - - assertEquals \ - 'region_highlight was modified' \ - '0 4 fg=1' \ - "$region_highlight" - - assertNull \ - 'last highlight region was not cleared' \ - "$_ZSH_AUTOSUGGEST_LAST_HIGHLIGHT" -} - -testHighlightReset() { - BUFFER='ec' - POSTDISPLAY='ho hello' - region_highlight=('0 1 fg=1' '2 10 fg=8' '1 2 fg=1') - _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT='2 10 fg=8' - - _zsh_autosuggest_highlight_reset - - assertEquals \ - 'last highlight region was not removed' \ - '0 1 fg=1 1 2 fg=1' \ - "$region_highlight" - - assertNull \ - 'last highlight variable was not cleared' \ - "$_ZSH_AUTOSUGGEST_LAST_HIGHLIGHT" -} - -run_tests "$0" diff --git a/test/strategies/default_test.zsh b/test/strategies/default_test.zsh deleted file mode 100755 index f5200e6..0000000 --- a/test/strategies/default_test.zsh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env zsh - -source "${0:a:h}/../test_helper.zsh" - -oneTimeSetUp() { - source_autosuggestions -} - -testNoMatch() { - set_history <<-'EOF' - ls foo - ls bar - EOF - - assertSuggestion \ - 'foo' \ - '' - - assertSuggestion \ - 'ls q' \ - '' -} - -testBasicMatches() { - set_history <<-'EOF' - ls foo - ls bar - EOF - - assertSuggestion \ - 'ls f' \ - 'ls foo' - - assertSuggestion \ - 'ls b' \ - 'ls bar' -} - -testMostRecentMatch() { - set_history <<-'EOF' - ls foo - cd bar - ls baz - cd quux - EOF - - assertSuggestion \ - 'ls' \ - 'ls baz' - - assertSuggestion \ - 'cd' \ - 'cd quux' -} - -run_tests "$0" diff --git a/test/strategies/match_prev_cmd_test.zsh b/test/strategies/match_prev_cmd_test.zsh deleted file mode 100755 index bf3fc64..0000000 --- a/test/strategies/match_prev_cmd_test.zsh +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env zsh - -source "${0:a:h}/../test_helper.zsh" - -oneTimeSetUp() { - source_autosuggestions - - ZSH_AUTOSUGGEST_STRATEGY=match_prev_cmd -} - -testNoMatch() { - set_history <<-'EOF' - ls foo - ls bar - EOF - - assertSuggestion \ - 'foo' \ - '' - - assertSuggestion \ - 'ls q' \ - '' -} - -testBasicMatches() { - set_history <<-'EOF' - ls foo - ls bar - EOF - - assertSuggestion \ - 'ls f' \ - 'ls foo' - - assertSuggestion \ - 'ls b' \ - 'ls bar' -} - -testMostRecentMatch() { - set_history <<-'EOF' - ls foo - cd bar - ls baz - cd quux - EOF - - assertSuggestion \ - 'ls' \ - 'ls baz' - - assertSuggestion \ - 'cd' \ - 'cd quux' -} - -testMatchMostRecentAfterPreviousCmd() { - set_history <<-'EOF' - echo what - ls foo - ls bar - echo what - ls baz - ls quux - echo what - EOF - - assertSuggestion \ - 'ls' \ - 'ls baz' -} - -run_tests "$0" diff --git a/test/strategies_test.zsh b/test/strategies_test.zsh deleted file mode 100644 index 0a937f4..0000000 --- a/test/strategies_test.zsh +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env zsh - -source "${0:a:h}/test_helper.zsh" - -oneTimeSetUp() { - source_autosuggestions -} - -assertBackslashSuggestion() { - set_history <<-'EOF' - echo "hello\nworld" - EOF - - assertSuggestion \ - 'echo "hello\' \ - 'echo "hello\nworld"' -} - -assertDoubleBackslashSuggestion() { - set_history <<-'EOF' - echo "\\" - EOF - - assertSuggestion \ - 'echo "\\' \ - 'echo "\\"' -} - -assertTildeSuggestion() { - set_history <<-'EOF' - cd ~/something - EOF - - assertSuggestion \ - 'cd' \ - 'cd ~/something' - - assertSuggestion \ - 'cd ~' \ - 'cd ~/something' - - assertSuggestion \ - 'cd ~/s' \ - 'cd ~/something' -} - -assertTildeSuggestionWithExtendedGlob() { - setopt local_options extended_glob - - assertTildeSuggestion -} - -assertParenthesesSuggestion() { - set_history <<-'EOF' - echo "$(ls foo)" - EOF - - assertSuggestion \ - 'echo "$(' \ - 'echo "$(ls foo)"' -} - -assertSquareBracketsSuggestion() { - set_history <<-'EOF' - echo "$history[123]" - EOF - - assertSuggestion \ - 'echo "$history[' \ - 'echo "$history[123]"' -} - -assertHashSuggestion() { - set_history <<-'EOF' - echo "#yolo" - EOF - - assertSuggestion \ - 'echo "#' \ - 'echo "#yolo"' -} - -testSpecialCharsForAllStrategies() { - local strategies - strategies=( - "default" - "match_prev_cmd" - ) - - for s in $strategies; do - ZSH_AUTOSUGGEST_STRATEGY="$s" - - assertBackslashSuggestion - assertDoubleBackslashSuggestion - assertTildeSuggestion - assertTildeSuggestionWithExtendedGlob - assertParenthesesSuggestion - assertSquareBracketsSuggestion - done -} - -run_tests "$0" diff --git a/test/suggestion_test.zsh b/test/suggestion_test.zsh deleted file mode 100644 index fc6330d..0000000 --- a/test/suggestion_test.zsh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env zsh - -source "${0:a:h}/test_helper.zsh" - -oneTimeSetUp() { - source_autosuggestions -} - -testEscapeCommand() { - assertEquals \ - 'Did not escape single backslash' \ - '\\' \ - "$(_zsh_autosuggest_escape_command '\')" - - assertEquals \ - 'Did not escape two backslashes' \ - '\\\\' \ - "$(_zsh_autosuggest_escape_command '\\')" - - assertEquals \ - 'Did not escape parentheses' \ - '\(\)' \ - "$(_zsh_autosuggest_escape_command '()')" - - assertEquals \ - 'Did not escape square brackets' \ - '\[\]' \ - "$(_zsh_autosuggest_escape_command '[]')" - - assertEquals \ - 'Did not escape pipe' \ - '\|' \ - "$(_zsh_autosuggest_escape_command '|')" - - assertEquals \ - 'Did not escape star' \ - '\*' \ - "$(_zsh_autosuggest_escape_command '*')" - - assertEquals \ - 'Did not escape question mark' \ - '\?' \ - "$(_zsh_autosuggest_escape_command '?')" -} - -run_tests "$0" diff --git a/test/test_helper.zsh b/test/test_helper.zsh deleted file mode 100644 index 7e7dbc0..0000000 --- a/test/test_helper.zsh +++ /dev/null @@ -1,60 +0,0 @@ -DIR="${0:a:h}" -ROOT_DIR="$DIR/.." -VENDOR_DIR="$ROOT_DIR/vendor" - -# Use stub.sh for stubbing/mocking -source "$VENDOR_DIR/stub.sh/stub.sh" - -#--------------------------------------------------------------------# -# Helper Functions # -#--------------------------------------------------------------------# - -# Source the autosuggestions plugin file -source_autosuggestions() { - source "$ROOT_DIR/zsh-autosuggestions.zsh" -} - -# Set history list from stdin -set_history() { - # Make a tmp file in shunit's tmp dir - local tmp=$(mktemp "$SHUNIT_TMPDIR/hist.XXX") - - # Write from stdin to the tmp file - > "$tmp" - - # Write an extra line to simulate history active mode - # See https://github.com/zsh-users/zsh/blob/ca3bc0d95d7deab4f5381f12b15047de748c0814/Src/hist.c#L69-L82 - echo >> "$tmp" - - # Clear history and re-read from the tmp file - fc -P; fc -p; fc -R "$tmp" - - rm "$tmp" -} - -# Should be called at the bottom of every test suite file -# Pass in the name of the test script ($0) for shunit -run_tests() { - local test_script="$1" - shift - - # Required for shunit to work with zsh - setopt localoptions shwordsplit - SHUNIT_PARENT="$test_script" - - source "$VENDOR_DIR/shunit2/2.1.6/src/shunit2" -} - -#--------------------------------------------------------------------# -# Custom Assertions # -#--------------------------------------------------------------------# - -assertSuggestion() { - local prefix="$1" - local expected_suggestion="$2" - - assertEquals \ - "Did not get correct suggestion for prefix:<$prefix> using strategy <$ZSH_AUTOSUGGEST_STRATEGY>" \ - "$expected_suggestion" \ - "$(_zsh_autosuggest_suggestion "$prefix")" -} diff --git a/test/widgets/accept_test.zsh b/test/widgets/accept_test.zsh deleted file mode 100644 index f126091..0000000 --- a/test/widgets/accept_test.zsh +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env zsh - -source "${0:a:h}/../test_helper.zsh" - -oneTimeSetUp() { - source_autosuggestions -} - -setUp() { - BUFFER='' - POSTDISPLAY='' - CURSOR=0 - KEYMAP='main' -} - -tearDown() { - restore _zsh_autosuggest_invoke_original_widget -} - -testCursorAtEnd() { - BUFFER='echo' - POSTDISPLAY=' hello' - CURSOR=4 - - stub _zsh_autosuggest_invoke_original_widget - - _zsh_autosuggest_accept 'original-widget' - - assertTrue \ - 'original widget not invoked' \ - 'stub_called _zsh_autosuggest_invoke_original_widget' - - assertEquals \ - 'BUFFER was not modified' \ - 'echo hello' \ - "$BUFFER" - - assertEquals \ - 'POSTDISPLAY was not cleared' \ - '' \ - "$POSTDISPLAY" -} - -testCursorNotAtEnd() { - BUFFER='echo' - POSTDISPLAY=' hello' - CURSOR=2 - - stub _zsh_autosuggest_invoke_original_widget - - _zsh_autosuggest_accept 'original-widget' - - assertTrue \ - 'original widget not invoked' \ - 'stub_called _zsh_autosuggest_invoke_original_widget' - - assertEquals \ - 'BUFFER was modified' \ - 'echo' \ - "$BUFFER" - - assertEquals \ - 'POSTDISPLAY was modified' \ - ' hello' \ - "$POSTDISPLAY" -} - -testViCursorAtEnd() { - BUFFER='echo' - POSTDISPLAY=' hello' - CURSOR=3 - KEYMAP='vicmd' - - stub _zsh_autosuggest_invoke_original_widget - - _zsh_autosuggest_accept 'original-widget' - - assertTrue \ - 'original widget not invoked' \ - 'stub_called _zsh_autosuggest_invoke_original_widget' - - assertEquals \ - 'BUFFER was not modified' \ - 'echo hello' \ - "$BUFFER" - - assertEquals \ - 'POSTDISPLAY was not cleared' \ - '' \ - "$POSTDISPLAY" -} - -testViCursorNotAtEnd() { - BUFFER='echo' - POSTDISPLAY=' hello' - CURSOR=2 - KEYMAP='vicmd' - - stub _zsh_autosuggest_invoke_original_widget - - _zsh_autosuggest_accept 'original-widget' - - assertTrue \ - 'original widget not invoked' \ - 'stub_called _zsh_autosuggest_invoke_original_widget' - - assertEquals \ - 'BUFFER was modified' \ - 'echo' \ - "$BUFFER" - - assertEquals \ - 'POSTDISPLAY was modified' \ - ' hello' \ - "$POSTDISPLAY" -} - -testRetval() { - stub_and_eval \ - _zsh_autosuggest_invoke_original_widget \ - 'return 1' - - _zsh_autosuggest_widget_accept 'original-widget' - - assertEquals \ - 'Did not return correct value from original widget' \ - '1' \ - "$?" -} - -testWidget() { - stub _zsh_autosuggest_highlight_reset - stub _zsh_autosuggest_accept - stub _zsh_autosuggest_highlight_apply - - # Call the function pointed to by the widget since we can't call - # the widget itself when zle is not active - ${widgets[autosuggest-accept]#*:} 'original-widget' - - assertTrue \ - 'autosuggest-accept widget does not exist' \ - 'zle -l autosuggest-accept' - - assertTrue \ - 'highlight_reset was not called' \ - 'stub_called _zsh_autosuggest_highlight_reset' - - assertTrue \ - 'widget function was not called' \ - 'stub_called _zsh_autosuggest_accept' - - assertTrue \ - 'highlight_apply was not called' \ - 'stub_called _zsh_autosuggest_highlight_apply' - - restore _zsh_autosuggest_highlight_reset - restore _zsh_autosuggest_accept - restore _zsh_autosuggest_highlight_apply -} - -run_tests "$0" diff --git a/test/widgets/clear_test.zsh b/test/widgets/clear_test.zsh deleted file mode 100644 index f0500c5..0000000 --- a/test/widgets/clear_test.zsh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env zsh - -source "${0:a:h}/../test_helper.zsh" - -oneTimeSetUp() { - source_autosuggestions -} - -setUp() { - BUFFER='' - POSTDISPLAY='' -} - -tearDown() { - restore _zsh_autosuggest_invoke_original_widget -} - -testClear() { - BUFFER='ec' - POSTDISPLAY='ho hello' - - _zsh_autosuggest_clear 'original-widget' - - assertEquals \ - 'BUFFER was modified' \ - 'ec' \ - "$BUFFER" - - assertNull \ - 'POSTDISPLAY was not cleared' \ - "$POSTDISPLAY" -} - -testRetval() { - stub_and_eval \ - _zsh_autosuggest_invoke_original_widget \ - 'return 1' - - _zsh_autosuggest_widget_clear 'original-widget' - - assertEquals \ - 'Did not return correct value from original widget' \ - '1' \ - "$?" -} - -testWidget() { - stub _zsh_autosuggest_highlight_reset - stub _zsh_autosuggest_clear - stub _zsh_autosuggest_highlight_apply - - # Call the function pointed to by the widget since we can't call - # the widget itself when zle is not active - ${widgets[autosuggest-clear]#*:} 'original-widget' - - assertTrue \ - 'autosuggest-clear widget does not exist' \ - 'zle -l autosuggest-clear' - - assertTrue \ - 'highlight_reset was not called' \ - 'stub_called _zsh_autosuggest_highlight_reset' - - assertTrue \ - 'widget function was not called' \ - 'stub_called _zsh_autosuggest_clear' - - assertTrue \ - 'highlight_apply was not called' \ - 'stub_called _zsh_autosuggest_highlight_apply' - - restore _zsh_autosuggest_highlight_reset - restore _zsh_autosuggest_clear - restore _zsh_autosuggest_highlight_apply -} - -run_tests "$0" diff --git a/test/widgets/execute_test.zsh b/test/widgets/execute_test.zsh deleted file mode 100644 index cb346db..0000000 --- a/test/widgets/execute_test.zsh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env zsh - -source "${0:a:h}/../test_helper.zsh" - -oneTimeSetUp() { - source_autosuggestions -} - -tearDown() { - restore _zsh_autosuggest_invoke_original_widget -} - -testRetval() { - stub_and_eval \ - _zsh_autosuggest_invoke_original_widget \ - 'return 1' - - _zsh_autosuggest_widget_execute 'original-widget' - - assertEquals \ - 'Did not return correct value from original widget' \ - '1' \ - "$?" -} - -run_tests "$0" diff --git a/test/widgets/modify_test.zsh b/test/widgets/modify_test.zsh deleted file mode 100644 index 7ed6346..0000000 --- a/test/widgets/modify_test.zsh +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env zsh - -source "${0:a:h}/../test_helper.zsh" - -oneTimeSetUp() { - source_autosuggestions -} - -setUp() { - BUFFER='' - POSTDISPLAY='' - ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE='' -} - -tearDown() { - restore _zsh_autosuggest_invoke_original_widget - restore _zsh_autosuggest_suggestion -} - -testModify() { - stub_and_eval \ - _zsh_autosuggest_invoke_original_widget \ - 'BUFFER+="e"' - - stub_and_echo \ - _zsh_autosuggest_suggestion \ - 'echo hello' - - _zsh_autosuggest_modify 'original-widget' - - assertTrue \ - 'original widget not invoked' \ - 'stub_called _zsh_autosuggest_invoke_original_widget' - - assertEquals \ - 'BUFFER was not modified' \ - 'e' \ - "$BUFFER" - - assertEquals \ - 'POSTDISPLAY does not contain suggestion' \ - 'cho hello' \ - "$POSTDISPLAY" -} - -testModifyBufferTooLarge() { - - ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE='20' - - stub_and_eval \ - _zsh_autosuggest_invoke_original_widget \ - 'BUFFER+="012345678901234567890"' - - stub_and_echo \ - _zsh_autosuggest_suggestion \ - '012345678901234567890123456789' - - _zsh_autosuggest_modify 'original-widget' - - assertTrue \ - 'original widget not invoked' \ - 'stub_called _zsh_autosuggest_invoke_original_widget' - - assertEquals \ - 'BUFFER was not modified' \ - '012345678901234567890' \ - "$BUFFER" - - assertEquals \ - 'POSTDISPLAY does not contain suggestion' \ - '' \ - "$POSTDISPLAY" -} - -testRetval() { - stub_and_eval \ - _zsh_autosuggest_invoke_original_widget \ - 'return 1' - - _zsh_autosuggest_widget_modify 'original-widget' - - assertEquals \ - 'Did not return correct value from original widget' \ - '1' \ - "$?" -} - -run_tests "$0" diff --git a/test/widgets/partial_accept_test.zsh b/test/widgets/partial_accept_test.zsh deleted file mode 100644 index 60c78a6..0000000 --- a/test/widgets/partial_accept_test.zsh +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env zsh - -source "${0:a:h}/../test_helper.zsh" - -oneTimeSetUp() { - source_autosuggestions -} - -setUp() { - BUFFER='' - POSTDISPLAY='' - CURSOR=0 -} - -tearDown() { - restore _zsh_autosuggest_invoke_original_widget -} - -testCursorMovesOutOfBuffer() { - BUFFER='ec' - POSTDISPLAY='ho hello' - CURSOR=1 - - stub_and_eval \ - _zsh_autosuggest_invoke_original_widget \ - 'CURSOR=5; LBUFFER="echo "; RBUFFER="hello"' - - _zsh_autosuggest_partial_accept 'original-widget' - - assertTrue \ - 'original widget not invoked' \ - 'stub_called _zsh_autosuggest_invoke_original_widget' - - assertEquals \ - 'BUFFER was not modified correctly' \ - 'echo ' \ - "$BUFFER" - - assertEquals \ - 'POSTDISPLAY was not modified correctly' \ - 'hello' \ - "$POSTDISPLAY" -} - -testCursorStaysInBuffer() { - BUFFER='echo hello' - POSTDISPLAY=' world' - CURSOR=1 - - stub_and_eval \ - _zsh_autosuggest_invoke_original_widget \ - 'CURSOR=5; LBUFFER="echo "; RBUFFER="hello"' - - _zsh_autosuggest_partial_accept 'original-widget' - - assertTrue \ - 'original widget not invoked' \ - 'stub_called _zsh_autosuggest_invoke_original_widget' - - assertEquals \ - 'BUFFER was modified' \ - 'echo hello' \ - "$BUFFER" - - assertEquals \ - 'POSTDISPLAY was modified' \ - ' world' \ - "$POSTDISPLAY" -} - -testRetval() { - stub_and_eval \ - _zsh_autosuggest_invoke_original_widget \ - 'return 1' - - _zsh_autosuggest_widget_partial_accept 'original-widget' - - assertEquals \ - 'Did not return correct value from original widget' \ - '1' \ - "$?" -} - -run_tests "$0" diff --git a/vendor/shunit2 b/vendor/shunit2 deleted file mode 160000 index 46973db..0000000 --- a/vendor/shunit2 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 46973db9df87bd5fdadea74cb472a99f212a0d3a diff --git a/vendor/stub.sh b/vendor/stub.sh deleted file mode 160000 index bd6f3c4..0000000 --- a/vendor/stub.sh +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bd6f3c4666cd2a702e388e09d77b8543a1f6b672 diff --git a/zsh-autosuggestions.zsh b/zsh-autosuggestions.zsh index 3761efe..ed631c3 100644 --- a/zsh-autosuggestions.zsh +++ b/zsh-autosuggestions.zsh @@ -2,7 +2,7 @@ # https://github.com/zsh-users/zsh-autosuggestions # v0.3.3 # Copyright (c) 2013 Thiago de Arruda -# Copyright (c) 2016 Eric Freese +# Copyright (c) 2016-2017 Eric Freese # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -25,6 +25,16 @@ # 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 # #--------------------------------------------------------------------# @@ -87,6 +97,42 @@ ZSH_AUTOSUGGEST_IGNORE_WIDGETS=( # Max size of buffer to trigger autosuggestion. Leave undefined for no upper bound. ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE= +# Use asynchronous mode by default. Unset this variable to use sync mode. +ZSH_AUTOSUGGEST_USE_ASYNC= + +# Pty name for calculating autosuggestions asynchronously +ZSH_AUTOSUGGEST_ASYNC_PTY_NAME=zsh_autosuggest_pty + +#--------------------------------------------------------------------# +# Utility Functions # +#--------------------------------------------------------------------# + +_zsh_autosuggest_escape_command() { + setopt localoptions EXTENDED_GLOB + + # Escape special chars in the string (requires EXTENDED_GLOB) + echo -E "${1//(#m)[\"\'\\()\[\]|*?~]/\\$MATCH}" +} + +#--------------------------------------------------------------------# +# Feature Detection # +#--------------------------------------------------------------------# + +_zsh_autosuggest_feature_detect() { + typeset -g _ZSH_AUTOSUGGEST_ZPTY_RETURNS_FD + typeset -h REPLY + + zpty $ZSH_AUTOSUGGEST_ASYNC_PTY_NAME '{ zshexit() { kill -KILL $$; sleep 1 } }' + + if (( REPLY )); then + _ZSH_AUTOSUGGEST_ZPTY_RETURNS_FD=1 + else + _ZSH_AUTOSUGGEST_ZPTY_RETURNS_FD=0 + fi + + zpty -d $ZSH_AUTOSUGGEST_ASYNC_PTY_NAME +} + #--------------------------------------------------------------------# # Handle Deprecated Variables/Widgets # #--------------------------------------------------------------------# @@ -260,13 +306,24 @@ _zsh_autosuggest_modify() { local orig_buffer="$BUFFER" local orig_postdisplay="$POSTDISPLAY" - # Clear suggestion while original widget runs + # Clear suggestion while waiting for next one unset POSTDISPLAY # Original widget may modify the buffer _zsh_autosuggest_invoke_original_widget $@ retval=$? + # Optimize if manually typing in the suggestion + if [ $#BUFFER -gt $#orig_buffer ]; then + local added=${BUFFER#$orig_buffer} + + # If the string added matches the beginning of the postdisplay + if [ "$added" = "${orig_postdisplay:0:$#added}" ]; then + POSTDISPLAY="${orig_postdisplay:$#added}" + return $retval + fi + fi + # Don't fetch a new suggestion if the buffer hasn't changed if [ "$BUFFER" = "$orig_buffer" ]; then POSTDISPLAY="$orig_postdisplay" @@ -274,21 +331,37 @@ _zsh_autosuggest_modify() { fi # Get a new suggestion if the buffer is not empty after modification - local suggestion if [ $#BUFFER -gt 0 ]; then - if [ -z "$ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE" -o $#BUFFER -lt "$ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE" ]; then - suggestion="$(_zsh_autosuggest_suggestion "$BUFFER")" + if [ -z "$ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE" -o $#BUFFER -le "$ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE" ]; then + _zsh_autosuggest_fetch fi fi - # Add the suggestion to the POSTDISPLAY - if [ -n "$suggestion" ]; then - POSTDISPLAY="${suggestion#$BUFFER}" - fi - return $retval } +# Fetch a new suggestion based on what's currently in the buffer +_zsh_autosuggest_fetch() { + if zpty -t "$ZSH_AUTOSUGGEST_ASYNC_PTY_NAME" &>/dev/null; then + _zsh_autosuggest_async_request "$BUFFER" + else + local suggestion + _zsh_autosuggest_strategy_$ZSH_AUTOSUGGEST_STRATEGY "$BUFFER" + _zsh_autosuggest_suggest "$suggestion" + fi +} + +# Offer a suggestion +_zsh_autosuggest_suggest() { + local suggestion="$1" + + if [ -n "$suggestion" ] && [ $#BUFFER -gt 0 ]; then + POSTDISPLAY="${suggestion#$BUFFER}" + else + unset POSTDISPLAY + fi +} + # Accept the entire suggestion _zsh_autosuggest_accept() { local -i max_cursor_pos=$#BUFFER @@ -356,7 +429,7 @@ _zsh_autosuggest_partial_accept() { return $retval } -for action in clear modify accept partial_accept execute; do +for action in clear modify fetch suggest accept partial_accept execute; do eval "_zsh_autosuggest_widget_$action() { local -i retval @@ -367,35 +440,18 @@ for action in clear modify accept partial_accept execute; do _zsh_autosuggest_highlight_apply + zle -R + return \$retval }" done +zle -N autosuggest-fetch _zsh_autosuggest_widget_fetch +zle -N autosuggest-suggest _zsh_autosuggest_widget_suggest zle -N autosuggest-accept _zsh_autosuggest_widget_accept zle -N autosuggest-clear _zsh_autosuggest_widget_clear zle -N autosuggest-execute _zsh_autosuggest_widget_execute -#--------------------------------------------------------------------# -# Suggestion # -#--------------------------------------------------------------------# - -# Delegate to the selected strategy to determine a suggestion -_zsh_autosuggest_suggestion() { - local escaped_prefix="$(_zsh_autosuggest_escape_command "$1")" - local strategy_function="_zsh_autosuggest_strategy_$ZSH_AUTOSUGGEST_STRATEGY" - - if [ -n "$functions[$strategy_function]" ]; then - echo -E "$($strategy_function "$escaped_prefix")" - fi -} - -_zsh_autosuggest_escape_command() { - setopt localoptions EXTENDED_GLOB - - # Escape special chars in the string (requires EXTENDED_GLOB) - echo -E "${1//(#m)[\\()\[\]|*?~]/\\$MATCH}" -} - #--------------------------------------------------------------------# # Default Suggestion Strategy # #--------------------------------------------------------------------# @@ -404,7 +460,21 @@ _zsh_autosuggest_escape_command() { # _zsh_autosuggest_strategy_default() { - fc -lnrm "$1*" 1 2>/dev/null | head -n 1 + # Reset options to defaults and enable LOCAL_OPTIONS + emulate -L zsh + + # Enable globbing flags so that we can use (#m) + setopt EXTENDED_GLOB + + # Escape backslashes and all of the glob operators so we can use + # this string as a pattern to search the $history associative array. + # - (#m) globbing flag enables setting references for match data + local prefix="${1//(#m)[\\*?[\]<>()|^~#]/\\$MATCH}" + + # Get the history items that match + # - (r) subscript flag makes the pattern match on values + suggestion="${history[(r)$prefix*]}" + } #--------------------------------------------------------------------# @@ -429,7 +499,7 @@ _zsh_autosuggest_strategy_default() { # `HIST_EXPIRE_DUPS_FIRST`. _zsh_autosuggest_strategy_match_prev_cmd() { - local prefix="$1" + local prefix="${1//(#m)[\\()\[\]|*?~]/\\$MATCH}" # Get all history event numbers that correspond to history # entries that match pattern $prefix* @@ -455,8 +525,117 @@ _zsh_autosuggest_strategy_match_prev_cmd() { fi done - # Echo the matched history entry - echo -E "$history[$histkey]" + # Give back the matched history entry + suggestion="$history[$histkey]" +} + +#--------------------------------------------------------------------# +# Async # +#--------------------------------------------------------------------# + +# Zpty process is spawned running this function +_zsh_autosuggest_async_server() { + emulate -R zsh + + # 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 + } + + # Output only newlines (not carriage return + newline) + stty -onlcr + + + # Silence any error messages + exec 2>/dev/null + + local strategy=$1 + local last_pid + + while IFS='' read -r -d $'\0' query; do + # Kill last bg process + kill -KILL $last_pid &>/dev/null + + # Run suggestion search in the background + ( + local suggestion + _zsh_autosuggest_strategy_$ZSH_AUTOSUGGEST_STRATEGY "$query" + echo -n -E "$suggestion"$'\0' + ) & + + last_pid=$! + done +} + +_zsh_autosuggest_async_request() { + # Write the query to the zpty process to fetch a suggestion + zpty -w -n $ZSH_AUTOSUGGEST_ASYNC_PTY_NAME "${1}"$'\0' +} + +# Called when new data is ready to be read from the pty +# First arg will be fd ready for reading +# Second arg will be passed in case of error +_zsh_autosuggest_async_response() { + setopt LOCAL_OPTIONS EXTENDED_GLOB + + local suggestion + + zpty -rt $ZSH_AUTOSUGGEST_ASYNC_PTY_NAME suggestion '*'$'\0' 2>/dev/null + zle autosuggest-suggest "${suggestion%%$'\0'##}" +} + +_zsh_autosuggest_async_pty_create() { + # With newer versions of zsh, REPLY stores the fd to read from + typeset -h REPLY + + # If we won't get a fd back from zpty, try to guess it + if [ $_ZSH_AUTOSUGGEST_ZPTY_RETURNS_FD -eq 0 ]; then + integer -l zptyfd + exec {zptyfd}>&1 # Open a new file descriptor (above 10). + exec {zptyfd}>&- # Close it so it's free to be used by zpty. + fi + + # Fork a zpty process running the server function + zpty -b $ZSH_AUTOSUGGEST_ASYNC_PTY_NAME "_zsh_autosuggest_async_server _zsh_autosuggest_strategy_$ZSH_AUTOSUGGEST_STRATEGY" + + # Store the fd so we can remove the handler later + if (( REPLY )); then + _ZSH_AUTOSUGGEST_PTY_FD=$REPLY + else + _ZSH_AUTOSUGGEST_PTY_FD=$zptyfd + fi + + # Set up input handler from the zpty + zle -F $_ZSH_AUTOSUGGEST_PTY_FD _zsh_autosuggest_async_response +} + +_zsh_autosuggest_async_pty_destroy() { + if [ -n "$_ZSH_AUTOSUGGEST_PTY_FD" ]; then + # Remove the input handler + zle -F $_ZSH_AUTOSUGGEST_PTY_FD + + # Destroy the zpty + zpty -d $ZSH_AUTOSUGGEST_ASYNC_PTY_NAME &>/dev/null + fi +} + +_zsh_autosuggest_async_pty_recreate() { + _zsh_autosuggest_async_pty_destroy + _zsh_autosuggest_async_pty_create +} + +_zsh_autosuggest_async_start() { + typeset -g _ZSH_AUTOSUGGEST_PTY_FD + + _zsh_autosuggest_async_pty_create + + # We recreate the pty to get a fresh list of history events + add-zsh-hook precmd _zsh_autosuggest_async_pty_recreate } #--------------------------------------------------------------------# @@ -465,9 +644,16 @@ _zsh_autosuggest_strategy_match_prev_cmd() { # Start the autosuggestion widgets _zsh_autosuggest_start() { + add-zsh-hook -d precmd _zsh_autosuggest_start + + _zsh_autosuggest_feature_detect _zsh_autosuggest_check_deprecated_config _zsh_autosuggest_bind_widgets + + if [ -n "${ZSH_AUTOSUGGEST_USE_ASYNC+x}" ]; then + _zsh_autosuggest_async_start + fi } -autoload -Uz add-zsh-hook +# Start the autosuggestion widgets on the next precmd add-zsh-hook precmd _zsh_autosuggest_start