feat(cwd): support explicit chdir (:bcd/:tcd/…) in temp context

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.
This commit is contained in:
Justin M. Keyes
2026-08-05 11:20:05 +02:00
parent 93696ca903
commit 1c1dc0558f
10 changed files with 300 additions and 106 deletions

View File

@@ -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

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -6329,6 +6329,7 @@ bool changedir_func(char *new_dir, CdScope scope)
*pp = pdir;
post_chdir(scope, dir_differs);
ctx_did_chdir();
return true;
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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()

View File

@@ -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')))