From 1c1dc0558f784a66fb6d0f7dd3bae8d89274ad64 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Wed, 5 Aug 2026 11:20:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(cwd):=20support=20explicit=20chdir=20(:bcd?= =?UTF-8?q?/:tcd/=E2=80=A6)=20in=20temp=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: - Explicit `:bcd` (etc.) persists from `nvim_buf_call()` but not from an autocmd handler targeting a hidden buf (`LspAttach`, `TermRequest`, …), which needs a `vim.schedule()` workaround. - `vim._with()` is supposed to work as a "sandbox", discarding side-effects, but it leaks CWD changes: `:lcd` from a `win` context, any chdir from a visible-buffer context. Solution: - Explicit :cd/:tcd/:bcd during a temp context persists by default. - "Ambient" directory changes ('autochdir', existing win-local CWD, etc.) are still undone, as before. - Add `kCtxKeepDirs`: snapshot/restore the target's full CWD state (w/b/tp-local, global, cwd). Used by `vim._with()` and `'inccommand'`, which must not leak state. --- runtime/lua/vim/_core/shared.lua | 6 +- src/nvim/context.c | 173 ++++++++++++++++++++-------- src/nvim/context_defs.h | 38 +++--- src/nvim/eval/fs.c | 2 + src/nvim/ex_docmd.c | 1 + src/nvim/ex_getln.c | 4 +- src/nvim/lua/stdlib.c | 5 +- test/functional/ex_cmds/cd_spec.lua | 123 +++++++++++++------- test/functional/lua/with_spec.lua | 52 +++++++++ test/old/testdir/test_cd.vim | 2 + 10 files changed, 300 insertions(+), 106 deletions(-) diff --git a/runtime/lua/vim/_core/shared.lua b/runtime/lua/vim/_core/shared.lua index 0c7500dea4..f234f6ab29 100644 --- a/runtime/lua/vim/_core/shared.lua +++ b/runtime/lua/vim/_core/shared.lua @@ -1533,9 +1533,13 @@ local get_context_state = function(context) return res end ---- Executes function `f` with the given context specification. +--- Executes function `f` with the given `context` spec: after execution, the original state +--- 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 diff --git a/src/nvim/context.c b/src/nvim/context.c index 911f2dd116..b1ae18aeaf 100644 --- a/src/nvim/context.c +++ b/src/nvim/context.c @@ -58,6 +58,9 @@ static int _ctx_switch_depth = 0; /// curwin saved by the outermost curwin-changing ctx_switch() (0: none). static handle_T _ctx_saved_curwin = 0; +/// Whether an explicit :cd/:tcd/:lcd/:bcd/chdir() happened since the innermost ctx_switch(). +static bool _ctx_did_chdir = false; + /// Free resources used by Context object. /// /// param[in] ctx pointer to Context object to free. @@ -244,30 +247,103 @@ int ctx_from_dict(Dict dict, Context *ctx, Error *err) return types; } -/// kCtxKeepCwd: remembers the cwd so that ctx_restore() can undo any directory change caused by -/// switching to "wp" ('autochdir', win/tab-local directories). -static void ctx_cwd_save(CtxSwitch *cs, win_T *wp, tabpage_T *tp) +/// Moves CWD state aside, so that the temporary "autocmd window" starts clean. +/// Undone by ctx_localdirs_restore(). +static void ctx_win_dirs_save(CtxSwitch *cs, win_T *cw_win, buf_T *buf) { - cs->cs_cwd_status = FAIL; + // A pooled tmp-window must not carry a stale w_localdir. + XFREE_CLEAR(cw_win->w_localdir); + cs->cs_b_localdir = buf->b_localdir; + buf->b_localdir = NULL; + cs->cs_tp_localdir = curtab->tp_localdir; + curtab->tp_localdir = NULL; + cs->cs_globaldir = globaldir; + globaldir = NULL; +} - // Getting and setting directory can be slow on some systems, only do - // this when the current or target window/tab have a local directory or - // 'acd' is set. +/// Restores the dir scopes saved in `cs`. With `persist`, a scope explicitly changed +/// (user :bcd/:tcd/:cd) keeps its new value instead. +/// +/// @param cwp The discarded temp win of a hidden buf, or NULL. If given, also fix the process CWD. +/// @param tp Tabpage that owns cs_tp_localdir, or NULL if it no longer exists. +static void ctx_localdirs_restore(CtxSwitch *cs, win_T *cwp, tabpage_T *tp, bool persist) +{ + const bool did_chdir = _ctx_did_chdir; + _ctx_did_chdir = persist && did_chdir; + + win_T *dirs_win = win_find_by_handle(cs->cs_new_curwin); + if (dirs_win != NULL) { + xfree(dirs_win->w_localdir); + dirs_win->w_localdir = cs->cs_w_localdir; + } else { + xfree(cs->cs_w_localdir); + } + + buf_T *b = bufref_valid(&cs->cs_new_curbuf) ? cs->cs_new_curbuf.br_buf : NULL; + if (b != NULL && !(persist && b->b_localdir != NULL)) { + xfree(b->b_localdir); + b->b_localdir = cs->cs_b_localdir; + } else { + xfree(cs->cs_b_localdir); + } + + if (tp != NULL && !(persist && tp->tp_localdir != NULL)) { + xfree(tp->tp_localdir); + tp->tp_localdir = cs->cs_tp_localdir; + } else { + xfree(cs->cs_tp_localdir); + } + + // Correct the directory before restoring globaldir: the first chdir during the switch saved + // the pre-switch cwd in `globaldir` (see `post_chdir`), which update_cwd() uses as fallback. + if (cwp != NULL && (did_chdir || cwp->w_localdir != NULL)) { + update_cwd(kCdCauseWindow); + } + + // Keep-case: the globaldir set during the switch (pre-switch cwd, see `post_chdir`). + if (!(persist && cs->cs_globaldir == NULL && globaldir != NULL)) { + xfree(globaldir); + globaldir = cs->cs_globaldir; + } +} + +/// Saves the dir state to be restored by ctx_dirs_restore(): +/// - kCtxKeepCwd or kCtxKeepDirs: the CWD, so any directory change caused by switching to `wp` +/// ('autochdir', win/tab-local directories) can be undone. +/// - kCtxKeepDirs: also copies of the target context's dir scopes (w/b/tp-local, global). +static void ctx_dirs_save(CtxSwitch *cs, win_T *wp, tabpage_T *tp, buf_T *buf) +{ + if (!(cs->cs_flags & (kCtxKeepCwd | kCtxKeepDirs))) { + return; + } + + // kCtxKeepDirs: also save copies of the target context's dir scopes. + if (cs->cs_flags & kCtxKeepDirs) { + buf_T *target_buf = buf != NULL ? buf : wp->w_buffer; + cs->cs_dirs_tab = tp->handle; + cs->cs_w_localdir = wp->w_localdir == NULL ? NULL : xstrdup(wp->w_localdir); + cs->cs_b_localdir = target_buf->b_localdir == NULL ? NULL : xstrdup(target_buf->b_localdir); + cs->cs_tp_localdir = tp->tp_localdir == NULL ? NULL : xstrdup(tp->tp_localdir); + cs->cs_globaldir = globaldir == NULL ? NULL : xstrdup(globaldir); + } + + // Getting and setting directory can be slow on some systems, only do this when the current or + // target window/tab have a local directory or 'acd' is set, or if kCtxKeepDirs was set. char cwd[MAXPATHL]; - if (curwin != wp - && (curwin->w_localdir != NULL || (wp != NULL && wp->w_localdir != NULL) - || curbuf->b_localdir != NULL || (wp != NULL && wp->w_buffer->b_localdir != NULL) - || (curtab != tp && (curtab->tp_localdir != NULL || tp->tp_localdir != NULL)) - || p_acd)) { - cs->cs_cwd_status = os_dirname(cwd, MAXPATHL); - if (cs->cs_cwd_status == OK) { + if ((cs->cs_flags & kCtxKeepDirs) + || (curwin != wp + && (curwin->w_localdir != NULL || (wp != NULL && wp->w_localdir != NULL) + || curbuf->b_localdir != NULL || (wp != NULL && wp->w_buffer->b_localdir != NULL) + || (curtab != tp && (curtab->tp_localdir != NULL || tp->tp_localdir != NULL)) + || p_acd))) { + if (os_dirname(cwd, MAXPATHL) == OK) { cs->cs_cwd = xstrdup(cwd); // allocated on demand: keeps CtxSwitch small } } // If 'acd' is set, check we are using that directory. If yes, then // apply 'acd' afterwards, otherwise restore the current directory. - if (cs->cs_cwd_status == OK && p_acd) { + if (cs->cs_cwd != NULL && p_acd) { if (curbuf->b_sfname != NULL && curbuf->b_fname == curbuf->b_sfname) { cs->cs_save_sfname = xstrdup(curbuf->b_sfname); } @@ -279,13 +355,27 @@ static void ctx_cwd_save(CtxSwitch *cs, win_T *wp, tabpage_T *tp) } } -/// kCtxKeepCwd: restores the current directory. -static void ctx_cwd_restore(CtxSwitch *cs) +/// Restores the dir state saved by ctx_dirs_save(), undoing any chdir made while switched. The +/// target window/buffer/tab may have been closed meanwhile. +static void ctx_dirs_restore(CtxSwitch *cs) { + // kCtxKeepDirs: restore the saved dir scopes. But not for hidden buf (ctx_win). + if ((cs->cs_flags & kCtxKeepDirs) && cs->cs_ctxwin_idx < 0) { + tabpage_T *dirs_tab = NULL; + FOR_ALL_TABS(tp) { + if (tp->handle == cs->cs_dirs_tab) { + dirs_tab = tp; + break; + } + } + ctx_localdirs_restore(cs, NULL, dirs_tab, false); + } + + // Restore the CWD itself. if (cs->cs_apply_acd) { xfree(cs->cs_save_sfname); do_autochdir(); - } else if (cs->cs_cwd_status == OK) { + } else if (cs->cs_cwd != NULL) { os_chdir(cs->cs_cwd); if (cs->cs_save_sfname != NULL) { xfree(curbuf->b_sfname); @@ -296,6 +386,11 @@ static void ctx_cwd_restore(CtxSwitch *cs) XFREE_CLEAR(cs->cs_cwd); } +void ctx_did_chdir(void) +{ + _ctx_did_chdir = true; +} + /// Return true if `win` is an active entry in ctx_win[] (the pool of temporary scratch windows). bool is_ctx_win(win_T *win) { @@ -343,16 +438,7 @@ static win_T *ctx_win_prep(CtxSwitch *cs, buf_T *buf) buf->b_nwindows++; win_init_empty(cw_win); // set cursor and topline to safe values - // Make sure w_localdir, b_localdir, tp_localdir, globaldir are NULL: the switched-to code runs - // in the actual cwd (no chdir on switch), and a pooled tmp-window must not carry a stale - // w_localdir. - XFREE_CLEAR(cw_win->w_localdir); - cs->cs_b_localdir = buf->b_localdir; - buf->b_localdir = NULL; - cs->cs_tp_localdir = curtab->tp_localdir; - curtab->tp_localdir = NULL; - cs->cs_globaldir = globaldir; - globaldir = NULL; + ctx_win_dirs_save(cs, cw_win, buf); if (need_append) { win_append(lastwin, cw_win, NULL); @@ -437,6 +523,8 @@ bool ctx_switch(CtxSwitch *cs, win_T *wp, tabpage_T *tp, buf_T *buf, CtxSwitchFl cs->cs_flags = flags; cs->cs_mode = buf != NULL ? kCtxSwitchBuf : kCtxSwitchWin; cs->cs_ctxwin_idx = -1; + cs->cs_did_chdir = _ctx_did_chdir; + _ctx_did_chdir = false; // 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 @@ -458,8 +546,10 @@ 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; } - if (flags & kCtxKeepCwd) { - ctx_cwd_save(cs, wp, tp == NULL ? curtab : tp); + // 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()). + if (buf == NULL || wp != NULL) { + ctx_dirs_save(cs, wp, tp == NULL ? curtab : tp, buf); } // Save the current state. @@ -580,21 +670,7 @@ void ctx_restore(CtxSwitch *cs) vars_clear(&cwp->w_vars->dv_hashtab); // free all w: variables hash_init(&cwp->w_vars->dv_hashtab); // re-use the hashtab - // If :lcd has been used in the autocommand window, correct current - // directory before restoring b_localdir, tp_localdir and globaldir. - if (cwp->w_localdir != NULL) { - update_cwd(kCdCauseWindow); - } - if (bufref_valid(&cs->cs_new_curbuf)) { - xfree(cs->cs_new_curbuf.br_buf->b_localdir); - cs->cs_new_curbuf.br_buf->b_localdir = cs->cs_b_localdir; - } else { - xfree(cs->cs_b_localdir); - } - xfree(curtab->tp_localdir); - curtab->tp_localdir = cs->cs_tp_localdir; - xfree(globaldir); - globaldir = cs->cs_globaldir; + ctx_localdirs_restore(cs, cwp, curtab, !(cs->cs_flags & kCtxKeepDirs)); // Buffer contents may have changed; cursor is checked below, AFTER restoring Visual state. if (curwin->w_topline > curbuf->b_ml.ml_line_count) { @@ -633,9 +709,12 @@ void ctx_restore(CtxSwitch *cs) if (cs->cs_flags & kCtxNoEvents) { unblock_autocmds(); } - if (cs->cs_flags & kCtxKeepCwd) { - ctx_cwd_restore(cs); + ctx_dirs_restore(cs); // No-op if ctx_dirs_save() saved nothing. + // Re-apply the restored context's effective directory. + if (cs->cs_ctxwin_idx < 0 && _ctx_did_chdir) { + update_cwd(kCdCauseWindow); } + _ctx_did_chdir = _ctx_did_chdir || cs->cs_did_chdir; if (cs->cs_flags & kCtxValidate) { // Update the status line if the cursor moved in the target window. win_T *const wp = win_find_by_handle(cs->cs_target_win); diff --git a/src/nvim/context_defs.h b/src/nvim/context_defs.h index 1bf2fd75da..cd84e65a75 100644 --- a/src/nvim/context_defs.h +++ b/src/nvim/context_defs.h @@ -45,15 +45,22 @@ typedef struct { /// Flags for ctx_switch(). typedef enum { + /// Restore process CWD: undo incidental chdir ('autochdir', "leaked" win/tab-local CWD). + /// + /// Note: this flag only exists for performance. Semantically every ctx-switch wants this, but the + /// getcwd() bookkeeping is costly for internal switches that don't run user code. + kCtxKeepCwd = 1, + /// Restore the target's full CWD state: undo all "chdir" operations on ctx_restore(), including + /// explicit :cd/:tcd/:bcd (which otherwise persist). + /// - Note: :lcd targeting a hidden buffer (temp window) is always discarded. + kCtxKeepDirs = 2, /// Don't affect the display (no redraw; limits access to another tabpage). - kCtxNoDisplay = 1, + kCtxNoDisplay = 4, /// Block autocommands until ctx_restore(). - kCtxNoEvents = 2, - /// Undo any chdir caused by the switch ('autochdir', win/tab-local CWD) on ctx_restore(). - kCtxKeepCwd = 4, + kCtxNoEvents = 8, /// Validate cursor/Visual around the switch; update display (statusline) if the target window's /// cursor moved. - kCtxValidate = 8, + kCtxValidate = 16, } CtxSwitchFlags; /// What ctx_switch() switched (set internally). @@ -77,16 +84,21 @@ 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 ctx_win[], 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 // State kept across the switch: - char *cs_b_localdir; ///< saved b_localdir of the target buffer (autocmd window) - char *cs_tp_localdir; ///< saved tp_localdir (autocmd window) - char *cs_globaldir; ///< saved globaldir (autocmd window) - char *cs_cwd; ///< saved cwd (kCtxKeepCwd; allocated on demand) - int cs_cwd_status; ///< OK if cs_cwd is valid - bool cs_apply_acd; ///< re-apply 'autochdir' on ctx_restore() - char *cs_save_sfname; ///< saved b_sfname (kCtxKeepCwd) + bool cs_did_chdir; ///< saved `ctx_did_chdir` of the enclosing context + handle_T cs_dirs_tab; ///< kCtxKeepDirs: tabpage that owns cs_tp_localdir. + // Saved dir state. Two users: + // 1. hidden-buffer target always saves b/tp/globaldir (so the temp context starts dir-neutral) + // 2. kCtxKeepDirs saves copies of all four. + char *cs_w_localdir; ///< Saved w_localdir of the target window + char *cs_b_localdir; ///< Saved b_localdir of the target buffer + char *cs_tp_localdir; ///< Saved tp_localdir + char *cs_globaldir; ///< Saved globaldir + char *cs_cwd; ///< Saved CWD (kCtxKeepCwd/kCtxKeepDirs). + bool cs_apply_acd; ///< Re-apply 'autochdir' on ctx_restore(). + char *cs_save_sfname; ///< Saved b_sfname (kCtxKeepCwd/kCtxKeepDirs). } CtxSwitch; diff --git a/src/nvim/eval/fs.c b/src/nvim/eval/fs.c index 6826ea4a39..f8724c9957 100644 --- a/src/nvim/eval/fs.c +++ b/src/nvim/eval/fs.c @@ -414,6 +414,8 @@ void f_chdir(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) scope = kCdScopeTabpage; } else if (strcmp(s, "window") == 0) { scope = kCdScopeWindow; + } else if (strcmp(s, "buffer") == 0) { + scope = kCdScopeBuffer; } else { semsg(_(e_invargNval), "scope", s); return; diff --git a/src/nvim/ex_docmd.c b/src/nvim/ex_docmd.c index 1da6864edd..42c5535992 100644 --- a/src/nvim/ex_docmd.c +++ b/src/nvim/ex_docmd.c @@ -6329,6 +6329,7 @@ bool changedir_func(char *new_dir, CdScope scope) *pp = pdir; post_chdir(scope, dir_differs); + ctx_did_chdir(); return true; } diff --git a/src/nvim/ex_getln.c b/src/nvim/ex_getln.c index a372e264a5..b043585205 100644 --- a/src/nvim/ex_getln.c +++ b/src/nvim/ex_getln.c @@ -2471,7 +2471,7 @@ static buf_T *cmdpreview_open_buf(void) // Rename preview buffer. CtxSwitch aco = { 0 }; - ctx_switch(&aco, NULL, NULL, cmdpreview_buf, 0); + ctx_switch(&aco, NULL, NULL, cmdpreview_buf, kCtxKeepDirs); int retv = rename_buffer("[Preview]"); ctx_restore(&aco); @@ -2480,7 +2480,7 @@ static buf_T *cmdpreview_open_buf(void) } // Temporarily switch to preview buffer to set it up for previewing. - ctx_switch(&aco, NULL, NULL, cmdpreview_buf, 0); + ctx_switch(&aco, NULL, NULL, cmdpreview_buf, kCtxKeepDirs); buf_clear(); curbuf->b_p_ma = true; curbuf->b_p_ul = -1; diff --git a/src/nvim/lua/stdlib.c b/src/nvim/lua/stdlib.c index aac888fc6b..394d5944e2 100644 --- a/src/nvim/lua/stdlib.c +++ b/src/nvim/lua/stdlib.c @@ -648,9 +648,10 @@ static int nlua_with(lua_State *L) if (win) { tabpage_T *tabpage = win_find_tabpage(win); - switched = ctx_switch(&cs, win, tabpage, NULL, kCtxNoDisplay | kCtxKeepCwd | kCtxValidate); + switched = ctx_switch(&cs, win, tabpage, NULL, + kCtxNoDisplay | kCtxValidate | kCtxKeepDirs); } else if (buf) { - ctx_switch(&cs, NULL, NULL, buf, 0); + ctx_switch(&cs, NULL, NULL, buf, kCtxKeepDirs); } if (switched) { diff --git a/test/functional/ex_cmds/cd_spec.lua b/test/functional/ex_cmds/cd_spec.lua index 6c8ff1ceb4..3ebdeeb08f 100644 --- a/test/functional/ex_cmds/cd_spec.lua +++ b/test/functional/ex_cmds/cd_spec.lua @@ -23,6 +23,7 @@ local directories = { } local tmpfile = 'Xtest-functional-ex_cmds-cd_spec-tmpfile' +local startdir ---@type string `getcwd()` at session start (set by `before_each`) local function join(...) return table.concat({ ... }, pathsep) @@ -57,29 +58,26 @@ local tlwd = function() end -- tab dir --local glwd = function() return eval('haslocaldir(-1, -1)') end -- global dir -local function before_test() +before_each(function() clear() for _, d in pairs(directories) do mkdir(d) end - directories.start = cwd() -end + startdir = cwd() +end) -local function remove_dirs() +after_each(function() for _, d in pairs(directories) do - vim.uv.fs_rmdir(d) + n.rmdir(d) end -end +end) -- Test both the `cd` and `chdir` variants for _, cmd in ipairs { 'cd', 'chdir' } do describe(':' .. cmd, function() - before_each(before_test) - after_each(remove_dirs) - describe('using explicit scope', function() it('for window', function() - local globalDir = directories.start + local globalDir = startdir local globalwin = call('winnr') local tabnr = call('tabpagenr') @@ -122,7 +120,7 @@ for _, cmd in ipairs { 'cd', 'chdir' } do end) it('for tab page', function() - local globalDir = directories.start + local globalDir = startdir local globaltab = call('tabpagenr') -- Everything matches globalDir to start @@ -152,7 +150,7 @@ for _, cmd in ipairs { 'cd', 'chdir' } do end) it('for buffer', function() - local globalDir = directories.start + local globalDir = startdir -- Create two buffers command(('e %s1'):format(tmpfile)) command(('e %s%s%s2'):format(directories.buffer, pathsep, tmpfile)) @@ -197,36 +195,36 @@ for _, cmd in ipairs { 'cd', 'chdir' } do describe('getcwd(-1, -1)', function() it('works', function() - eq(directories.start, cwd(-1, -1)) + eq(startdir, cwd(-1, -1)) eq(0, lwd(-1, -1)) end) it('works with tab-local pwd', function() command('silent t' .. cmd .. ' ' .. directories.tab) - eq(directories.start, cwd(-1, -1)) + eq(startdir, cwd(-1, -1)) eq(0, lwd(-1, -1)) end) it('works with window-local pwd', function() command('silent l' .. cmd .. ' ' .. directories.window) - eq(directories.start, cwd(-1, -1)) + eq(startdir, cwd(-1, -1)) eq(0, lwd(-1, -1)) end) it('works with buffer-local pwd', function() command(('silent b%s %s'):format(cmd, directories.buffer)) - eq(directories.start, cwd(-1, -1)) + eq(startdir, cwd(-1, -1)) eq(0, lwd(-1, -1)) -- Must behave the same if bufnr is -1 - eq(directories.start, cwd(-1, -1, -1)) + eq(startdir, cwd(-1, -1, -1)) eq(0, lwd(-1, -1, -1)) end) end) describe('Local directory gets inherited', function() it('by tabs', function() - local globalDir = directories.start + local globalDir = startdir -- Create a new tab and change directory command('tabnew') @@ -246,7 +244,7 @@ for _, cmd in ipairs { 'cd', 'chdir' } do end) it('works', function() - local globalDir = directories.start + local globalDir = startdir -- Create a new tab first and verify that is has the same working dir command('tabnew') eq(globalDir, cwd()) @@ -310,7 +308,7 @@ for _, cmd in ipairs { 'cd', 'chdir' } do end) it('works when mixing tab-local and buffer-local directories', function() - local globalDir = directories.start + local globalDir = startdir -- Create two buffers for testing. One in each tab command(('e %s1'):format(tmpfile)) @@ -354,7 +352,7 @@ for _, cmd in ipairs { 'cd', 'chdir' } do eq(0, blwd()) -- No window-buffer directory end) it('works when mixing window local and buffer local directories', function() - local globalDir = directories.start + local globalDir = startdir -- Create a new window first and verify that is has the same working directory command('new') eq(globalDir, cwd()) @@ -394,16 +392,13 @@ end for _, cmd in ipairs { 'bcd', 'bchdir' } do describe(':' .. cmd, function() - before_each(before_test) - after_each(remove_dirs) - it('works after deleting the only buffer', function() command(('%s %s'):format(cmd, directories.buffer)) command('bd') -- delete buffer end) it('buffer-local directory is NOT sticky/inherited', function() - local bufdir = join(directories.start, directories.buffer) + local bufdir = join(startdir, directories.buffer) command('edit ' .. tmpfile) command(('%s %s'):format(cmd, directories.buffer)) @@ -411,12 +406,12 @@ for _, cmd in ipairs { 'bcd', 'bchdir' } do -- A new buffer starts without a buffer-local directory. command('new') - eq(directories.start, cwd()) + eq(startdir, cwd()) eq(0, blwd()) command('close') eq(bufdir, cwd()) command('enew') - eq(directories.start, cwd()) + eq(startdir, cwd()) eq(0, blwd()) command('b# ') eq(bufdir, cwd()) @@ -426,19 +421,74 @@ for _, cmd in ipairs { 'bcd', 'bchdir' } do command(('%s %s'):format(cmd, directories.buffer)) eq(bufdir, cwd()) command('edit ' .. tmpfile .. '2') - eq(directories.start, cwd()) + eq(startdir, cwd()) eq(0, blwd()) end) end) end +describe('cd during temp context-switch', function() + it(':bcd/:tcd/:lcd persists in target scope, does not leak into original context', function() + local exec_lua = n.exec_lua + local bufdir = join(startdir, directories.buffer) + local windir = join(startdir, directories.window) + local tabdir = join(startdir, directories.tab) + + --- Creates a loaded, hidden buffer. + local function hidden_buf(name) + local b = call('bufadd', name) + call('bufload', b) + return b + end + + --- Runs `vim.cmd[cmd](dir)` with buffer `b` as temporary curbuf. + local function cd_in_buf_call(b, cmd, dir) + exec_lua(function(b_, cmd_, d) + vim.api.nvim_buf_call(b_, function() + vim.cmd[cmd_](d) + end) + end, b, cmd, dir) + end + + -- :bcd on a hidden buffer via nvim_buf_call() persists; the caller's cwd is unchanged. + local hidden = hidden_buf('Xtest-cd-hidden') + cd_in_buf_call(hidden, 'bcd', bufdir) + eq({ 1, bufdir, startdir }, { lwd(-1, -1, hidden), cwd(-1, -1, hidden), cwd() }) + + -- :lcd targets the temporary window, which is discarded; the caller's cwd is unchanged. + cd_in_buf_call(hidden, 'lcd', windir) + eq({ 0, startdir }, { wlwd(), cwd() }) + + -- :lcd via win_execute() persists on the target window; the CWD outside it is unchanged. + command('split') + call('win_execute', call('win_getid', 2), ('lcd %s'):format(windir)) + eq({ 1, windir, startdir }, { lwd(2), cwd(2), cwd() }) + command('only') + + -- An autocmd handler targeting a hidden buffer can set its buffer-local dir; the caller's + -- cwd is unchanged. + local hidden2 = hidden_buf('Xtest-cd-hidden2') + exec_lua(function(b, d) + vim.api.nvim_create_autocmd('TermRequest', { + buffer = b, + once = true, + callback = function() + vim.cmd.bcd(d) + end, + }) + vim.api.nvim_exec_autocmds('TermRequest', { buffer = b, data = { sequence = 'x' } }) + end, hidden2, bufdir) + eq({ 1, bufdir, startdir }, { lwd(-1, -1, hidden2), cwd(-1, -1, hidden2), cwd() }) + + -- :tcd via nvim_buf_call() persists, and the tab scope claims the new cwd. + cd_in_buf_call(hidden, 'tcd', tabdir) + eq({ 1, tabdir, tabdir }, { tlwd(), tcwd(), cwd() }) + end) +end) + -- Test legal parameters for 'getcwd' and 'haslocaldir' for _, cmd in ipairs { 'getcwd', 'haslocaldir' } do describe(cmd .. '()', function() - before_each(function() - clear() - end) - it('validation', function() local err474 = 'Vim:E474: Invalid argument' eq(err474, pcall_err(call, cmd, 'some string')) @@ -472,15 +522,6 @@ for _, cmd in ipairs { 'getcwd', 'haslocaldir' } do end describe('getcwd()', function() - before_each(function() - clear() - mkdir(directories.global) - end) - - after_each(function() - n.rmdir(directories.global) - end) - it('returns empty string if working directory does not exist', function() skip(is_os('win'), 'N/A for Windows') command('cd ' .. directories.global) diff --git a/test/functional/lua/with_spec.lua b/test/functional/lua/with_spec.lua index 2d134c3da3..4ac121ed69 100644 --- a/test/functional/lua/with_spec.lua +++ b/test/functional/lua/with_spec.lua @@ -344,6 +344,32 @@ describe('vim._with', function() ]]) eq(true, out) end) + + it('restores CWD state', function() + local out = exec_lua [[ + local other_buf, cur_buf = setup_buffers() + 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.cmd.bcd(dir) + error('oops') + end) + return { + fn.haslocaldir(-1, -1, other_buf), + fn.haslocaldir(-1, -1, cur_buf), + fn.getcwd() == cwd, + } + ]] + eq({ 0, 0, true }, out) + end) end) describe('`cwd` context', function() @@ -381,6 +407,19 @@ describe('vim._with', function() ]] eq({ true, true, true, true }, out) end) + + it('does not modify global CWD', function() + local out = exec_lua [[ + local other_buf, _ = setup_buffers() + local cwd = fn.getcwd() + -- Activate a window-local dir, so that the global dir must be remembered. + vim.cmd.lcd(vim.fs.joinpath(cwd, 'test')) + local lcd_cwd = fn.getcwd() -- Not necessarily `cwd .. '/test'`: symlinks are resolved. + vim._with({ buf = other_buf, cwd = vim.fs.joinpath(cwd, 'src') }, function() end) + return { fn.getcwd() == lcd_cwd, fn.getcwd(-1, -1) == cwd } + ]] + eq({ true, true }, out) + end) end) describe('`emsg_silent` context', function() @@ -1305,6 +1344,19 @@ describe('vim._with', function() exec_lua('vim._with({ win = ... }, function() vim.cmd.wincmd "J" end)', t2_move_win) eq({ 'col', { { 'leaf', t2_other_win }, { 'leaf', t2_move_win } } }, fn.winlayout(2)) end) + + it('restores CWD state', 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) + end) end) describe('`wo` context', function() diff --git a/test/old/testdir/test_cd.vim b/test/old/testdir/test_cd.vim index 21cb31e0e5..0a502ea980 100644 --- a/test/old/testdir/test_cd.vim +++ b/test/old/testdir/test_cd.vim @@ -104,6 +104,8 @@ func Test_chdir_func() call assert_match('^\[global\]', trim(execute('verbose pwd'))) call chdir('.', 'tabpage') call assert_match('^\[tabpage\]', trim(execute('verbose pwd'))) + call chdir('.', 'buffer') + call assert_match('^\[buffer\]', trim(execute('verbose pwd'))) call chdir('.', 'window') call assert_match('^\[window\]', trim(execute('verbose pwd')))