zsh-autosuggestions/spec/highlight_spec.rb
Daniel Portales a98dd4abcf Fix region_highlight entries leaking across accept/edit cycles
Before this change, the highlight module tracked only the most recent
plugin-owned entry in a scalar (_ZSH_AUTOSUGGEST_LAST_HIGHLIGHT) and
relied on a parameter-expansion subtraction to remove it:

    region_highlight=("${(@)region_highlight:#$_ZSH_AUTOSUGGEST_LAST_HIGHLIGHT}")

That has two failure modes:

1. The tracked string is expanded as a zsh glob pattern. If the user's
   ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE contains characters like `#`
   (e.g. `fg=#RRGGBB`), the entry is never removed and orphans
   accumulate in region_highlight across redraws. See #789.

2. Only one entry can be tracked at a time. When apply is called
   repeatedly without a successful reset (which happens under fast edits
   and widget-wrapping interactions), every apply overwrites the tracked
   reference and previous entries are orphaned.

Both manifest as stale suggestion colors bleeding onto accepted text,
typically in combination with zsh-syntax-highlighting (whose entries
interleave with ours in region_highlight).

Changes:

* Track every plugin-owned entry in an array
  (_ZSH_AUTOSUGGEST_OWNED_HIGHLIGHTS).

* On zsh 5.9+, tag each entry with `memo=zsh-autosuggestions` and on
  reset strip by memo in a single pass — robust regardless of how other
  plugins manipulate region_highlight. This matches the mechanism
  zsh-syntax-highlighting has used since 0.8.0.

* On zsh < 5.9, remove owned entries by literal string comparison
  (loop, not pattern expansion) to avoid the `#`-as-glob issue.

* _ZSH_AUTOSUGGEST_LAST_HIGHLIGHT is retained and kept in sync for
  backwards compatibility with any external consumer.

Adds spec/highlight_spec.rb covering:
  - round-trip with a hex-colored style (regression for #789)
  - reset does not touch foreign region_highlight entries
  - accumulated orphans from repeated apply calls are all cleaned up

Supersedes #790 (which used `shift -p region_highlight` — incorrect
when another plugin has appended the most-recent entry).

Fixes #789, #698.
2026-04-20 07:52:36 -06:00

121 lines
4.2 KiB
Ruby

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