github saberzero1/motions 1.3.0

4 hours ago

Added

  • Every built-in surround character can now be rebound — vim.obsidian.surround.set and surroundmap refused 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 — t and f as targets, where they reach the tag and function-call finders, and <, f and F as replacements, where they open the tag-name and function-name prompts. An alias resolves to its canonical character first, so overriding ) also reaches b; leaving it on the built-in would make dsb and ds) 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 shared getCustomSurroundPair resolver behind all seven lookup sites, the relaxed registerSurroundPair, and eight built-in dispatch guards — five on t/f as targets, three on </f/F as replacements)

Fixed

  • The Neovim backend's version gate enforced 0.10, not the 0.12 it advertises — REQUIRED_API_LEVEL read 12, but 12 is Neovim 0.10's NVIM_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.lua passes on_range to nvim_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 with invalid key: on_range, 0.12.5 reports 14 and accepts it — and Neovim's own CMakeLists.txt at 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
  • 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 as could not start Neovim at "…": <neovim error>. Check the configured path and permissions. The reporter of #199 spent days moving nvim.exe between directories on advice that could not have helped: the process was already running, which proves the path resolved and executed. Post-spawn failures now read Neovim started at "…" but the connection failed: … and point at the Neovim configuration instead.
    • Plugin: src/rpc/neovim-connection.ts
  • EACCES is no longer reported as a non-executable binary on Windows — libuv's uv_translate_sys_error maps ERROR_ELEVATION_REQUIRED and ERROR_CANT_ACCESS_FILE to EACCES, while a plain ERROR_ACCESS_DENIED arrives as EPERM. "The binary is not executable" therefore describes the one Windows cause that does not produce it. Windows EACCES now names the Run this program as an administrator compatibility flag and non-native paths such as WSL or a mapped drive; EPERM is handled separately and points at permissions and antivirus; and every spawn failure now carries its errno code, so an unhandled one is diagnosable from the notice alone rather than being swallowed.
    • Plugin: src/rpc/neovim-connection.ts
  • 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 sets swapfile false 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
  • 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 abc where *az* abc was wanted. Read out of Obsidian 1.13.7's bundle: Live Preview hides inactive formatting markers behind Decoration.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 the snippetRuntime slot, so it follows enableSnippets)
    • The first version keyed the guard on EditorState identity 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 recorded match: true, hasSel: false on that transaction and then armed: false on the snap at from: 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 in updatePlugins, ahead of the update listener that arms the guard, so Obsidian's timer is always queued first.
  • cs dot-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 final t of the second word, . produced (test), "test(, )test", "test", having matched that word's closing quote against the next word's opening quote. Typing cs"b by 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 in handleSurroundSubState applies 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 — which findSurroundingQuotes, scanning backwards for the nearest quote, then takes as an opening quote. Brackets hid it, because findSurroundingBrackets counts 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 where changeSurroundPair parks it, so csba.. 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, the savedReplacement branch)
  • A surround pair dropped from .obsidian.init.lua stayed bound until Obsidian restarted — applyLuaSurroundPairs returned 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
  • surroundmap had the same gap, and tracked nothing at all — an explicit surroundunmap worked, but a surroundmap line simply deleted from .obsidian.vimrc survived 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 shape vimrcMapKeys and vimrcExmapNames already used two functions away.
    • Plugin: src/vimrc/loader.ts, src/main.ts

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 answers nvim_get_api_info with an accepted level and then exits, so the next in-flight request rejects and the failure lands after spawn. Negative control: dropping the spawned argument from the outer catch — the pre-fix behaviour — failed it both ways round, but the connection failed reporting Expected: true, Received: false and could not start Neovim reporting Expected: false, Received: true, with nothing else in the spec changing. The spec's ACCEPTED_STUB_API_LEVEL restates REQUIRED_API_LEVEL, since a spec cannot value-import a src module, and the unit suite pins the two: drifting it to 13 failed on that field alone.
    • Tests: test/specs/rpc-lifecycle.e2e.ts
  • The RPC test fixture had vim.opt.swapfile = false in 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 the beforeEach hook with Neovim 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 reported Expected: false, Received: true, and with that assertion inverted so execution reached the disk diff, a freshly created …%test-vault-IX0xSq%Target.md.swp appeared 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
  • 7 unit cases in test/unit/rpc/neovim-api-floor.test.ts hold the floor against the keys companion.lua actually 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 with floor is api_level 12; these keys need more: [['on_start',13],['on_win',13],['on_range',14],['on_end',13]] and expected 14 to be 12. Negative controls for the rest: dropping only scripts/install-neovim.sh to 13 failed the consistency case on that field alone (installSh: 13 against installPs1: 14, prerequisites: 14); disabling the Windows EACCES branch 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 12 stubbed 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
  • 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: 4 against an expected 3, 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 at ch: 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)
  • 3 e2e cases and 3 fork cases pin where the cs repeat 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 to dist/, so a negative control there needs a rebuild between the stash and the run — stashing src/vim.js alone leaves the built bundle fixed and all three pass, which is a vacuous green. Rebuilt from pristine master, vim_dot_cs_quotes_cursor_before_closing_quote failed with the same string and the two controls held. (#197)
    • Tests: test/specs/surround.e2e.ts, ~/Repos/codemirror-vim/test/vim_test.js
  • 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_outward asserts all three states of (((test))) → <((test))> → <<(test)>> → <<<test>>>. It and the #197 case 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 #197 fails 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 overridden and surroundunmap should restore an overridden builtin pair both 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 pristine master, the four e2e override cases failed with the built-in output — ( hello ) world for the paren, <<hello>> world for the tag target (the tag finder matching nothing), (hello) world for 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 need getSurroundPair stayed green: alias_follows_target returned (hello) world, tag_target and func_target deleted 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 failed custom_surround_override_alias_takes_precedence with [[hello]] world for ysiwb, and left the other three alias cases green. custom_surround_reserved_char_rejected asserted the restriction being removed, so it is now custom_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-existing vim_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
  • The two config-reload releases have their own controls, since neither is reachable from the fork suite. Restoring the pairs.length === 0 early return failed should restore the builtin surround pair when its override is removed from init.lua with (hello) world, and removing the vimrc release pass failed removing surroundmap from the vimrc should restore the builtin pair the same way — while surroundunmap should restore an overridden builtin pair stayed green through both, proving the explicit-unmap path and the implicit-removal path are tested separately. loadVimrcSoft exists because loadVimrc restarts 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 at nvim.exe rather than its folder, spaces in the path needing no quoting because no shell is involved, the elevation flag behind EACCES, and why WSL, .bat wrappers and mapped drives are not valid targets. The EACCES advice 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. An E325 row covers the swap files left behind by earlier versions.
  • CONTRIBUTING.md: src/snippets/live-preview-guard.ts in 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 crediting cs dot-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 under vim.obsidian.surround, rewritten and pointed at the feature page.
  • docs/configuration/vimrc.md: a built-in override in the example vimrc, and surroundmap/surroundunmap in 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

Don't miss a new motions release

NewReleases is sending notifications on new releases.