diff --git a/ChangeLog b/ChangeLog index 65f2838e..9c754177 100644 --- a/ChangeLog +++ b/ChangeLog @@ -126,6 +126,10 @@ next: carry the full trace and the "please report" preamble. (#1676) - Work around a globbing performance issue in Python 3.12 (#1696, #1694) + - Register changes the user makes while a snippet is still active but no + placeholder is being replaced are no longer reverted to the pre-snippet + registers when insert mode is left or the snippet is torn down. (#1697, + #1695) version 4.0 (10-May-2026): New features: diff --git a/pythonx/UltiSnips/vim_state.py b/pythonx/UltiSnips/vim_state.py index a10d92e9..a5290b8d 100644 --- a/pythonx/UltiSnips/vim_state.py +++ b/pythonx/UltiSnips/vim_state.py @@ -55,7 +55,14 @@ def __init__(self): self._poss = deque(maxlen=5) self._lvb = None - self._text_to_expect = "" + # Texts of the placeholders the snippet has selected since the + # register cache below was taken. Typing over a select-mode + # placeholder deletes it into @" (and @- or @1), so @" holding one + # of these means the snippet clobbered the registers and the cache + # must be restored. Anything else in @" was put there by the user, + # e.g. a `dd` after leaving the snippet with , and must win + # over the cache (#1695). + self._placeholder_texts = [] # Cache reginfo dicts for each preserved register in a Vim variable # so binary register contents (which can't always cross the @@ -64,26 +71,37 @@ def __init__(self): # snippet, cleared at teardown. vim.command("let g:_ultisnips_reg_cache = {}") - def remember_unnamed_register(self, text_to_expect): - """Cache the snippet-clobberable registers if @" doesn't already - match the previously-expected text. - - 'text_to_expect' is text we expect to be in @" on the next call - (typically the placeholder text the snippet just put there). When - @" still matches the previous expectation we know we put it there - ourselves, so we don't overwrite the original cached state. - - """ - escaped_text = self._text_to_expect.replace("'", "''") - res = int(vim_helper.eval('@" != ' + "'" + escaped_text + "'")) - if res: + def _unnamed_register_holds_placeholder_text(self): + """Whether @" holds the text of a placeholder the snippet selected + since the cache was taken, i.e. whether the snippet is what last + wrote @". Compared inside Vim so binary register contents never + have to cross the Python boundary.""" + if not self._placeholder_texts: + return False + texts = ",".join(_vim_str(text) for text in self._placeholder_texts) + return vim_helper.eval(f'index([{texts}], @") >= 0') == "1" + + def remember_unnamed_register(self, placeholder_text): + """Cache the snippet-clobberable registers unless @" still holds + text we put there ourselves, then note 'placeholder_text' (the text + of the placeholder the snippet just selected) as text a select-mode + replacement may push into @" before the next call.""" + if not self._unnamed_register_holds_placeholder_text(): for reg in self._PRESERVED_REGISTERS: lit = _vim_str(reg) vim.command(f"let g:_ultisnips_reg_cache[{lit}] = getreginfo({lit})") - self._text_to_expect = text_to_expect + self._placeholder_texts = [] + if placeholder_text not in self._placeholder_texts: + self._placeholder_texts.append(placeholder_text) def restore_unnamed_register(self): - """Restore the cached registers, if we have any cached. + """Restore the cached registers, if we have any cached and the + snippet is what last wrote @". + + When @" holds none of the placeholder texts the snippet selected, + the user changed the registers themselves while the snippet was + still active (a `dd` after leaving it with , say) and restoring + the cache would throw their change away (#1695). Iteration order matters because `setreg('0', …)` and `setreg('"', dict)` both update the unnamed-register pointer. @@ -102,6 +120,8 @@ def restore_unnamed_register(self): """ if int(vim_helper.eval("empty(g:_ultisnips_reg_cache)")): return + if not self._unnamed_register_holds_placeholder_text(): + return for reg in self._PRESERVED_REGISTERS: lit = _vim_str(reg) vim.command( @@ -114,7 +134,7 @@ def reset_register_cache(self): """Drop the cached register state. Called when the snippet finishes so the next one starts from a clean slate.""" vim.command("let g:_ultisnips_reg_cache = {}") - self._text_to_expect = "" + self._placeholder_texts = [] def remember_position(self): """Remember the current position as a previous pose.""" diff --git a/test/test_Fixes.py b/test/test_Fixes.py index 7f592515..24a47433 100644 --- a/test/test_Fixes.py +++ b/test/test_Fixes.py @@ -149,6 +149,32 @@ class PreservesNumberedRegisterAcrossSnippet(_VimTest): wanted = "\n[X]\n\nLINE1" +# Tests for https://github.com/SirVer/ultisnips/issues/1695 — +# The register cache taken at expansion time is restored when the snippet +# is torn down and whenever insert mode is left. Register changes the user +# makes in between, while the snippet is still active but no placeholder is +# being replaced, must survive that restore instead of being reverted to +# the pre-snippet state. + + +class RetainsUserRegisterChange_AfterLeavingSnippet(_VimTest): + # `yy`, expand, ``, `dd` the snippet line, move away (teardown), + # `p`: the `dd`'d line must be pasted, not the yank from before the + # snippet. + snippets = ("test", "${1:hello} ${2:world} $0") + keys = "yank" + ESC + "yy" + "otest" + EX + ESC + "dd" + "k" + "p" + wanted = "yank\nhello world " + + +class RetainsUserRegisterChange_OnInsertLeave(_VimTest): + # `yy`, expand, ``, `x` inside the snippet (`@"` = "o"), re-enter + # and leave insert mode (InsertLeave restore), `p`: the `x`'d character + # must be pasted, not the yank from before the snippet. + snippets = ("test", "${1:hello} ${2:world} $0") + keys = "yank" + ESC + "yy" + "otest" + EX + ESC + "x" + "a" + ESC + "p" + wanted = "yank\nhell oworld " + + # End: Github Pull Request # 134 # Test to ensure that shiftwidth follows tabstop when it's set to zero post