diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c7735..d2ff09c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## Unreleased +- Fix `region_highlight` entries leaking across accept/edit cycles, causing stale colors to bleed onto accepted text (#698, #789). Plugin-owned entries now carry a `memo=zsh-autosuggestions` tag on zsh 5.9+ and are tracked individually for older versions. + ## v0.7.1 - Clear POSTDISPLAY instead of unsetting (#634) - Always reset async file descriptor after consuming it (#630) diff --git a/spec/highlight_spec.rb b/spec/highlight_spec.rb new file mode 100644 index 0000000..7c6f99c --- /dev/null +++ b/spec/highlight_spec.rb @@ -0,0 +1,121 @@ +require 'tempfile' + +describe 'suggestion highlighting' do + # We communicate ZLE state back to the test process via a file, because + # `region_highlight` only exists inside a ZLE widget. Bind a dump widget + # so we can inspect the array at any point in the interactive flow. + let(:dump_file) { Tempfile.create(['zsh-autosuggest-highlight', '.log']).path } + + let(:options) do + [ + %(ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE='fg=red'), + ] + end + + before do + session. + run_command(%(_dump_region_highlight() { print -l -- "${region_highlight[@]}" > #{dump_file} })). + run_command('zle -N dump-region-highlight _dump_region_highlight'). + run_command('bindkey "^X^D" dump-region-highlight'). + clear_screen + end + + after do + File.delete(dump_file) if File.exist?(dump_file) + end + + def region_highlight_entries + # Dump + give zle a moment to write the file. + session.send_keys('C-x C-d') + deadline = Time.now + 2 + until Time.now > deadline + content = File.read(dump_file) rescue '' + return content.lines.map(&:chomp).reject(&:empty?) if File.exist?(dump_file) + sleep 0.05 + end + [] + end + + def plugin_owned_entries(entries) + entries.select { |e| e.include?('fg=red') || e.include?('memo=zsh-autosuggestions') } + end + + context 'when the suggestion is accepted and edited' do + it 'does not leak stale region_highlight entries' do + with_history('echo hello world') do + session.send_string('echo h') + wait_for { session.content }.to eq('echo hello world') + + # Accept full suggestion via forward-char at end of buffer + session.send_keys('End') + wait_for { session.content }.to eq('echo hello world') + + # Delete a few chars from the accepted text + 3.times { session.send_keys('BSpace') } + wait_for { session.content }.to eq('echo hello w') + + owned = plugin_owned_entries(region_highlight_entries) + expect(owned.size).to be <= 1, + "expected at most one plugin-owned region_highlight entry, got #{owned.size}: #{owned.inspect}" + end + end + end + + context 'with a hex color in ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE' do + let(:options) do + [ + %(ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE='fg=#5a548b'), + ] + end + + def plugin_owned_entries(entries) + # Hex colors contain `#`, so match by memo tag (5.9+) or the raw style. + entries.select { |e| e.include?('fg=#5a548b') || e.include?('memo=zsh-autosuggestions') } + end + + it 'still removes owned entries on reset (regression: #789)' do + with_history('echo hello world') do + session.send_string('echo h') + wait_for { session.content }.to eq('echo hello world') + + session.send_keys('End') + 3.times { session.send_keys('BSpace') } + wait_for { session.content }.to eq('echo hello w') + + owned = plugin_owned_entries(region_highlight_entries) + expect(owned.size).to be <= 1, + "expected at most one plugin-owned region_highlight entry, got #{owned.size}: #{owned.inspect}" + end + end + end + + context 'when another plugin appends to region_highlight after ours' do + let(:after_sourcing) do + -> do + # Simulate zsh-syntax-highlighting by wrapping line-pre-redraw to + # append an entry AFTER autosuggestions has already applied its own. + session. + run_command('_fake_other_plugin() { region_highlight+=("0 1 fg=green memo=fake-syntax-highlighter") }'). + run_command('zle -N _fake_other_plugin'). + run_command('autoload -Uz add-zle-hook-widget'). + run_command('add-zle-hook-widget line-pre-redraw _fake_other_plugin') + end + end + + it 'still removes only its own entries on reset' do + with_history('echo hello world') do + session.send_string('echo h') + wait_for { session.content }.to eq('echo hello world') + + session.send_keys('End') + 3.times { session.send_keys('BSpace') } + wait_for { session.content }.to eq('echo hello w') + + entries = region_highlight_entries + owned = plugin_owned_entries(entries) + expect(owned.size).to be <= 1, + "expected at most one plugin-owned entry, got: #{owned.inspect}" + end + end + end +end diff --git a/src/highlight.zsh b/src/highlight.zsh index 273c03d..03c49e3 100644 --- a/src/highlight.zsh +++ b/src/highlight.zsh @@ -3,14 +3,77 @@ # Highlighting # #--------------------------------------------------------------------# +# `is-at-least` is autoloaded in src/start.zsh, but we may be called +# before start.zsh runs (e.g. another plugin triggering us). Safe to +# autoload here too — it's idempotent. +autoload -Uz is-at-least + +# Array of every region_highlight entry this plugin has added. Using an +# array (instead of a single scalar) ensures every entry we own can be +# removed on reset even when: +# * another plugin appends to region_highlight between our apply and +# reset calls (e.g. zsh-syntax-highlighting), +# * multiple apply calls occur without an intervening successful reset +# (which can happen under rapid edits / widget wrapping edge cases). +typeset -ga _ZSH_AUTOSUGGEST_OWNED_HIGHLIGHTS + +# Returns 0 if this zsh supports the `memo=` attribute on region_highlight +# entries (zsh 5.9+). The result is cached on first call. +_zsh_autosuggest_highlight_memo_support() { + typeset -g _ZSH_AUTOSUGGEST_MEMO_SUPPORT + if [[ -z "$_ZSH_AUTOSUGGEST_MEMO_SUPPORT" ]]; then + if is-at-least 5.9; then + _ZSH_AUTOSUGGEST_MEMO_SUPPORT=1 + else + _ZSH_AUTOSUGGEST_MEMO_SUPPORT=0 + fi + fi + (( _ZSH_AUTOSUGGEST_MEMO_SUPPORT )) +} + # If there was a highlight, remove it _zsh_autosuggest_highlight_reset() { typeset -g _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT - if [[ -n "$_ZSH_AUTOSUGGEST_LAST_HIGHLIGHT" ]]; then - region_highlight=("${(@)region_highlight:#$_ZSH_AUTOSUGGEST_LAST_HIGHLIGHT}") + if (( $#_ZSH_AUTOSUGGEST_OWNED_HIGHLIGHTS == 0 )); then unset _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT + return fi + + if _zsh_autosuggest_highlight_memo_support; then + # Single pass: drop every region_highlight entry carrying our + # memo tag. Order-independent and robust against interleaving + # with other plugins' entries. + local entry + local -a kept=() + for entry in $region_highlight; do + [[ "$entry" != *memo=zsh-autosuggestions* ]] && kept+=("$entry") + done + region_highlight=("${kept[@]}") + else + # Fallback for zsh < 5.9: remove by literal string comparison. + # We intentionally do NOT use `${(@)array:#$needle}` because + # that treats $needle as a glob pattern — `#` in hex colors + # (e.g. `fg=#RRGGBB`) would be interpreted as pattern-matching + # syntax and the entry would never be removed. + # See https://github.com/zsh-users/zsh-autosuggestions/issues/789 + local owned entry + for owned in $_ZSH_AUTOSUGGEST_OWNED_HIGHLIGHTS; do + local -a kept=() + local removed=0 + for entry in $region_highlight; do + if (( ! removed )) && [[ "$entry" == "$owned" ]]; then + removed=1 + else + kept+=("$entry") + fi + done + region_highlight=("${kept[@]}") + done + fi + + _ZSH_AUTOSUGGEST_OWNED_HIGHLIGHTS=() + unset _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT } # If there's a suggestion, highlight it @@ -18,8 +81,16 @@ _zsh_autosuggest_highlight_apply() { typeset -g _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT if (( $#POSTDISPLAY )); then - typeset -g _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT="$#BUFFER $(($#BUFFER + $#POSTDISPLAY)) $ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE" - region_highlight+=("$_ZSH_AUTOSUGGEST_LAST_HIGHLIGHT") + local entry="$#BUFFER $(($#BUFFER + $#POSTDISPLAY)) $ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE" + if _zsh_autosuggest_highlight_memo_support; then + entry+=" memo=zsh-autosuggestions" + fi + region_highlight+=("$entry") + _ZSH_AUTOSUGGEST_OWNED_HIGHLIGHTS+=("$entry") + + # Retained for backwards compatibility with anything reading + # this variable externally. + _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT="$entry" else unset _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT fi diff --git a/zsh-autosuggestions.zsh b/zsh-autosuggestions.zsh index e780225..a118fd2 100644 --- a/zsh-autosuggestions.zsh +++ b/zsh-autosuggestions.zsh @@ -241,14 +241,77 @@ _zsh_autosuggest_invoke_original_widget() { # Highlighting # #--------------------------------------------------------------------# +# `is-at-least` is autoloaded in src/start.zsh, but we may be called +# before start.zsh runs (e.g. another plugin triggering us). Safe to +# autoload here too — it's idempotent. +autoload -Uz is-at-least + +# Array of every region_highlight entry this plugin has added. Using an +# array (instead of a single scalar) ensures every entry we own can be +# removed on reset even when: +# * another plugin appends to region_highlight between our apply and +# reset calls (e.g. zsh-syntax-highlighting), +# * multiple apply calls occur without an intervening successful reset +# (which can happen under rapid edits / widget wrapping edge cases). +typeset -ga _ZSH_AUTOSUGGEST_OWNED_HIGHLIGHTS + +# Returns 0 if this zsh supports the `memo=` attribute on region_highlight +# entries (zsh 5.9+). The result is cached on first call. +_zsh_autosuggest_highlight_memo_support() { + typeset -g _ZSH_AUTOSUGGEST_MEMO_SUPPORT + if [[ -z "$_ZSH_AUTOSUGGEST_MEMO_SUPPORT" ]]; then + if is-at-least 5.9; then + _ZSH_AUTOSUGGEST_MEMO_SUPPORT=1 + else + _ZSH_AUTOSUGGEST_MEMO_SUPPORT=0 + fi + fi + (( _ZSH_AUTOSUGGEST_MEMO_SUPPORT )) +} + # If there was a highlight, remove it _zsh_autosuggest_highlight_reset() { typeset -g _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT - if [[ -n "$_ZSH_AUTOSUGGEST_LAST_HIGHLIGHT" ]]; then - region_highlight=("${(@)region_highlight:#$_ZSH_AUTOSUGGEST_LAST_HIGHLIGHT}") + if (( $#_ZSH_AUTOSUGGEST_OWNED_HIGHLIGHTS == 0 )); then unset _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT + return fi + + if _zsh_autosuggest_highlight_memo_support; then + # Single pass: drop every region_highlight entry carrying our + # memo tag. Order-independent and robust against interleaving + # with other plugins' entries. + local entry + local -a kept=() + for entry in $region_highlight; do + [[ "$entry" != *memo=zsh-autosuggestions* ]] && kept+=("$entry") + done + region_highlight=("${kept[@]}") + else + # Fallback for zsh < 5.9: remove by literal string comparison. + # We intentionally do NOT use `${(@)array:#$needle}` because + # that treats $needle as a glob pattern — `#` in hex colors + # (e.g. `fg=#RRGGBB`) would be interpreted as pattern-matching + # syntax and the entry would never be removed. + # See https://github.com/zsh-users/zsh-autosuggestions/issues/789 + local owned entry + for owned in $_ZSH_AUTOSUGGEST_OWNED_HIGHLIGHTS; do + local -a kept=() + local removed=0 + for entry in $region_highlight; do + if (( ! removed )) && [[ "$entry" == "$owned" ]]; then + removed=1 + else + kept+=("$entry") + fi + done + region_highlight=("${kept[@]}") + done + fi + + _ZSH_AUTOSUGGEST_OWNED_HIGHLIGHTS=() + unset _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT } # If there's a suggestion, highlight it @@ -256,8 +319,16 @@ _zsh_autosuggest_highlight_apply() { typeset -g _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT if (( $#POSTDISPLAY )); then - typeset -g _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT="$#BUFFER $(($#BUFFER + $#POSTDISPLAY)) $ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE" - region_highlight+=("$_ZSH_AUTOSUGGEST_LAST_HIGHLIGHT") + local entry="$#BUFFER $(($#BUFFER + $#POSTDISPLAY)) $ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE" + if _zsh_autosuggest_highlight_memo_support; then + entry+=" memo=zsh-autosuggestions" + fi + region_highlight+=("$entry") + _ZSH_AUTOSUGGEST_OWNED_HIGHLIGHTS+=("$entry") + + # Retained for backwards compatibility with anything reading + # this variable externally. + _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT="$entry" else unset _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT fi