Added
- Every built-in surround character can now be rebound —
vim.obsidian.surround.setandsurroundmaprefused all 19 of them, which meant the commonest reason to reach for a custom pair was the one thing the feature could not do:ysiw(wraps as( word )and nothing could ask for(word). Registration now accepts them, and each dispatch that reads a built-in meaning before it reads a delimiter pair yields to an override —tandfas targets, where they reach the tag and function-call finders, and<,fandFas replacements, where they open the tag-name and function-name prompts. An alias resolves to its canonical character first, so overriding)also reachesb; leaving it on the built-in would makedsbandds)disagree about what the pair is. Overridden brackets keep depth-aware matching, because the search moves from the bracket scanner to the multi-character one, which counts depth as well. Empty delimiters are rejected instead of stored: they disable the character rather than rebinding it, a failure mode that only became reachable once a built-in could be the target. (#197)- Fork:
~/Repos/codemirror-vim/src/vim.js(a sharedgetCustomSurroundPairresolver behind all seven lookup sites, the relaxedregisterSurroundPair, and eight built-in dispatch guards — five ont/fas targets, three on</f/Fas replacements)
- Fork:
Fixed
- The Neovim backend's version gate enforced 0.10, not the 0.12 it advertises —
REQUIRED_API_LEVELread12, but 12 is Neovim 0.10'sNVIM_API_LEVEL; 0.11 is 13 and 0.12 is 14. Every 0.10 and 0.11 therefore cleared a gate whose own rejection notice, the docs and the README all say 0.12, and then died a few statements later inside the decoration bridge:companion.luapasseson_rangetonvim_set_decoration_provider, a key that only exists from 0.12. Measured against the official release tarballs — 0.11.4 reports api_level 13 and answers that call withinvalid key: on_range, 0.12.5 reports 14 and accepts it — and Neovim's ownCMakeLists.txtat v0.9.5/v0.10.0/v0.11.0/v0.12.0 gives 11/12/13/14. The floor is now 14 at all four sites that carry it. (#199)- Plugin:
src/rpc/neovim-connection.ts - Scripts:
scripts/install-neovim.sh,scripts/install-neovim.ps1
- Plugin:
- A Neovim-side startup error no longer claims the binary path is wrong — every failure after
spawn()reused the notice written for failures before it, so an RPC error arrived ascould not start Neovim at "…": <neovim error>. Check the configured path and permissions.The reporter of #199 spent days movingnvim.exebetween directories on advice that could not have helped: the process was already running, which proves the path resolved and executed. Post-spawn failures now readNeovim started at "…" but the connection failed: …and point at the Neovim configuration instead.- Plugin:
src/rpc/neovim-connection.ts
- Plugin:
EACCESis no longer reported as a non-executable binary on Windows — libuv'suv_translate_sys_errormapsERROR_ELEVATION_REQUIREDandERROR_CANT_ACCESS_FILEtoEACCES, while a plainERROR_ACCESS_DENIEDarrives asEPERM. "The binary is not executable" therefore describes the one Windows cause that does not produce it. WindowsEACCESnow names the Run this program as an administrator compatibility flag and non-native paths such as WSL or a mapped drive;EPERMis handled separately and points at permissions and antivirus; and every spawn failure now carries itserrnocode, so an unhandled one is diagnosable from the notice alone rather than being swallowed.- Plugin:
src/rpc/neovim-connection.ts
- Plugin:
- The mirrored buffer left a swap file behind for every note visited, and the next session could not connect at all — the mirror carries the note's absolute path and is permanently modified, so Neovim gave it a swap file like any other named buffer, one per note because the buffer is renamed rather than reopened. Disconnect sends SIGTERM, and Neovim's signal handler preserves swap files rather than deleting them, so they accumulate by design. The next session names the same buffer and gets E325
Found a swap file— a blocking prompt an embedded Neovim cannot answer, so activation never finishes and the backend fails to connect, which is the reporter's alternation between E325 and an unexplained startup failure. The mirror now setsswapfilefalse on itself, buffer-locally, so the user's own buffers keep their configured behaviour. Existing stale swap files need no cleanup: with the option off, Neovim skips the check entirely — measured against a deliberately stale swap, which produced no prompt. Reproduced on Linux; it was never platform-specific, and the reporter's Windows restarts merely surfaced it first. (#199)- Plugin:
src/rpc/document-sync.ts
- Plugin:
- A snippet tabstop inside Markdown emphasis no longer jumps outside it in Live Preview — with the reported body
$1 *a$2* abc$3, Tab from the first tabstop left the cursor at*a*|instead of*a|*, so the next keystroke typed outside the emphasis:*a*z abcwhere*az* abcwas wanted. Read out of Obsidian 1.13.7's bundle: Live Preview hides inactive formatting markers behindDecoration.replace, and the view plugin that owns them re-snaps a selection landing inside a hidden marker to its outer edge —min(from, pos)for the opening marker,max(to, pos)for the closing one. It tests the incoming selection against the decoration set built for the previous selection, so the snap fires on the very transaction that first moves the cursor into the markup, which is what a tabstop jump is; and it dispatches the correction from a zero-delay timer, so the cursor lands on the tabstop and hops out a tick later. Its own bail-outs decide the shape of the bug: it skips any transaction that also changed the document, which is why expansion still places the first tabstop correctly and only later jumps are affected, and it does nothing in Source mode, where no marker is hidden. A transaction filter now drops the correction — a bare selection move with no document change, no effects and no user event — when it starts from the selection a tabstop jump just produced and leaves it. (#198)- Plugin:
src/snippets/live-preview-guard.ts(new),src/main.ts(installed in thesnippetRuntimeslot, so it followsenableSnippets) - The first version keyed the guard on
EditorStateidentity and missed the snap entirely. The completion plugin dispatches its own selection-less, effect-only bookkeeping transaction between the jump and the snap, which consumed the guard: instrumentation recordedmatch: true, hasSel: falseon that transaction and thenarmed: falseon the snap atfrom: 3, to: 4. The guard is keyed on the jump's selection instead, and is closed by the first transaction that moves the cursor or edits the document, plus a zero-delay release for the ordinary case where no snap comes. Ordering is safe by construction: the view plugin that schedules the snap runs inupdatePlugins, ahead of the update listener that arms the guard, so Obsidian's timer is always queued first.
- Plugin:
csdot-repeat changed the wrong pair when the cursor sat on the last character before the closing delimiter — on(test), "test", "test", "test"with the cursor on the finaltof the second word,.produced(test), "test(, )test", "test", having matched that word's closing quote against the next word's opening quote. Typingcs"bby hand at the same position produced the correct(test), (test), "test", "test", so the repeat and the command disagreed. The repeat path shifted its search start forward by the length of the replacement's opening delimiter on every iteration of the count loop, where the interactive path inhandleSurroundSubStateapplies that shift only from the second iteration onward. The shift exists so a later iteration begins past the delimiter the loop just wrote and expands outward; on the first iteration it only moves the search off the cursor, and one column right of the last character inside a pair is that pair's closing delimiter — whichfindSurroundingQuotes, scanning backwards for the nearest quote, then takes as an opening quote. Brackets hid it, becausefindSurroundingBracketscounts depth and recovers from the same displacement. The repeat path now steps past that delimiter only when the cursor is actually standing on it — which is wherechangeSurroundPairparks it, socsba..still walks outward through(((test)))while a cursor merely resting inside a pair is left alone. Gating on the iteration number instead is not equivalent and breaks that walk: repeated.is a series of separate calls that are all the first iteration. (#197)- Fork:
~/Repos/codemirror-vim/src/vim.js(surroundAction, thesavedReplacementbranch)
- Fork:
- A surround pair dropped from
.obsidian.init.luastayed bound until Obsidian restarted —applyLuaSurroundPairsreturned on an empty list before reaching its own unregister pass, so removing the last pair from the config released nothing. That was invisible while only new characters could be registered; with built-ins overridable it strands a rebound(in a state the config no longer describes. The release pass now runs ahead of the guard.- Plugin:
src/main.ts
- Plugin:
surroundmaphad the same gap, and tracked nothing at all — an explicitsurroundunmapworked, but asurroundmapline simply deleted from.obsidian.vimrcsurvived the reload, because nothing recorded what the previous pass had registered. Triggers are now tracked and released on soft reload and on teardown, in the shapevimrcMapKeysandvimrcExmapNamesalready used two functions away.- Plugin:
src/vimrc/loader.ts,src/main.ts
- Plugin:
Tests
- A new e2e scenario covers the notice split itself, because no unit test can see which of the two messages
connect()selects. Its stub answersnvim_get_api_infowith an accepted level and then exits, so the next in-flight request rejects and the failure lands after spawn. Negative control: dropping thespawnedargument from the outercatch— the pre-fix behaviour — failed it both ways round,but the connection failedreportingExpected: true, Received: falseandcould not start NeovimreportingExpected: false, Received: true, with nothing else in the spec changing. The spec'sACCEPTED_STUB_API_LEVELrestatesREQUIRED_API_LEVEL, since a spec cannot value-import asrcmodule, and the unit suite pins the two: drifting it to 13 failed on that field alone.- Tests:
test/specs/rpc-lifecycle.e2e.ts
- Tests:
- The RPC test fixture had
vim.opt.swapfile = falsein it, which hid the swap-file defect for the entire life of the backend — every spec ran clean while every real user accumulated swap files. Both fixtures now leave the global default alone, so the suite measures the product. Removing that one line, against the unfixed bundle, did not merely fail the new assertion: it broke thebeforeEachhook withNeovim RPC did not connect, because the E325 prompt blocks activation — the defect is a total connection failure on reconnect, not a cosmetic one. The new scenario diffs the swap directory before and after rather than asserting it empty, because that directory is shared with the developer's own Neovim; an absolute assertion failed spuriously on debris from an earlier control run. Negative controls, each proven independently: with the fix reverted the option assertion reportedExpected: false, Received: true, and with that assertion inverted so execution reached the disk diff, a freshly created…%test-vault-IX0xSq%Target.md.swpappeared in the added set. Four control runs left four accumulated swap files on disk, one per run; the fixed run left none.- Tests:
test/specs/rpc-text-sync.e2e.ts,test/fixtures/nvim/init.lua,test/fixtures/nvim/lsp-probe.lua
- Tests:
- 7 unit cases in
test/unit/rpc/neovim-api-floor.test.tshold the floor against the keyscompanion.luaactually passes — read out of the Lua file rather than restated, so a seventh key added without measuring it fails — against the level the rejection notice advertises, and against the three sibling gates that carry the same number. 4 further cases cover the notice split. Red first: the floor cases failed withfloor is api_level 12; these keys need more: [['on_start',13],['on_win',13],['on_range',14],['on_end',13]]andexpected 14 to be 12. Negative controls for the rest: dropping onlyscripts/install-neovim.shto 13 failed the consistency case on that field alone (installSh: 13againstinstallPs1: 14,prerequisites: 14); disabling the WindowsEACCESbranch reproduced the old string verbatim,the binary is not executable (EACCES).; and restoring the combined sentence produced the issue's screenshot verbatim,could not start Neovim at "C:\Neovim\bin\nvim.exe": Neovim RPC error: […invalid key: on_range…]. Check the configured path and permissions. refuses a Neovim API level below 12stubbed level 11 only, so it stayed green for the whole life of the defect — 11 is below both the broken floor and the correct one. It is now a loop over 11, 12 and 13, the last two being exactly the levels that used to be admitted. Negative control: with the floor put back to 12 and the bundle rebuilt, level 11 still passed while 12 and 13 failed with"notices":[]— no version notice was ever shown, because the stub was admitted and the connection proceeded, which is the reported failure path. All 15 cases pass against a real Obsidian 1.13.7 and a real Neovim 0.12.5 with the floor at 14.- Tests:
test/specs/rpc-lifecycle.e2e.ts,test/specs/rpc-prerequisites.ts
- Tests:
- 5 e2e cases cover the Live Preview tabstop snap, over the reported body and a second one whose in-emphasis tabstop is last — a distinct dispatch path, since the autocomplete fork reaches the final field by clearing the snippet rather than advancing it. Red first, against the unfixed bundle: the cursor case reported
ch: 4against an expected3, and typing after the jump produced" *a*z abc"against" *az* abc". Re-running with the guard removed after the fix reproduced both and the final-tabstop case atch: 4; all three returned green once it was restored. Two of the five are controls that must stay green throughout and did — placement of the first tabstop, which lands outside the emphasis, and the whole sequence in Source mode, where nothing is hidden — so a failure is localised to the in-emphasis jump rather than to snippets in general. (#198)- Tests:
test/specs/snippets/snippet-live-preview-tabstop.e2e.ts(new)
- Tests:
- 3 e2e cases and 3 fork cases pin where the
csrepeat starts its search, one at the reported position and two controls at positions that were never broken — mid-word, and on the opening quote — so a fix that displaces the search the other way fails rather than trading one wrong position for another. Red first: the reported case returned(test), "test(, )test", "test"against the expected(test), (test), "test", "test", byte-identical to the issue, while both controls passed. The fork's browser tests import from.., which resolves todist/, so a negative control there needs a rebuild between the stash and the run — stashingsrc/vim.jsalone leaves the built bundle fixed and all three pass, which is a vacuous green. Rebuilt from pristinemaster,vim_dot_cs_quotes_cursor_before_closing_quotefailed with the same string and the two controls held. (#197)- Tests:
test/specs/surround.e2e.ts,~/Repos/codemirror-vim/test/vim_test.js
- Tests:
- The
csba..nested walk is now pinned at the fork level, where it had no coverage at all — the fork suite passed 1916/1916 against a rule that broke it, and only the plugin's golden suite caught it, comparing against a real Neovim.dot_cs_nested_layers_step_outwardasserts all three states of(((test)))→<((test))>→<<(test)>>→<<<test>>>. It and the#197case are opposing controls on the same line of code: with the iteration-number rule the nested walk fails at the first.with<((test))>while the quote cases pass, and with the unconditional offset the nested walk passes while#197fails with(test), "test(, )test", "test". Only the delimiter-under-cursor rule satisfies both. - 14 fork cases and 9 e2e cases cover overriding the built-ins, one per dispatch that had to learn to yield plus controls that must stay green. The controls earn their place:
should leave other builtin pairs alone when one is overriddenandsurroundunmap should restore an overridden builtin pairboth passed in every run, which is what localises a failure to the override path rather than to surround in general. Two independent negative controls, because relaxing registration and guarding dispatch are separable. Built against pristinemaster, the four e2e override cases failed with the built-in output —( hello ) worldfor the paren,<<hello>> worldfor the tag target (the tag finder matching nothing),(hello) worldfor the alias — and the removal case failed on its own first assertion. Then, with registration relaxed but the guards stripped, exactly the five guard-dependent fork cases failed and the four that only needgetSurroundPairstayed green:alias_follows_targetreturned(hello) world,tag_targetandfunc_targetdeleted nothing, and both<-as-replacement cases opened the prompt instead of wrapping. A third control covers the alias ordering, which the docs now promise: swapping the resolver's two lookups so the alias resolves first failedcustom_surround_override_alias_takes_precedencewith[[hello]] worldforysiwb, and left the other three alias cases green.custom_surround_reserved_char_rejectedasserted the restriction being removed, so it is nowcustom_surround_multichar_trigger_rejected; the validation it covered is still tested, alongside the new empty-delimiter rejection. The fork suite is 1915 passing over 1916 cases, the one failure being the pre-existingvim_increment_octal. (#197)- Tests:
test/specs/lua-config.e2e.ts,test/specs/vimrc.e2e.ts,test/specs/config-management.e2e.ts,~/Repos/codemirror-vim/test/vim_test.js
- Tests:
- The two config-reload releases have their own controls, since neither is reachable from the fork suite. Restoring the
pairs.length === 0early return failedshould restore the builtin surround pair when its override is removed from init.luawith(hello) world, and removing the vimrc release pass failedremoving surroundmap from the vimrc should restore the builtin pairthe same way — whilesurroundunmap should restore an overridden builtin pairstayed green through both, proving the explicit-unmap path and the implicit-removal path are tested separately.loadVimrcSoftexists becauseloadVimrcrestarts Obsidian, which resets the fork's module state by itself and so cannot observe a leak at all.
Documentation
docs/features/neovim-backend.md: a Troubleshooting the connection section, which #199 asked for by name. Covers the version check and the commands to run it, a table mapping each failure notice to what it actually means — including that a notice beginning "Neovim started at" has already proved the path works — and a Windows section on pointing atnvim.exerather than its folder, spaces in the path needing no quoting because no shell is involved, the elevation flag behindEACCES, and why WSL,.batwrappers and mapped drives are not valid targets. TheEACCESadvice no longer prescribes a direction: the first draft said to clear the Run this program as an administrator flag, and the reporter then resolved it by switching that flag on, so the page now explains that it has to agree with how Obsidian itself is running and says which way worked in the reported case. AnE325row covers the swap files left behind by earlier versions.CONTRIBUTING.md:src/snippets/live-preview-guard.tsin the codebase tree.KNOWN_LIMITATIONS.md: a note under Visual mode on single-character text objects (Fixed) separating the formatting-mark transaction filter removed there from the snippet guard added for #198. The removed filter moved the cursor itself, on every transaction; the guard drops Obsidian's own corrective dispatch, only immediately after a tabstop jump. Removing it is not a repeat of that fix.KNOWN_LIMITATIONS.md: under Surround nvim-surround parity gaps, the bullet creditingcsdot-repeat to a "search position offset" now records that the offset was applied one iteration too early, and the stale test counts are replaced with measured ones.docs/features/surround.md: the sentence listing 19 reserved characters is replaced by an Overriding the built-in pairs section — the spacing and curly-quote examples, a table of the three characters whose interactive behaviour an override displaces, the alias rule, and the empty-delimiter rejection.docs/configuration/lua-config.md: the same reserved-characters sentence undervim.obsidian.surround, rewritten and pointed at the feature page.docs/configuration/vimrc.md: a built-in override in the example vimrc, andsurroundmap/surroundunmapin the command table now say they override and restore built-ins rather than only adding and removing custom pairs.
Full Changelog: 1.2.0...1.3.0