From eb19a52b7c235ad5dd86167a6268b1beaf560df8 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Thu, 6 Aug 2026 13:16:48 +0200 Subject: [PATCH] feat(vim._with): `keepcwd` --- runtime/lua/vim/_core/shared.lua | 5 +-- src/nvim/context.c | 34 +++++++------- src/nvim/context_defs.h | 9 ++-- src/nvim/lua/stdlib.c | 11 +++-- test/functional/lua/with_spec.lua | 73 ++++++++++++++++++++++++------- 5 files changed, 92 insertions(+), 40 deletions(-) diff --git a/runtime/lua/vim/_core/shared.lua b/runtime/lua/vim/_core/shared.lua index f234f6ab29..00e7f39520 100644 --- a/runtime/lua/vim/_core/shared.lua +++ b/runtime/lua/vim/_core/shared.lua @@ -1480,6 +1480,7 @@ end --- @field go? table --- @field hide? boolean --- @field keepalt? boolean +--- @field keepcwd? boolean --- @field keepjumps? boolean --- @field keepmarks? boolean --- @field keeppatterns? boolean @@ -1537,9 +1538,6 @@ end --- indicated by the spec is restored. --- --- Notes: ---- - If `buf`/`win` is specified, CWD state (win/buf/tab-local dirs) is restored after execution. ---- Any :cd/:tcd/:lcd/:bcd during execution is undone. ---- - TODO: allow opt-out? Workaround: use nvim_buf_call()/nvim_win_call(). --- - Context `{ buf = buf }` has no guarantees about current window when --- inside context. --- - Context `{ buf = buf, win = win }` is yet not allowed, but this seems @@ -1571,6 +1569,7 @@ function vim._with(context, f) vim.validate('context.go', context.go, 'table', true) vim.validate('context.hide', context.hide, 'boolean', true) vim.validate('context.keepalt', context.keepalt, 'boolean', true) + vim.validate('context.keepcwd', context.keepcwd, 'boolean', true) vim.validate('context.keepjumps', context.keepjumps, 'boolean', true) vim.validate('context.keepmarks', context.keepmarks, 'boolean', true) vim.validate('context.keeppatterns', context.keeppatterns, 'boolean', true) diff --git a/src/nvim/context.c b/src/nvim/context.c index b1ae18aeaf..24855b5a3b 100644 --- a/src/nvim/context.c +++ b/src/nvim/context.c @@ -497,38 +497,42 @@ win_T *ctx_saved_curwin(void) return _ctx_saved_curwin == 0 ? NULL : win_find_by_handle(_ctx_saved_curwin); } -/// Prepares a temporary window or buffer as a temporary execution context. ctx_restore() MUST be -/// called afterwards, also when this returns false. +/// Prepares a temporary execution context. ctx_restore() MUST be called afterwards, also when this +/// returns false. /// /// - Passing `wp` makes that window the curwin (in tabpage `tp`, or NULL for current tabpage). /// - (Legacy: switch_win(), switch_win_noblock(), win_execute_before().) /// - Passing `buf`, enters a window showing `buf` in the current tabpage, or prepares a temporary /// "autocmd window" for it (never switches tabpage). /// - (Legacy: aucmd_prepbuf().) +/// - Passing neither: only CWD state is saved; flags must include `kCtxKeepDirs`. /// /// The switch itself never triggers autocommands; whether autocommands can fire _while_ switched /// (until ctx_restore()) is the caller's choice via kCtxNoEvents. /// -/// @param wp Target window, or NULL to target a buffer. +/// @param wp Target window, or NULL. /// @param tp Tabpage of `wp`, or NULL to not switch tabpage. -/// @param buf Target buffer, or NULL to target a window. +/// @param buf Target buffer, or NULL. /// @param flags kCtx flags. /// /// @return false if switching failed (only possible for a window target). bool ctx_switch(CtxSwitch *cs, win_T *wp, tabpage_T *tp, buf_T *buf, CtxSwitchFlags flags) { - assert((wp == NULL) != (buf == NULL)); + // Exactly one target, or none with kCtxKeepDirs (which only saves the CWD state). + assert(((wp == NULL) != (buf == NULL)) || (wp == NULL && (flags & kCtxKeepDirs))); assert(buf == NULL || tp == NULL); // a buffer target never switches tabpage CLEAR_POINTER(cs); cs->cs_flags = flags; - cs->cs_mode = buf != NULL ? kCtxSwitchBuf : kCtxSwitchWin; + cs->cs_mode = buf != NULL ? kCtxSwitchBuf : wp != NULL ? kCtxSwitchWin : kCtxSwitchDirs; cs->cs_ctxwin_idx = -1; cs->cs_did_chdir = _ctx_did_chdir; _ctx_did_chdir = false; + if (cs->cs_mode == kCtxSwitchDirs) { + wp = curwin; // No target: "switch" to curwin, i.e. stay put. + } - // Resolve the target window. A buffer target prefers a window already showing "buf" in the - // current tabpage (least side effects, esp. if "buf" is curbuf); when there is none, an autocmd - // window is prepared below, after the save (entering it changes curwin and prevwin). + // Resolve the target window. A buffer target prefers a window already showing it, in the current + // tabpage (minimizes side effects); else a ctx_win is prepared below (ctx_win_prep). if (buf != NULL) { if (buf == curbuf) { // be quick when buf is curbuf wp = curwin; @@ -546,8 +550,8 @@ bool ctx_switch(CtxSwitch *cs, win_T *wp, tabpage_T *tp, buf_T *buf, CtxSwitchFl cs->cs_target_win = wp->handle; cs->cs_target_old_pos = wp->w_cursor; } - // The CWD-state snapshot is only for targets with a real window: hidden-buffer targets are - // handled by the ctx_win machinery instead (see ctx_win_prep()). + // The CWD-state snapshot is only for a real window target; hidden-buffer target is handled by the + // ctx_win machinery (ctx_win_prep). if (buf == NULL || wp != NULL) { ctx_dirs_save(cs, wp, tp == NULL ? curtab : tp, buf); } @@ -580,8 +584,8 @@ bool ctx_switch(CtxSwitch *cs, win_T *wp, tabpage_T *tp, buf_T *buf, CtxSwitchFl if (buf != NULL) { if (wp == NULL) { - // No window shows `buf`: prepare a temp window. Anything related to a window (e.g., setting - // folds) may have unexpected results. + // Hidden buffer (`buf` not visible in any window): prepare a temp window. + // Window behavior (e.g., setting folds) may have unexpected results. wp = ctx_win_prep(cs, buf); // Leave the window we entered "from". leaving_window(curwin); @@ -677,7 +681,7 @@ void ctx_restore(CtxSwitch *cs) curwin->w_topline = curbuf->b_ml.ml_line_count; curwin->w_topfill = 0; } - } else { + } else if (cs->cs_mode == kCtxSwitchBuf) { // Restore the buffer previously edited by curwin. if (curwin->handle == cs->cs_new_curwin && curbuf != cs->cs_new_curbuf.br_buf @@ -693,7 +697,7 @@ void ctx_restore(CtxSwitch *cs) } ctx_restore_curwin(cs, NULL); - } + } // Else: only save CWD state. if (!cs->cs_same_win) { Visual.active = cs->cs_visual_active; diff --git a/src/nvim/context_defs.h b/src/nvim/context_defs.h index cd84e65a75..c1474ab736 100644 --- a/src/nvim/context_defs.h +++ b/src/nvim/context_defs.h @@ -65,9 +65,10 @@ typedef enum { /// What ctx_switch() switched (set internally). enum { - kCtxSwitchNone = 0, ///< zero-initialized: ctx_restore() is a no-op - kCtxSwitchWin, ///< window target - kCtxSwitchBuf, ///< buffer target + kCtxSwitchNone = 0, ///< Zero-initialized: ctx_restore() is a no-op. + kCtxSwitchWin, ///< Window target. + kCtxSwitchBuf, ///< Buffer target. + kCtxSwitchDirs, ///< No target: only CWD state is saved. }; /// Context before a temporary switch of current window/buffer. Undone by ctx_restore(). @@ -84,7 +85,7 @@ typedef struct { // Temporary location (ctx_switch()): handle_T cs_new_curwin; ///< ID of new curwin bufref_T cs_new_curbuf; ///< new curbuf - int cs_ctxwin_idx; ///< "autocmd" window in ctx_win[], or -1. + int cs_ctxwin_idx; ///< "autocmd" window in the ctx_win pool, or -1. // Target tracking (kCtxValidate): handle_T cs_target_win; ///< the window switched to pos_T cs_target_old_pos; ///< its cursor before the switch diff --git a/src/nvim/lua/stdlib.c b/src/nvim/lua/stdlib.c index 394d5944e2..8b14ddc64b 100644 --- a/src/nvim/lua/stdlib.c +++ b/src/nvim/lua/stdlib.c @@ -586,6 +586,7 @@ static int nlua_with(lua_State *L) int flags = 0; buf_T *buf = NULL; win_T *win = NULL; + bool keepcwd = false; int log_level = -1; #define APPLY_FLAG(key, flag) \ @@ -604,6 +605,8 @@ static int nlua_with(lua_State *L) buf = handle_get_buffer((int)luaL_checkinteger(L, -1)); } else if (strequal("win", k)) { win = handle_get_window((int)luaL_checkinteger(L, -1)); + } else if (strequal("keepcwd", k)) { + keepcwd = v; } else if (strequal("log_level", k)) { log_level = (int)luaL_checkinteger(L, -1); } else { @@ -645,13 +648,15 @@ static int nlua_with(lua_State *L) TRY_WRAP(&err, { CtxSwitch cs = { 0 }; bool switched = true; + CtxSwitchFlags dirs = keepcwd ? kCtxKeepDirs : kCtxKeepCwd; if (win) { tabpage_T *tabpage = win_find_tabpage(win); - switched = ctx_switch(&cs, win, tabpage, NULL, - kCtxNoDisplay | kCtxValidate | kCtxKeepDirs); + switched = ctx_switch(&cs, win, tabpage, NULL, kCtxNoDisplay | kCtxValidate | dirs); } else if (buf) { - ctx_switch(&cs, NULL, NULL, buf, kCtxKeepDirs); + ctx_switch(&cs, NULL, NULL, buf, dirs); + } else if (keepcwd) { + ctx_switch(&cs, NULL, NULL, NULL, kCtxKeepDirs); } if (switched) { diff --git a/test/functional/lua/with_spec.lua b/test/functional/lua/with_spec.lua index 4ac121ed69..e5b9b013c0 100644 --- a/test/functional/lua/with_spec.lua +++ b/test/functional/lua/with_spec.lua @@ -345,30 +345,25 @@ describe('vim._with', function() eq(true, out) end) - it('restores CWD state', function() + it('keeps ":bcd" on the target buffer', function() local out = exec_lua [[ local other_buf, cur_buf = setup_buffers() + local err_buf = api.nvim_create_buf(false, true) local cwd = fn.getcwd() local dir = vim.fs.joinpath(cwd, 'test') - -- ":bcd" on the target buffer is discarded: hidden target, (nested) visible target, and - -- when the callback errors. - vim._with({ buf = other_buf }, function() - vim.cmd.bcd(dir) - vim._with({ buf = cur_buf }, function() - vim.cmd.bcd(dir) - end) - end) - pcall(vim._with, { buf = other_buf }, function() + vim._with({ buf = other_buf }, function() vim.cmd.bcd(dir) end) + pcall(vim._with, { buf = err_buf }, function() vim.cmd.bcd(dir) error('oops') end) return { fn.haslocaldir(-1, -1, other_buf), + fn.haslocaldir(-1, -1, err_buf), fn.haslocaldir(-1, -1, cur_buf), - fn.getcwd() == cwd, + fn.getcwd() == cwd, -- Caller's CWD is unaffected: the target is not current. } ]] - eq({ 0, 0, true }, out) + eq({ 1, 1, 0, true }, out) end) end) @@ -422,6 +417,55 @@ describe('vim._with', function() end) end) + describe('`keepcwd` context', function() + it('undoes chdir at every scope', function() + local out = exec_lua [[ + local cwd = fn.getcwd() + local dir = vim.fs.joinpath(cwd, 'test') + vim._with({ keepcwd = true }, function() + vim.cmd.cd(dir) + vim.cmd.tcd(dir) + vim.cmd.bcd(dir) + vim.cmd.lcd(dir) + end) + return { + fn.haslocaldir(), -- window + fn.haslocaldir(-1, 0), -- tabpage + fn.haslocaldir(-1, -1, 0), -- buffer + fn.getcwd() == cwd, + fn.getcwd(-1, -1) == cwd, -- global + } + ]] + eq({ 0, 0, 0, true, true }, out) + end) + + it('discards ":bcd"/":lcd" that a `buf`/`win` context would keep', function() + local out = exec_lua [[ + local other_buf, _ = setup_buffers() + local other_win, _ = setup_windows() + local cwd = fn.getcwd() + local dir = vim.fs.joinpath(cwd, 'test') + vim._with({ buf = other_buf, keepcwd = true }, function() vim.cmd.bcd(dir) end) + vim._with({ win = other_win, keepcwd = true }, function() vim.cmd.lcd(dir) end) + return { + fn.haslocaldir(-1, -1, other_buf), + fn.haslocaldir(fn.win_id2win(other_win)), + fn.getcwd() == cwd, + } + ]] + eq({ 0, 0, true }, out) + end) + + it('restores nothing else: the callback may switch window', function() + local out = exec_lua [[ + local other_win, _ = setup_windows() + vim._with({ keepcwd = true }, function() api.nvim_set_current_win(other_win) end) + return api.nvim_get_current_win() == other_win + ]] + eq(true, out) + end) + end) + describe('`emsg_silent` context', function() pending('works', function() local ok = pcall( @@ -1345,17 +1389,16 @@ describe('vim._with', function() eq({ 'col', { { 'leaf', t2_other_win }, { 'leaf', t2_move_win } } }, fn.winlayout(2)) end) - it('restores CWD state', function() + it('keeps ":lcd" on the target window, but restores the CWD', function() local out = exec_lua [[ local other_win, cur_win = setup_windows() local cwd = fn.getcwd() - -- ":lcd" on the target window is discarded. vim._with({ win = other_win }, function() vim.cmd.lcd(vim.fs.joinpath(cwd, 'test')) end) return { fn.haslocaldir(fn.win_id2win(other_win)), fn.getcwd() == cwd } ]] - eq({ 0, true }, out) + eq({ 1, true }, out) end) end)