Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
54 changes: 37 additions & 17 deletions pythonx/UltiSnips/vim_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Esc>, 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
Expand All @@ -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 <Esc>, 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.
Expand All @@ -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(
Expand All @@ -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."""
Expand Down
26 changes: 26 additions & 0 deletions test/test_Fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<Esc>`, `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, `<Esc>`, `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
Expand Down
Loading