From 838f130e6a0b018853c63e7908f98e53a2b7fa7e Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Tue, 4 Aug 2026 20:00:51 +0200 Subject: [PATCH 1/5] fix(types): emmylua warning about `params` field --- src/gen/gen_eval_files.lua | 10 +++++----- src/nvim/eval.lua | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/gen/gen_eval_files.lua b/src/gen/gen_eval_files.lua index 22f59e8425..89b13660da 100755 --- a/src/gen/gen_eval_files.lua +++ b/src/gen/gen_eval_files.lua @@ -98,7 +98,7 @@ local function split(x, sep) end --- @param f string ---- @param params [string,string][]|true +--- @param params [string,string,string?][]|true --- @return string local function render_fun_sig(f, params) local param_str --- @type string @@ -107,7 +107,7 @@ local function render_fun_sig(f, params) else param_str = table.concat( vim.tbl_map( - --- @param v [string,string] + --- @param v [string,string,string?] --- @return string function(v) return luaescape(v[1]) @@ -126,8 +126,8 @@ local function render_fun_sig(f, params) end --- Uniquify names ---- @param params [string,string,string][] ---- @return [string,string,string][] +--- @param params [string,string,string?][] +--- @return [string,string,string?][] local function process_params(params) local seen = {} --- @type table local sfx = 1 @@ -192,7 +192,7 @@ local function get_api_meta() sees[#sees + 1] = see.desc end - local params = {} --- @type [string,string][] + local params = {} --- @type [string,string,string?][] for _, p in ipairs(fun.params) do params[#params + 1] = { p.name, diff --git a/src/nvim/eval.lua b/src/nvim/eval.lua index 0321db3384..8834682e52 100644 --- a/src/nvim/eval.lua +++ b/src/nvim/eval.lua @@ -14,7 +14,7 @@ --- @field generics? string[] Used to write `---@generic` annotations over a function. --- @field signature? string --- @field desc? string ---- @field params [string, string, string][] +--- @field params [string, string, string?][] Each entry is `{name, type, desc?}`; `desc` is only present in API-meta entries. --- @field notes? string[] --- @field see? string[] --- From ea3868bcf99bbf381d22c06614bd7c6f4de55472 Mon Sep 17 00:00:00 2001 From: saher Date: Wed, 2 Apr 2025 18:17:15 +0300 Subject: [PATCH 2/5] feat(editor): `:bcd` changes buffer-local directory Problem: No way to set a buffer-local directory. Use-cases: - "Root dir" for LSP (and the "project" concept). - `:terminal` OSC 7 Solution: Add `:bcd` command. - Extend `getcwd()` to take a third arg; `getcwd(-1, -1, bunfr)` returns the buffer-local working directory. - Buffer-local directories have less priority than window-local ones, and more priority than tab-local ones. Co-authored-by: Justin M. Keyes --- runtime/doc/autocmd.txt | 9 +- runtime/doc/editing.txt | 16 +- runtime/doc/news.txt | 2 + runtime/doc/vim_diff.txt | 4 +- runtime/doc/vimfn.txt | 60 ++++-- runtime/lua/vim/_meta/vimfn.gen.lua | 60 ++++-- src/nvim/buffer.c | 12 ++ src/nvim/buffer_defs.h | 3 + src/nvim/context.c | 18 +- src/nvim/context_defs.h | 1 + src/nvim/errors.h | 2 + src/nvim/eval.lua | 65 ++++-- src/nvim/eval/fs.c | 226 +++++++++++++++----- src/nvim/ex_cmds.c | 18 ++ src/nvim/ex_cmds.lua | 12 ++ src/nvim/ex_docmd.c | 27 ++- src/nvim/ex_session.c | 31 ++- src/nvim/file_search.c | 4 + src/nvim/vim_defs.h | 10 +- src/nvim/window.c | 47 ++-- test/functional/autocmd/dirchanged_spec.lua | 224 ++++++++++++++++--- test/functional/ex_cmds/cd_spec.lua | 200 ++++++++++++++++- test/functional/ex_cmds/mksession_spec.lua | 67 +++++- 23 files changed, 940 insertions(+), 178 deletions(-) diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt index f73661ec4e..02c4ce80da 100644 --- a/runtime/doc/autocmd.txt +++ b/runtime/doc/autocmd.txt @@ -615,10 +615,11 @@ DiffUpdated After diffs have been updated. Depending on *DirChanged* DirChanged After the |current-directory| was changed. The pattern can be: - "window" to trigger on `:lcd` - "tabpage" to trigger on `:tcd` - "global" to trigger on `:cd` "auto" to trigger on 'autochdir'. + "buffer" to trigger on `:bcd` + "global" to trigger on `:cd` + "tabpage" to trigger on `:tcd` + "window" to trigger on `:lcd` Sets these |v:event| keys: cwd: current working directory scope: "global", "tabpage", "window" @@ -633,7 +634,7 @@ DirChangedPre When the |current-directory| is going to be The pattern is like with |DirChanged|. Sets these |v:event| keys: directory: new working directory - scope: "global", "tabpage", "window" + scope: "global", "tabpage", "window", "buffer" changed_window: v:true if we fired the event switching window (or tab) is set to the new directory name. diff --git a/runtime/doc/editing.txt b/runtime/doc/editing.txt index 6df3e0a588..cf2e6e96ec 100644 --- a/runtime/doc/editing.txt +++ b/runtime/doc/editing.txt @@ -1387,10 +1387,11 @@ scope in the hierarchy applies. *:tc* *:tcd* :tc[d][!] {path} Like |:cd|, but only set the directory for the current - tab. The current window will also use this directory. - The current directory is not changed for windows in - other tabs and for windows in the current tab that - have their own window-local directory. + tab. The current buffer and window will also use this + directory. The current directory is not changed for + windows in other tabs and for windows in the current + tab that have their own window-local directory, or for + buffers that have their own buffer-local directory. *:tcd-* :tc[d][!] - Change to the previous current directory (before the @@ -1399,10 +1400,15 @@ scope in the hierarchy applies. *:tch* *:tchdir* :tch[dir][!] Same as |:tcd|. + *:bc* *:bcd* +:bc[d][!] {path} Like |:cd|, but only set the current directory for the + current buffer. The current directory for other + windows or buffers or tabs is not changed. + *:lc* *:lcd* :lc[d][!] {path} Like |:cd|, but only set the current directory for the current window. The current directory for other - windows or tabs is not changed. + windows, tabs, or buffers is not changed. *:lch* *:lchdir* :lch[dir][!] Same as |:lcd|. diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 47355b4c5c..18c5b7e628 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -247,6 +247,8 @@ DIAGNOSTICS EDITOR +• |:bcd| sets a buffer-local directory. |getcwd()| and |haslocaldir()| take + `bufnr` as a third parameter. • |:command-completion-customlist| can return a list of dictionaries with kind/menu/info/abbr for the popup menu. • |cmdwin| (|q:|, |q/|, |q?|, |c_CTRL-F|) is implemented as a "normal" diff --git a/runtime/doc/vim_diff.txt b/runtime/doc/vim_diff.txt index f77c8a03c6..7570978367 100644 --- a/runtime/doc/vim_diff.txt +++ b/runtime/doc/vim_diff.txt @@ -695,8 +695,8 @@ Vimscript compatibility: Working directory (Vim implemented some of these after Nvim): - |DirChanged| and |DirChangedPre| can be triggered when switching to another window or tab. -- |getcwd()| and |haslocaldir()| may throw errors if the tabpage or window - cannot be found. *E5000* *E5001* *E5002* +- |getcwd()| and |haslocaldir()| may throw errors if the tabpage, window, or + buffer cannot be found. *E5000* *E5001* *E5002* *E5006* *E5007* - |haslocaldir()| checks for tab-local directory if and only if -1 is passed as window number, and its only possible returns values are 0 and 1. - `getcwd(-1)` is equivalent to `getcwd(-1, 0)` instead of returning the global diff --git a/runtime/doc/vimfn.txt b/runtime/doc/vimfn.txt index 0f53a2592b..f47ef1e82d 100644 --- a/runtime/doc/vimfn.txt +++ b/runtime/doc/vimfn.txt @@ -1117,6 +1117,8 @@ chdir({dir} [, {scope}]) *chdir()* changed to the scope of the current directory: - If the window local directory (|:lcd|) is set, it changes the current working directory for that scope. + - If the buffer local directory (|:bcd|) is set, it + changes the current working directory for that scope. - Otherwise, if the tabpage local directory (|:tcd|) is set, it changes the current directory for that scope. - Otherwise, changes the global directory for that scope. @@ -1124,6 +1126,7 @@ chdir({dir} [, {scope}]) *chdir()* If {scope} is present, changes the current working directory for the specified scope: "window" Changes the window local directory. |:lcd| + "buffer" Changes the buffer local directory. |:bcd| "tabpage" Changes the tabpage local directory. |:tcd| "global" Changes the global directory. |:cd| @@ -3745,31 +3748,46 @@ getcursorcharpos([{winid}]) *getcursorcharpos()* Return: ~ (`any`) -getcwd([{winnr} [, {tabnr}]]) *getcwd()* +getcwd([{winnr} [, {tabnr} [, {bufnr}]]]) *getcwd()* Lua: Prefer |uv.cwd()| for the global working directory; tab-local and window-local scopes differ. With no arguments, returns the name of the effective - |current-directory|. With {winnr} or {tabnr} the working - directory of that scope is returned, and 'autochdir' is - ignored. Tabs and windows are identified by their respective - numbers, 0 means current tab or window. Missing tab number + |current-directory|. With {winnr} or {tabnr} or {bufnr} the + working directory of that scope is returned, and 'autochdir' + is ignored. + + Tabs, windows and buffers are identified by their respective + numbers, 0 means current tab or window or buffer. Missing tab + number implies 0. Thus the following are equivalent: >vim getcwd(0) getcwd(0, 0) < If {winnr} is -1 it is ignored, only the tab is resolved. {winnr} is a |window-number| or |window-ID|. - If both {winnr} and {tabnr} are -1 the global working - directory is returned. + If both {winnr} and {tabnr} are -1 and {bufnr} is missing the + global working directory is returned. Note: When {tabnr} is -1 Vim returns an empty string to signal that it is invalid, whereas Nvim returns either the global working directory if {winnr} is -1 or the working directory of the window indicated by {winnr}. - Throw error if the arguments are invalid. |E5000| |E5001| |E5002| + + If {bufnr} is provided, {winnr} and {tabnr} must be -1 and the + working directory of that buffer is returned. If {bufnr} is + -1, it is ignored, and the global working directory is + returned. + Examples of buffer usage: >vim + getcwd(-1, -1, 0) " Get current buffer's directory + getcwd(-1, -1, 3) " Get directory of buffer #3 + getcwd(-1, -1, -1) " Get global directory + getcwd(-1, -1) " Get global directory +< Throw error if the arguments are invalid. + |E5000| |E5001| |E5002| |E5006| |E5007| Parameters: ~ • {winnr} (`integer?`) • {tabnr} (`integer?`) + • {bufnr} (`integer?`) Return: ~ (`string`) @@ -4994,15 +5012,16 @@ has_key({dict}, {key}) *has_key()* Return: ~ (`0|1`) -haslocaldir([{winnr} [, {tabnr}]]) *haslocaldir()* - Checks whether the window or tabpage has set a local working - directory. Returns 1 when the window has set a local path - via |:lcd| or when {winnr} is -1 and the tabpage has set a - local path via |:tcd|, otherwise 0. +haslocaldir([{winnr} [, {tabnr} [, {bufnr} ]]]) *haslocaldir()* + Checks whether the window, tabpage or buffer has set a local + working directory. Returns 1 when the window has set a local + path via |:lcd|, or when {winnr} is -1 and the tabpage has set + a local path via |:tcd|, or when {winnr} and {tabnr} are -1 + and {bufnr} has set a local path via |:bcd|, otherwise 0. - Tabs and windows are identified by their respective numbers, - 0 means current tab or window. Missing argument implies 0. - Thus the following are equivalent: >vim + Tabs, windows and buffers are identified by their respective + numbers, 0 means current tab or window. Missing argument + implies 0. Thus the following are equivalent: >vim echo haslocaldir() echo haslocaldir(0) echo haslocaldir(0, 0) @@ -5010,11 +5029,18 @@ haslocaldir([{winnr} [, {tabnr}]]) *haslocaldir()* With {winnr} and {tabnr} use the window in that tabpage. {winnr} is a |window-number| or |window-ID|. If {winnr} is -1 it is ignored, only the tab is resolved. - Throw error if the arguments are invalid. |E5000| |E5001| |E5002| + If {bufnr} is provided, {winnr} and {tabnr} must be -1 and + only the buffer is resolved. + Examples of buffer usage: >vim + haslocaldir(-1, -1, 0) " Current buf has a local directory? + haslocaldir(-1, -1, 3) " Buf #3 has a local directory? +< Throw error if the arguments are invalid. + |E5000| |E5001| |E5002| |E5006| |E5007| Parameters: ~ • {winnr} (`integer?`) • {tabnr} (`integer?`) + • {bufnr} (`integer?`) Return: ~ (`0|1`) diff --git a/runtime/lua/vim/_meta/vimfn.gen.lua b/runtime/lua/vim/_meta/vimfn.gen.lua index 092c495939..8eb79696ea 100644 --- a/runtime/lua/vim/_meta/vimfn.gen.lua +++ b/runtime/lua/vim/_meta/vimfn.gen.lua @@ -952,6 +952,8 @@ function vim.fn.charidx(string, idx, countcc, utf16) end --- changed to the scope of the current directory: --- - If the window local directory (|:lcd|) is set, it --- changes the current working directory for that scope. +--- - If the buffer local directory (|:bcd|) is set, it +--- changes the current working directory for that scope. --- - Otherwise, if the tabpage local directory (|:tcd|) is --- set, it changes the current directory for that scope. --- - Otherwise, changes the global directory for that scope. @@ -959,6 +961,7 @@ function vim.fn.charidx(string, idx, countcc, utf16) end --- If {scope} is present, changes the current working directory --- for the specified scope: --- "window" Changes the window local directory. |:lcd| +--- "buffer" Changes the buffer local directory. |:bcd| --- "tabpage" Changes the tabpage local directory. |:tcd| --- "global" Changes the global directory. |:cd| --- @@ -3318,27 +3321,42 @@ function vim.fn.getcursorcharpos(winid) end --- Lua: Prefer |uv.cwd()| for the global working directory; tab-local and window-local scopes differ. --- --- With no arguments, returns the name of the effective ---- |current-directory|. With {winnr} or {tabnr} the working ---- directory of that scope is returned, and 'autochdir' is ---- ignored. Tabs and windows are identified by their respective ---- numbers, 0 means current tab or window. Missing tab number +--- |current-directory|. With {winnr} or {tabnr} or {bufnr} the +--- working directory of that scope is returned, and 'autochdir' +--- is ignored. +--- +--- Tabs, windows and buffers are identified by their respective +--- numbers, 0 means current tab or window or buffer. Missing tab +--- number --- implies 0. Thus the following are equivalent: >vim --- getcwd(0) --- getcwd(0, 0) --- vim +--- getcwd(-1, -1, 0) " Get current buffer's directory +--- getcwd(-1, -1, 3) " Get directory of buffer #3 +--- getcwd(-1, -1, -1) " Get global directory +--- getcwd(-1, -1) " Get global directory +--- vim +--- Tabs, windows and buffers are identified by their respective +--- numbers, 0 means current tab or window. Missing argument +--- implies 0. Thus the following are equivalent: >vim --- echo haslocaldir() --- echo haslocaldir(0) --- echo haslocaldir(0, 0) @@ -4460,12 +4479,19 @@ function vim.fn.has_key(dict, key) end --- With {winnr} and {tabnr} use the window in that tabpage. --- {winnr} is a |window-number| or |window-ID|. --- If {winnr} is -1 it is ignored, only the tab is resolved. ---- Throw error if the arguments are invalid. |E5000| |E5001| |E5002| +--- If {bufnr} is provided, {winnr} and {tabnr} must be -1 and +--- only the buffer is resolved. +--- Examples of buffer usage: >vim +--- haslocaldir(-1, -1, 0) " Current buf has a local directory? +--- haslocaldir(-1, -1, 3) " Buf #3 has a local directory? +--- b_s); // reset syntax info buf->b_flags &= ~BF_READERR; // a read error is no longer relevant + + XFREE_CLEAR(buf->b_localdir); + XFREE_CLEAR(buf->b_prevdir); return true; } @@ -1023,6 +1026,8 @@ static void free_buffer_stuff(buf_T *buf, int free_flags) map_clear_mode(buf, MAP_ALL_MODES, true, false); // clear local mappings map_clear_mode(buf, MAP_ALL_MODES, true, true); // clear local abbrevs XFREE_CLEAR(buf->b_start_fenc); + XFREE_CLEAR(buf->b_localdir); + XFREE_CLEAR(buf->b_prevdir); buf_free_callbacks(buf); } @@ -1771,6 +1776,9 @@ void set_curbuf(buf_T *buf, int action, bool update_jumplist) if (bufref_valid(&prevbufref) && prevbuf->terminal != NULL) { terminal_check_size(prevbuf->terminal); } + + // Maybe cd to buffer-local directory + fix_current_dir(false); } /// Enter a new current buffer. @@ -2023,6 +2031,8 @@ buf_T *buflist_new(char *ffname_arg, char *sfname_arg, linenr_T lnum, int flags) trigger_undo_ftplugin(buf, curwin); // It's like this buffer is deleted. Watch out for autocommands that // change curbuf! If that happens, allocate a new buffer anyway. + // We also ask it to not free the buffer-local directory so we can reuse + // it. buf_freeall(buf, BFA_WIPE | BFA_DEL); if (aborting()) { // autocmds may abort script processing xfree(ffname); @@ -2152,6 +2162,8 @@ bool curbuf_reusable(void) && curbuf->b_ffname == NULL && curbuf->b_nwindows <= 1 && !curbuf->terminal + && curbuf->b_localdir == NULL + && curbuf->b_prevdir == NULL && (curbuf->b_ml.ml_mfp == NULL || buf_is_empty(curbuf)) && !bt_quickfix(curbuf) && !curbufIsChanged()); diff --git a/src/nvim/buffer_defs.h b/src/nvim/buffer_defs.h index a20e3f358a..2699e1d97b 100644 --- a/src/nvim/buffer_defs.h +++ b/src/nvim/buffer_defs.h @@ -767,6 +767,9 @@ struct file_buffer { // The number for times the current line has been flushed in the memline. int flush_count; + + char *b_localdir; ///< Absolute path of local cwd or NULL. + char *b_prevdir; ///< Previous directory. }; // Stuff for diff mode. diff --git a/src/nvim/context.c b/src/nvim/context.c index 19d3712847..769b4b31f5 100644 --- a/src/nvim/context.c +++ b/src/nvim/context.c @@ -256,6 +256,7 @@ static void ctx_cwd_save(CtxSwitch *cs, win_T *wp, tabpage_T *tp) 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); @@ -342,9 +343,12 @@ 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, 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. + // 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; @@ -577,9 +581,15 @@ void ctx_restore(CtxSwitch *cs) 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 tp_localdir and globaldir. + // directory before restoring b_localdir, tp_localdir and globaldir. if (cwp->w_localdir != NULL) { - win_fix_current_dir(); + fix_current_dir(true); + } + 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; diff --git a/src/nvim/context_defs.h b/src/nvim/context_defs.h index 1248626936..1bf2fd75da 100644 --- a/src/nvim/context_defs.h +++ b/src/nvim/context_defs.h @@ -82,6 +82,7 @@ typedef struct { 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) diff --git a/src/nvim/errors.h b/src/nvim/errors.h index 48d02ebc61..3166029b48 100644 --- a/src/nvim/errors.h +++ b/src/nvim/errors.h @@ -20,6 +20,8 @@ EXTERN const char e_invalid_buffer_name_str[] INIT(= N_("E158: Invalid buffer na EXTERN const char e_command_too_recursive[] INIT(= N_("E169: Command too recursive")); EXTERN const char e_buffer_nr_invalid_buffer_number[] INIT(= N_("E680: : invalid buffer number")); EXTERN const char e_buffer_is_not_loaded[] INIT(= N_("E681: Buffer is not loaded")); +EXTERN const char e_getcwd_buffer_scope_requires_minus_one[] INIT(= N_("E5006: Window and tab scope must be -1 when using buffer scope")); +EXTERN const char e_cannot_find_buffer_number[] INIT(= N_("E5007: Cannot find buffer number.")); EXTERN const char e_endif[] INIT(= N_("E171: Missing :endif")); EXTERN const char e_endtry[] INIT(= N_("E600: Missing :endtry")); EXTERN const char e_endwhile[] INIT(= N_("E170: Missing :endwhile")); diff --git a/src/nvim/eval.lua b/src/nvim/eval.lua index 8834682e52..8ed0e37f67 100644 --- a/src/nvim/eval.lua +++ b/src/nvim/eval.lua @@ -1257,6 +1257,8 @@ M.funcs = { changed to the scope of the current directory: - If the window local directory (|:lcd|) is set, it changes the current working directory for that scope. + - If the buffer local directory (|:bcd|) is set, it + changes the current working directory for that scope. - Otherwise, if the tabpage local directory (|:tcd|) is set, it changes the current directory for that scope. - Otherwise, changes the global directory for that scope. @@ -1264,6 +1266,7 @@ M.funcs = { If {scope} is present, changes the current working directory for the specified scope: "window" Changes the window local directory. |:lcd| + "buffer" Changes the buffer local directory. |:bcd| "tabpage" Changes the tabpage local directory. |:tcd| "global" Changes the global directory. |:cd| @@ -4089,32 +4092,45 @@ M.funcs = { signature = 'getcursorcharpos([{winid}])', }, getcwd = { - args = { 0, 2 }, + args = { 0, 3 }, base = 1, desc = [=[ With no arguments, returns the name of the effective - |current-directory|. With {winnr} or {tabnr} the working - directory of that scope is returned, and 'autochdir' is - ignored. Tabs and windows are identified by their respective - numbers, 0 means current tab or window. Missing tab number + |current-directory|. With {winnr} or {tabnr} or {bufnr} the + working directory of that scope is returned, and 'autochdir' + is ignored. + + Tabs, windows and buffers are identified by their respective + numbers, 0 means current tab or window or buffer. Missing tab + number implies 0. Thus the following are equivalent: >vim getcwd(0) getcwd(0, 0) vim + getcwd(-1, -1, 0) " Get current buffer's directory + getcwd(-1, -1, 3) " Get directory of buffer #3 + getcwd(-1, -1, -1) " Get global directory + getcwd(-1, -1) " Get global directory + vim + Tabs, windows and buffers are identified by their respective + numbers, 0 means current tab or window. Missing argument + implies 0. Thus the following are equivalent: >vim echo haslocaldir() echo haslocaldir(0) echo haslocaldir(0, 0) @@ -5428,13 +5445,19 @@ M.funcs = { With {winnr} and {tabnr} use the window in that tabpage. {winnr} is a |window-number| or |window-ID|. If {winnr} is -1 it is ignored, only the tab is resolved. - Throw error if the arguments are invalid. |E5000| |E5001| |E5002| + If {bufnr} is provided, {winnr} and {tabnr} must be -1 and + only the buffer is resolved. + Examples of buffer usage: >vim + haslocaldir(-1, -1, 0) " Current buf has a local directory? + haslocaldir(-1, -1, 3) " Buf #3 has a local directory? + #include "auto/config.h" +#include "nvim/api/private/helpers.h" #include "nvim/ascii_defs.h" #include "nvim/buffer_defs.h" #include "nvim/cmdexpand.h" @@ -419,6 +420,8 @@ void f_chdir(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) } } else if (curwin->w_localdir != NULL) { scope = kCdScopeWindow; + } else if (curbuf->b_localdir != NULL) { + scope = kCdScopeBuffer; } else if (curtab->tp_localdir != NULL) { scope = kCdScopeTabpage; } @@ -641,12 +644,12 @@ void f_fnamemodify(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) xfree(fbuf); } -/// `getcwd([{win}[, {tab}]])` function +/// `getcwd([{win}[, {tab}[, {buf}]]])` function /// /// Every scope not specified implies the currently selected scope object. /// /// @pre The arguments must be of type number. -/// @pre There may not be more than two arguments. +/// @pre There may not be more than three arguments. /// @pre An argument may not be -1 if preceding arguments are not all -1. /// /// @post The return value will be a string. @@ -655,50 +658,103 @@ void f_getcwd(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) // Possible scope of working directory to return. CdScope scope = kCdScopeInvalid; - // Numbers of the scope objects (window, tab) we want the working directory - // of. A `-1` means to skip this scope, a `0` means the current object. - int scope_number[] = { - [kCdScopeWindow] = 0, // Number of window to look at. - [kCdScopeTabpage] = 0, // Number of tab to look at. + // Numbers of the scope objects (window, buffer, tab) we want the working + // directory of. A `-1` means to skip this scope, a `0` means the current object. + + // getcwd() takes arguments in this order: (window, tab, buffer) + // Note that this is different from the order of CdScope + enum { + WINDOW_IDX = 0, + TABPAGE_IDX = 1, + BUFFER_IDX = 2, }; + int argv[] = { // arguments passed to getcwd(). + 0, // Number of window to look at. + 0, // Number of tab to look at. + 0, // Number of buffer to look at. + }; + int argc = 0; // number of arguments passed to getcwd(). + char *cwd = NULL; // Current working directory to print char *from = NULL; // The original string to copy tabpage_T *tp = curtab; // The tabpage to look at. win_T *win = curwin; // The window to look at. + buf_T *buf = curbuf; // The buffer to look at. rettv->v_type = VAR_STRING; rettv->vval.v_string = NULL; - // Pre-conditions and scope extraction together - for (int i = MIN_CD_SCOPE; i < MAX_CD_SCOPE; i++) { + // Pre-conditions + for (; argc < 3; argc++) { // If there is no argument there are no more scopes after it, break out. - if (argvars[i].v_type == VAR_UNKNOWN) { + if (argvars[argc].v_type == VAR_UNKNOWN) { break; } - if (argvars[i].v_type != VAR_NUMBER) { + if (argvars[argc].v_type != VAR_NUMBER) { emsg(_(e_invarg)); return; } - scope_number[i] = (int)argvars[i].vval.v_number; + argv[argc] = (int)argvars[argc].vval.v_number; // It is an error for the scope number to be less than `-1`. - if (scope_number[i] < -1) { + if (argv[argc] < -1) { emsg(_(e_invarg)); return; } - // Use the narrowest scope the user requested - if (scope_number[i] >= 0 && scope == kCdScopeInvalid) { - // The scope is the current iteration step. - scope = i; - } else if (scope_number[i] < 0) { - scope = i + 1; + } + + // Scope extraction + // Imagine X >= 0 + switch (argc) { + case 0: + scope = kCdScopeInvalid; // getcwd() + break; + case 1: + if (argv[WINDOW_IDX] > -1) { + scope = kCdScopeWindow; // getcwd(X) + } else { + scope = kCdScopeTabpage; // getcwd(-1) + } + break; + case 2: + if (argv[WINDOW_IDX] > -1) { + scope = kCdScopeWindow; // getcwd(X, ...) + } else if (argv[TABPAGE_IDX] > -1) { + scope = kCdScopeTabpage; // getcwd(-1, X) + } else { + scope = kCdScopeGlobal; // getcwd(-1, -1) + } + break; + case 3: + if (argv[BUFFER_IDX] > -1) { + scope = kCdScopeBuffer; // getcwd(..., ..., X) + } else { + scope = kCdScopeGlobal; // getcwd(..., ..., -1) + } + break; + } + + // getcwd(-1, -1, X) + if (scope == kCdScopeBuffer) { + if (argv[WINDOW_IDX] >= 0 || argv[TABPAGE_IDX] >= 0) { + emsg(_(e_getcwd_buffer_scope_requires_minus_one)); + return; + } + if (argv[BUFFER_IDX] > 0) { + Error err = ERROR_INIT; + buf = find_buffer_by_handle(argv[BUFFER_IDX], &err); + if (ERROR_SET(&err)) { + emsg(_(e_cannot_find_buffer_number)); + xfree(err.msg); + return; + } } } // Find the tabpage by number - if (scope_number[kCdScopeTabpage] > 0) { - tp = find_tabpage(scope_number[kCdScopeTabpage]); + if (argv[TABPAGE_IDX] > 0) { + tp = find_tabpage(argv[TABPAGE_IDX]); if (!tp) { emsg(_("E5000: Cannot find tab number.")); return; @@ -706,19 +762,21 @@ void f_getcwd(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) } // Find the window in `tp` by number, `NULL` if none. - if (scope_number[kCdScopeWindow] >= 0) { - if (scope_number[kCdScopeTabpage] < 0) { + if (argv[WINDOW_IDX] >= 0) { + if (argv[TABPAGE_IDX] < 0) { emsg(_("E5001: Higher scope cannot be -1 if lower scope is >= 0.")); return; } - if (scope_number[kCdScopeWindow] > 0) { + if (argv[WINDOW_IDX] > 0) { win = find_win_by_nr(&argvars[0], tp); if (!win) { emsg(_("E5002: Cannot find window number.")); return; } } + // Window scope may fall through to the buffer shown in that window. + buf = win->w_buffer; } cwd = xmalloc(MAXPATHL); @@ -731,6 +789,13 @@ void f_getcwd(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) break; } FALLTHROUGH; + case kCdScopeBuffer: + assert(buf); + from = buf->b_localdir; + if (from) { + break; + } + FALLTHROUGH; case kCdScopeTabpage: assert(tp); from = tp->tp_localdir; @@ -959,7 +1024,7 @@ void f_glob2regpat(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) rettv->vval.v_string = pat == NULL ? NULL : file_pat_to_reg_pat(pat, NULL, NULL, false); } -/// `haslocaldir([{win}[, {tab}]])` function +/// `haslocaldir([{win}[, {tab}[, {buf}]]])` function /// /// Returns `1` if the scope object has a local directory, `0` otherwise. If a /// scope object is not specified the current one is implied. This function @@ -977,62 +1042,113 @@ void f_haslocaldir(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) // Numbers of the scope objects (window, tab) we want the working directory // of. A `-1` means to skip this scope, a `0` means the current object. - int scope_number[] = { - [kCdScopeWindow] = 0, // Number of window to look at. - [kCdScopeTabpage] = 0, // Number of tab to look at. + + // haslocaldir() takes arguments in this order: (window, tab, buffer) + // Note that this is different from the order of CdScope + enum { + WINDOW_IDX = 0, + TABPAGE_IDX = 1, + BUFFER_IDX = 2, }; + int argv[] = { // arguments passed to haslocaldir(). + 0, // Number of window to look at. + 0, // Number of tab to look at. + 0, // Number of buffer to look at. + }; + int argc = 0; // number of arguments passed to haslocaldir. + tabpage_T *tp = curtab; // The tabpage to look at. - win_T *win = curwin; // The window to look at. + win_T *win = curwin; // The window to look at. + buf_T *buf = curbuf; // The buffer to look at. rettv->v_type = VAR_NUMBER; rettv->vval.v_number = 0; - // Pre-conditions and scope extraction together - for (int i = MIN_CD_SCOPE; i < MAX_CD_SCOPE; i++) { - if (argvars[i].v_type == VAR_UNKNOWN) { + // Pre-conditions + for (; argc < 3; argc++) { + if (argvars[argc].v_type == VAR_UNKNOWN) { break; } - if (argvars[i].v_type != VAR_NUMBER) { + if (argvars[argc].v_type != VAR_NUMBER) { emsg(_(e_invarg)); return; } - scope_number[i] = (int)argvars[i].vval.v_number; - if (scope_number[i] < -1) { + argv[argc] = (int)argvars[argc].vval.v_number; + if (argv[argc] < -1) { emsg(_(e_invarg)); return; } - // Use the narrowest scope the user requested - if (scope_number[i] >= 0 && scope == kCdScopeInvalid) { - // The scope is the current iteration step. - scope = i; - } else if (scope_number[i] < 0) { - scope = i + 1; - } } - // If the user didn't specify anything, default to window scope - if (scope == kCdScopeInvalid) { - scope = MIN_CD_SCOPE; + // Scope extraction + // Imagine X >= 0 + switch (argc) { + case 0: + // If the user didn't specify anything, default to window scope + scope = kCdScopeWindow; // haslocaldir() + break; + case 1: + if (argv[0] > -1) { + scope = kCdScopeWindow; // haslocaldir(X) + } else { + scope = kCdScopeTabpage; // haslocaldir(-1) + } + break; + case 2: + if (argv[0] > -1) { + scope = kCdScopeWindow; // haslocaldir(X, ...) + } else if (argv[1] > -1) { + scope = kCdScopeTabpage; // haslocaldir(-1, X) + } else { + scope = kCdScopeGlobal; // haslocaldir(-1, -1) + } + break; + case 3: + if (argv[2] > -1) { + scope = kCdScopeBuffer; // haslocaldir(..., ..., X) + } else { + scope = kCdScopeGlobal; // haslocaldir(..., ..., -1) + } + break; + } + + // haslocaldir(-1, -1, X) + if (scope == kCdScopeBuffer) { + if (argv[WINDOW_IDX] >= 0 || argv[TABPAGE_IDX] >= 0) { + emsg(_(e_getcwd_buffer_scope_requires_minus_one)); + return; + } + if (argv[BUFFER_IDX] > 0) { + Error err = ERROR_INIT; + buf = find_buffer_by_handle(argv[BUFFER_IDX], &err); + if (ERROR_SET(&err)) { + emsg(_(e_cannot_find_buffer_number)); + xfree(err.msg); + return; + } + } } // Find the tabpage by number - if (scope_number[kCdScopeTabpage] > 0) { - tp = find_tabpage(scope_number[kCdScopeTabpage]); - if (!tp) { - emsg(_("E5000: Cannot find tab number.")); - return; + if (argv[TABPAGE_IDX] >= 0) { + if (argv[TABPAGE_IDX] > 0) { + tp = find_tabpage(argv[TABPAGE_IDX]); + if (!tp) { + emsg(_("E5000: Cannot find tab number.")); + return; + } } } // Find the window in `tp` by number, `NULL` if none. - if (scope_number[kCdScopeWindow] >= 0) { - if (scope_number[kCdScopeTabpage] < 0) { + if (argv[WINDOW_IDX] >= 0) { + if (argv[TABPAGE_IDX] < 0) { emsg(_("E5001: Higher scope cannot be -1 if lower scope is >= 0.")); return; } - if (scope_number[kCdScopeWindow] > 0) { + if (argv[WINDOW_IDX] > 0) { win = find_win_by_nr(&argvars[0], tp); if (!win) { emsg(_("E5002: Cannot find window number.")); @@ -1046,6 +1162,10 @@ void f_haslocaldir(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) assert(win); rettv->vval.v_number = win->w_localdir ? 1 : 0; break; + case kCdScopeBuffer: + assert(buf); + rettv->vval.v_number = buf->b_localdir ? 1 : 0; + break; case kCdScopeTabpage: assert(tp); rettv->vval.v_number = tp->tp_localdir ? 1 : 0; diff --git a/src/nvim/ex_cmds.c b/src/nvim/ex_cmds.c index 18cb5c534a..e5584a5353 100644 --- a/src/nvim/ex_cmds.c +++ b/src/nvim/ex_cmds.c @@ -2937,6 +2937,24 @@ int do_ecmd(int fnum, char *ffname, char *sfname, exarg_T *eap, linenr_T newlnum redraw_curbuf_later(UPD_NOT_VALID); // redraw this buffer later } + // A new buffer inherits the buffer-local directory of the buffer it was created from (unless + // autocommands already set one). + if (!oldbuf && curbuf->b_localdir == NULL + && bufref_valid(&old_curbuf) + && old_curbuf.br_buf != curbuf + && old_curbuf.br_buf->b_localdir != NULL) { + curbuf->b_localdir = xstrdup(old_curbuf.br_buf->b_localdir); + if (old_curbuf.br_buf->b_prevdir != NULL) { + curbuf->b_prevdir = xstrdup(old_curbuf.br_buf->b_prevdir); + } + } + + // If editing a buffer in the current window, make sure to update to the + // buffer's working directory. + if (oldwin == curwin) { + fix_current_dir(false); + } + // Change directories when the 'acd' option is set. do_autochdir(); diff --git a/src/nvim/ex_cmds.lua b/src/nvim/ex_cmds.lua index bba712b334..a5767c1876 100644 --- a/src/nvim/ex_cmds.lua +++ b/src/nvim/ex_cmds.lua @@ -192,6 +192,18 @@ M.cmds = { addr_type = 'ADDR_NONE', func = 'ex_edit', }, + { + command = 'bcd', + flags = bit.bor(BANG, FILE1, TRLBAR, BUFLOCK_OK, LOCK_OK), + addr_type = 'ADDR_NONE', + func = 'ex_cd', + }, + { + command = 'bchdir', + flags = bit.bor(BANG, FILE1, TRLBAR, BUFLOCK_OK, LOCK_OK), + addr_type = 'ADDR_NONE', + func = 'ex_cd', + }, { command = 'bdelete', flags = bit.bor(BANG, RANGE, BUFNAME, COUNT, EXTRA, TRLBAR), diff --git a/src/nvim/ex_docmd.c b/src/nvim/ex_docmd.c index a446d2ecf7..8f9fa7fcfb 100644 --- a/src/nvim/ex_docmd.c +++ b/src/nvim/ex_docmd.c @@ -6212,6 +6212,9 @@ static char *get_prevdir(CdScope scope) case kCdScopeTabpage: return curtab->tp_prevdir; break; + case kCdScopeBuffer: + return curbuf->b_prevdir; + break; case kCdScopeWindow: return curwin->w_prevdir; break; @@ -6222,7 +6225,7 @@ static char *get_prevdir(CdScope scope) /// Deal with the side effects of changing the current directory. /// -/// @param scope Scope of the function call (global, tab or window). +/// @param scope Scope of the function call (global, tab, buffer or window). static void post_chdir(CdScope scope, bool trigger_dirchanged) { // Always overwrite the window-local CWD. @@ -6233,6 +6236,10 @@ static void post_chdir(CdScope scope, bool trigger_dirchanged) XFREE_CLEAR(curtab->tp_localdir); } + if (scope >= kCdScopeBuffer) { + XFREE_CLEAR(curbuf->b_localdir); + } + if (scope < kCdScopeGlobal) { char *pdir = get_prevdir(scope); // If still in global directory, set CWD as the global directory. @@ -6253,6 +6260,9 @@ static void post_chdir(CdScope scope, bool trigger_dirchanged) case kCdScopeTabpage: curtab->tp_localdir = xstrdup(cwd); break; + case kCdScopeBuffer: + curbuf->b_localdir = xstrdup(cwd); + break; case kCdScopeWindow: curwin->w_localdir = xstrdup(cwd); break; @@ -6268,9 +6278,9 @@ static void post_chdir(CdScope scope, bool trigger_dirchanged) } } -/// Change directory function used by :cd/:tcd/:lcd Ex commands and the chdir() function. +/// Change directory function used by :cd/:tcd/:bcd/:lcd Ex commands and the chdir() function. /// @param new_dir The directory to change to. -/// @param scope Scope of the function call (global, tab or window). +/// @param scope Scope of the function call (global, tab, buffer or window). /// @return true if the directory is successfully changed. bool changedir_func(char *new_dir, CdScope scope) { @@ -6325,6 +6335,9 @@ bool changedir_func(char *new_dir, CdScope scope) case kCdScopeWindow: pp = &curwin->w_prevdir; break; + case kCdScopeBuffer: + pp = &curbuf->b_prevdir; + break; default: pp = &prev_dir; } @@ -6336,7 +6349,7 @@ bool changedir_func(char *new_dir, CdScope scope) return true; } -/// ":cd", ":tcd", ":lcd", ":chdir", "tchdir" and ":lchdir". +/// ":cd", ":tcd", ":bcd", ":lcd", ":chdir", "tchdir", ":bchdir", and ":lchdir". void ex_cd(exarg_T *eap) { char *new_dir = eap->arg; @@ -6352,6 +6365,10 @@ void ex_cd(exarg_T *eap) case CMD_tchdir: scope = kCdScopeTabpage; break; + case CMD_bcd: + case CMD_bchdir: + scope = kCdScopeBuffer; + break; case CMD_lcd: case CMD_lchdir: scope = kCdScopeWindow; @@ -6380,6 +6397,8 @@ static void ex_pwd(exarg_T *eap) context = last_chdir_reason; } else if (curwin->w_localdir != NULL) { context = "window"; + } else if (curbuf->b_localdir != NULL) { + context = "buffer"; } else if (curtab->tp_localdir != NULL) { context = "tabpage"; } diff --git a/src/nvim/ex_session.c b/src/nvim/ex_session.c index 0fdf3ee146..97ce4a9e37 100644 --- a/src/nvim/ex_session.c +++ b/src/nvim/ex_session.c @@ -822,9 +822,8 @@ static int makeopens(FILE *fd, char *dirnow) return FAIL; } - // Restore the tab-local working directory if specified - // Do this before the windows, so that the window-local directory can - // override the tab-local directory. + // Restore the tab-local working directory if specified. Do this before the buffers and windows, + // so the buf-local / win-local directory can override the tab-local directory. if ((ssop_flags & kOptSsopFlagCurdir) && tp->tp_localdir != NULL) { if (fputs("tcd ", fd) < 0 || ses_put_fname(fd, tp->tp_localdir, &ssop_flags) == FAIL @@ -834,6 +833,32 @@ static int makeopens(FILE *fd, char *dirnow) did_lcd = true; } + // Restore buffer-local CWD (:bcd) via a one-shot BufEnter handler: there is no reliable point + // during session-load where all buffers are entered. Re-applied per tab, AFTER the ":tcd" + // above: ":tcd" clears the b_localdir of the then-current buffer, which may be a buffer whose + // autocmd already fired. + if (ssop_flags & kOptSsopFlagCurdir) { + FOR_ALL_BUFFERS(buf) { + if (buf->b_localdir == NULL || buf->b_fname == NULL || !buf->b_p_bl + || (only_save_windows && buf->b_nwindows == 0) + || (buf->b_help && !(ssop_flags & kOptSsopFlagHelp)) + || (bt_terminal(buf) && !(ssop_flags & kOptSsopFlagTerminal))) { + continue; + } + // `bufadd()` finds the buffer by exact (literal) name. + if (fputs("lua vim.api.nvim_create_autocmd('BufEnter', { once = true, " + "buffer = vim.fn.bufadd([=[", fd) < 0 + || fputs(ses_get_fname(buf, &ssop_flags), fd) < 0 + || fputs("]=]), callback = function() vim.cmd.bcd({ [=[", fd) < 0 + || fputs(buf->b_localdir, fd) < 0 + || fputs("]=], magic = { file = false, bar = false } }) end })\n", fd) < 0) { + return FAIL; + } + // Cwd may change when the autocmd fires: filenames must be written absolute from here on. + did_lcd = true; + } + } + // Restore the view of the window (options, file, cursor, etc.). for (win_T *wp = tab_firstwin; wp != NULL; wp = wp->w_next) { if (!ses_do_win(wp)) { diff --git a/src/nvim/file_search.c b/src/nvim/file_search.c index 01f2fe131f..fc4ee0b3cf 100644 --- a/src/nvim/file_search.c +++ b/src/nvim/file_search.c @@ -1854,6 +1854,9 @@ void do_autocmd_dirchanged(char *new_dir, CdScope scope, CdCause cause, bool pre case kCdScopeTabpage: snprintf(buf, sizeof(buf), "tabpage"); break; + case kCdScopeBuffer: + snprintf(buf, sizeof(buf), "buffer"); + break; case kCdScopeWindow: snprintf(buf, sizeof(buf), "window"); break; @@ -1874,6 +1877,7 @@ void do_autocmd_dirchanged(char *new_dir, CdScope scope, CdCause cause, bool pre switch (cause) { case kCdCauseManual: case kCdCauseWindow: + case kCdCauseBuffer: break; case kCdCauseAuto: snprintf(buf, sizeof(buf), "auto"); diff --git a/src/nvim/vim_defs.h b/src/nvim/vim_defs.h index 0eadcf5b03..ae33e4f2cd 100644 --- a/src/nvim/vim_defs.h +++ b/src/nvim/vim_defs.h @@ -33,23 +33,21 @@ typedef enum { /// /// Scopes are enumerated from lowest to highest. When adding a scope make sure /// to update all functions using scopes as well, such as the implementation of -/// `getcwd()`. When using scopes as limits (e.g. in loops) don't use the scopes -/// directly, use `MIN_CD_SCOPE` and `MAX_CD_SCOPE` instead. +/// `getcwd()`. typedef enum { kCdScopeInvalid = -1, kCdScopeWindow, ///< Affects one window. + kCdScopeBuffer, ///< Affects one buffer. kCdScopeTabpage, ///< Affects one tab page. kCdScopeGlobal, ///< Affects the entire Nvim instance. } CdScope; -#define MIN_CD_SCOPE kCdScopeWindow -#define MAX_CD_SCOPE kCdScopeGlobal - /// What caused the current directory to change. typedef enum { kCdCauseOther = -1, - kCdCauseManual, ///< Using `:cd`, `:tcd`, `:lcd` or `chdir()`. + kCdCauseManual, ///< Using `:cd`, `:bcd`, `:tcd`, `:lcd` or `chdir()`. kCdCauseWindow, ///< Switching to another window. + kCdCauseBuffer, ///< Switching to another buffer. kCdCauseAuto, ///< On 'autochdir'. } CdCause; diff --git a/src/nvim/window.c b/src/nvim/window.c index 5e18af5b2d..d9b01af856 100644 --- a/src/nvim/window.c +++ b/src/nvim/window.c @@ -5300,7 +5300,7 @@ static void win_enter_ext(win_T *const wp, const int flags) win_fix_cursor(get_real_state() & (MODE_NORMAL|MODE_CMDLINE|MODE_TERMINAL)); } - win_fix_current_dir(); + fix_current_dir(true); entering_window(curwin); // Careful: autocommands may close the window and make "wp" invalid @@ -5352,18 +5352,41 @@ static void win_enter_ext(win_T *const wp, const int flags) do_autochdir(); } -/// Used after making another window the current one: change directory if needed. -void win_fix_current_dir(void) +/// Used after making another window or buffer the current one: change directory if needed. +void fix_current_dir(bool caused_by_win) { - // New directory is either the local directory of the window, tab or NULL. - char *new_dir = curwin->w_localdir ? curwin->w_localdir : curtab->tp_localdir; + CdCause cause; + if (caused_by_win) { + cause = kCdCauseWindow; + } else { + cause = kCdCauseBuffer; + } + + // New directory is either the local directory of the window, buffer, tab or NULL. + char *new_dir; + CdScope scope; + + if (curwin->w_localdir) { + new_dir = curwin->w_localdir; + scope = kCdScopeWindow; + } else if (curbuf->b_localdir) { + new_dir = curbuf->b_localdir; + scope = kCdScopeBuffer; + } else if (curtab->tp_localdir) { + new_dir = curtab->tp_localdir; + scope = kCdScopeTabpage; + } else { + new_dir = NULL; + scope = kCdScopeGlobal; + } + char cwd[MAXPATHL]; if (os_dirname(cwd, MAXPATHL) != OK) { cwd[0] = NUL; } if (new_dir) { - // Window/tab has a local directory: Save current directory as global + // Window/buffer/tab has a local directory: Save current directory as global // (unless that was done already) and change to the local directory. if (globaldir == NULL) { if (cwd[0] != NUL) { @@ -5372,27 +5395,25 @@ void win_fix_current_dir(void) } bool dir_differs = pathcmp(new_dir, cwd, -1) != 0; if (!p_acd && dir_differs) { - do_autocmd_dirchanged(new_dir, curwin->w_localdir ? kCdScopeWindow : kCdScopeTabpage, - kCdCauseWindow, true); + do_autocmd_dirchanged(new_dir, scope, cause, true); } if (os_chdir(new_dir) == 0) { if (!p_acd && dir_differs) { - do_autocmd_dirchanged(new_dir, curwin->w_localdir ? kCdScopeWindow : kCdScopeTabpage, - kCdCauseWindow, false); + do_autocmd_dirchanged(new_dir, scope, cause, false); } } last_chdir_reason = NULL; shorten_fnames(true); } else if (globaldir != NULL) { - // Window doesn't have a local directory and we are not in the global + // Window nor buffer have a local directory and we are not in the global // directory: Change to the global directory. bool dir_differs = pathcmp(globaldir, cwd, -1) != 0; if (!p_acd && dir_differs) { - do_autocmd_dirchanged(globaldir, kCdScopeGlobal, kCdCauseWindow, true); + do_autocmd_dirchanged(globaldir, kCdScopeGlobal, cause, true); } if (os_chdir(globaldir) == 0) { if (!p_acd && dir_differs) { - do_autocmd_dirchanged(globaldir, kCdScopeGlobal, kCdCauseWindow, false); + do_autocmd_dirchanged(globaldir, kCdScopeGlobal, cause, false); } } XFREE_CLEAR(globaldir); diff --git a/test/functional/autocmd/dirchanged_spec.lua b/test/functional/autocmd/dirchanged_spec.lua index 4c940b293d..b743069ed2 100644 --- a/test/functional/autocmd/dirchanged_spec.lua +++ b/test/functional/autocmd/dirchanged_spec.lua @@ -17,6 +17,7 @@ describe('autocmd DirChanged and DirChangedPre', function() curdir .. '/Xtest-functional-autocmd-dirchanged.dir1', curdir .. '/Xtest-functional-autocmd-dirchanged.dir2', curdir .. '/Xtest-functional-autocmd-dirchanged.dir3', + curdir .. '/Xtest-functional-autocmd-dirchanged.dir4', } local win_dirs = { curdir .. '\\XTEST-FUNCTIONAL-AUTOCMD-DIRCHANGED.DIR1', @@ -62,32 +63,43 @@ describe('autocmd DirChanged and DirChangedPre', function() eq(1, eval('g:cdprecount')) eq(1, eval('g:cdcount')) - command('tcd ' .. dirs[2]) - eq({ directory = dirs[2], scope = 'tabpage', changed_window = false }, eval('g:evpre')) - eq({ cwd = dirs[2], scope = 'tabpage', changed_window = false }, eval('g:ev')) - eq('tabpage', eval('g:amatchpre')) - eq('tabpage', eval('g:amatch')) + command('bcd ' .. dirs[2]) + eq({ directory = dirs[2], scope = 'buffer', changed_window = false }, eval('g:evpre')) + eq({ cwd = dirs[2], scope = 'buffer', changed_window = false }, eval('g:ev')) + eq('buffer', eval('g:amatchpre')) + eq('buffer', eval('g:amatch')) eq(2, eval('g:cdprecount')) eq(2, eval('g:cdcount')) - command('cd ' .. dirs[3]) - eq({ directory = dirs[3], scope = 'global', changed_window = false }, eval('g:evpre')) - eq({ cwd = dirs[3], scope = 'global', changed_window = false }, eval('g:ev')) - eq('global', eval('g:amatchpre')) - eq('global', eval('g:amatch')) + command('tcd ' .. dirs[3]) + eq({ directory = dirs[3], scope = 'tabpage', changed_window = false }, eval('g:evpre')) + eq({ cwd = dirs[3], scope = 'tabpage', changed_window = false }, eval('g:ev')) + eq('tabpage', eval('g:amatchpre')) + eq('tabpage', eval('g:amatch')) eq(3, eval('g:cdprecount')) eq(3, eval('g:cdcount')) + + command('cd ' .. dirs[4]) + eq({ directory = dirs[4], scope = 'global', changed_window = false }, eval('g:evpre')) + eq({ cwd = dirs[4], scope = 'global', changed_window = false }, eval('g:ev')) + eq('global', eval('g:amatchpre')) + eq('global', eval('g:amatch')) + eq(4, eval('g:cdprecount')) + eq(4, eval('g:cdcount')) end) it('DirChanged set getcwd() during event #6260', function() command('lcd ' .. dirs[1]) eq(dirs[1], eval('g:getcwd')) - command('tcd ' .. dirs[2]) + command('bcd ' .. dirs[2]) eq(dirs[2], eval('g:getcwd')) - command('cd ' .. dirs[3]) + command('tcd ' .. dirs[3]) eq(dirs[3], eval('g:getcwd')) + + command('cd ' .. dirs[4]) + eq(dirs[4], eval('g:getcwd')) end) it('disallow recursion', function() @@ -216,52 +228,75 @@ describe('autocmd DirChanged and DirChangedPre', function() eq(2, eval('g:cdcount')) end - command('cd ' .. dirs[3]) - eq({ directory = dirs[3], scope = 'global', changed_window = false }, eval('g:evpre')) - eq({ cwd = dirs[3], scope = 'global', changed_window = false }, eval('g:ev')) - eq('global', eval('g:amatch')) + command('tcd ' .. dirs[3]) + eq({ directory = dirs[3], scope = 'tabpage', changed_window = false }, eval('g:evpre')) + eq({ cwd = dirs[3], scope = 'tabpage', changed_window = false }, eval('g:ev')) + eq('tabpage', eval('g:amatchpre')) + eq('tabpage', eval('g:amatch')) eq(3, eval('g:cdprecount')) eq(3, eval('g:cdcount')) command('let g:evpre = {}') command('let g:ev = {}') - command('cd ' .. dirs[3]) + command('tcd ' .. dirs[3]) eq({}, eval('g:evpre')) eq({}, eval('g:ev')) eq(3, eval('g:cdprecount')) eq(3, eval('g:cdcount')) if is_os('win') then - command('cd ' .. win_dirs[3]) + command('tcd ' .. win_dirs[3]) eq({}, eval('g:evpre')) eq({}, eval('g:ev')) eq(3, eval('g:cdprecount')) eq(3, eval('g:cdcount')) end - command('set autochdir') - - command('split ' .. dirs[1] .. '/foo') - eq({ directory = dirs[1], scope = 'window', changed_window = false }, eval('g:evpre')) - eq({ cwd = dirs[1], scope = 'window', changed_window = false }, eval('g:ev')) - eq('auto', eval('g:amatchpre')) - eq('auto', eval('g:amatch')) + command('cd ' .. dirs[4]) + eq({ directory = dirs[4], scope = 'global', changed_window = false }, eval('g:evpre')) + eq({ cwd = dirs[4], scope = 'global', changed_window = false }, eval('g:ev')) + eq('global', eval('g:amatch')) eq(4, eval('g:cdprecount')) eq(4, eval('g:cdcount')) command('let g:evpre = {}') command('let g:ev = {}') - command('split ' .. dirs[1] .. '/bar') + command('cd ' .. dirs[4]) eq({}, eval('g:evpre')) eq({}, eval('g:ev')) eq(4, eval('g:cdprecount')) eq(4, eval('g:cdcount')) if is_os('win') then - command('split ' .. win_dirs[1] .. '/baz') + command('cd ' .. dirs[4]) eq({}, eval('g:evpre')) eq({}, eval('g:ev')) eq(4, eval('g:cdprecount')) eq(4, eval('g:cdcount')) end + + command('set autochdir') + + command(('split %s/foo'):format(dirs[2])) + eq({ directory = dirs[2], scope = 'window', changed_window = false }, eval('g:evpre')) + eq({ cwd = dirs[2], scope = 'window', changed_window = false }, eval('g:ev')) + eq('auto', eval('g:amatchpre')) + eq('auto', eval('g:amatch')) + eq(5, eval('g:cdprecount')) + eq(5, eval('g:cdcount')) + command('let g:evpre = {}') + command('let g:ev = {}') + command(('split %s/bar'):format(dirs[2])) + eq({}, eval('g:evpre')) + eq({}, eval('g:ev')) + eq(5, eval('g:cdprecount')) + eq(5, eval('g:cdcount')) + + if is_os('win') then + command(('split %s/baz'):format(win_dirs[2])) + eq({}, eval('g:evpre')) + eq({}, eval('g:ev')) + eq(5, eval('g:cdprecount')) + eq(5, eval('g:cdcount')) + end end) it('are triggered by switching to win/tab with different CWD #6054', function() @@ -341,6 +376,141 @@ describe('autocmd DirChanged and DirChangedPre', function() end end) + it('are triggered by switching to buf/tab with different CWD', function() + local files = { + dirs[1] .. '/file', + dirs[2] .. '/file', + dirs[3] .. '/file', + } + + command('e ' .. files[3]) -- buffer 3 + command('e ' .. files[2]) -- buffer 2 + command('e ' .. files[1]) -- buffer 1 + + command('bcd ' .. dirs[1]) + command('b ' .. files[2]) -- Switch to buffer 2 + command('bcd ' .. dirs[2]) + command('b ' .. files[3]) -- Switch to buffer 3 + command('bcd ' .. dirs[3]) + + eq({ directory = dirs[3], scope = 'buffer', changed_window = false }, eval('g:evpre')) + eq({ cwd = dirs[3], scope = 'buffer', changed_window = false }, eval('g:ev')) + eq('buffer', eval('g:amatchpre')) + eq('buffer', eval('g:amatch')) + + eq(5, eval('g:cdprecount')) + eq(5, eval('g:cdcount')) + command('tabnew') -- tab 2 (tab-local CWD) + eq(5, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(5, eval('g:cdcount')) -- same CWD, no DirChanged event + command('tcd ' .. dirs[2]) + command('tabnext') -- tab 1 (no tab-local CWD) + eq({ directory = dirs[3], scope = 'buffer', changed_window = true }, eval('g:evpre')) + eq({ cwd = dirs[3], scope = 'buffer', changed_window = true }, eval('g:ev')) + eq('buffer', eval('g:amatchpre')) + eq('buffer', eval('g:amatch')) + command('tabnext') -- tab 2 + eq({ directory = dirs[2], scope = 'tabpage', changed_window = true }, eval('g:evpre')) + eq({ cwd = dirs[2], scope = 'tabpage', changed_window = true }, eval('g:ev')) + eq('tabpage', eval('g:amatchpre')) + eq('tabpage', eval('g:amatch')) + eq(8, eval('g:cdprecount')) + eq(8, eval('g:cdcount')) + + command('tabnext') -- tab 1 + command('b ' .. files[2]) -- buffer 2 + eq(10, eval('g:cdprecount')) + eq(10, eval('g:cdcount')) + command('tabnext') -- tab 2 (has the *same* CWD) + eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + + if is_os('win') then + command('tabnew') -- tab 3 + eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + command('tcd ' .. dirs[2]) + eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + command('tabnext') -- tab 1 + eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + command('tabprevious') -- tab 3 + eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + command('tabprevious') -- tab 2 + eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + command('tabprevious') -- tab 1 + eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + command('bcd ' .. dirs[2]) -- buffer 2 + eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + command('tabnext') -- tab 2 + eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + command('tabnext') -- tab 3 + eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + command('tabnext') -- tab 1 + eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + command('tabprevious') -- tab 3 + eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + end + end) + + it('are triggered by switching to buf/win with different CWD', function() + command('lcd ' .. dirs[3]) -- window 3 + command(('split %s/file'):format(dirs[2])) -- window 2 + command('lcd ' .. dirs[2]) + command(('split %s/file'):format(dirs[1])) -- window 1 + command('lcd ' .. dirs[1]) + + -- All windows have a window-local CWD + + command('2wincmd w') -- window 2 + eq({ directory = dirs[2], scope = 'window', changed_window = true }, eval('g:evpre')) + eq({ cwd = dirs[2], scope = 'window', changed_window = true }, eval('g:ev')) + eq('window', eval('g:amatchpre')) + eq('window', eval('g:amatch')) + + eq(4, eval('g:cdprecount')) + eq(4, eval('g:cdcount')) + command('bcd ' .. dirs[1]) -- window 2 now has a buffer with buffer-local CWD (and no window-local CWD) + eq({ directory = dirs[1], scope = 'buffer', changed_window = false }, eval('g:evpre')) + eq({ cwd = dirs[1], scope = 'buffer', changed_window = false }, eval('g:ev')) + eq('buffer', eval('g:amatchpre')) + eq('buffer', eval('g:amatch')) + eq(5, eval('g:cdprecount')) + eq(5, eval('g:cdcount')) + command('3wincmd w') -- window 3 (window-local CWD) + eq(6, eval('g:cdprecount')) + eq(6, eval('g:cdcount')) + command('bcd ' .. dirs[2]) -- window 3 now has a buffer with buffer-local CWD (and no window-local CWD) + command('1wincmd w') -- window 1 (window-local CWD) + eq({ directory = dirs[1], scope = 'window', changed_window = true }, eval('g:evpre')) + eq({ cwd = dirs[1], scope = 'window', changed_window = true }, eval('g:ev')) + eq('window', eval('g:amatchpre')) + eq('window', eval('g:amatch')) + command('2wincmd w') -- window 2 (buffer-local CWD) + eq({ directory = dirs[1], scope = 'window', changed_window = true }, eval('g:evpre')) -- buffer-local CWD + eq({ cwd = dirs[1], scope = 'window', changed_window = true }, eval('g:ev')) + eq('window', eval('g:amatchpre')) + eq('window', eval('g:amatch')) + eq(8, eval('g:cdprecount')) + eq(8, eval('g:cdcount')) + + command('1wincmd w') -- window 1 (window-local cwd) + eq(8, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(8, eval('g:cdcount')) -- same CWD, no DirChanged event + command(('b %s/file'):format(dirs[2])) -- buffer 2 (has buffer-local cwd) + eq(8, eval('g:cdprecount')) -- no DirChangedPre event, window-local CWD has higher priority + eq(8, eval('g:cdcount')) -- no DirChanged event, window-local CWD has higher priority + end) + it('are triggered by nvim_set_current_dir()', function() request('nvim_set_current_dir', dirs[1]) eq({ directory = dirs[1], scope = 'global', changed_window = false }, eval('g:evpre')) diff --git a/test/functional/ex_cmds/cd_spec.lua b/test/functional/ex_cmds/cd_spec.lua index b6d5b195e7..2881a4634c 100644 --- a/test/functional/ex_cmds/cd_spec.lua +++ b/test/functional/ex_cmds/cd_spec.lua @@ -18,9 +18,12 @@ local mkdir = t.mkdir local directories = { tab = 'Xtest-functional-ex_cmds-cd_spec.tab', -- Tab window = 'Xtest-functional-ex_cmds-cd_spec.window', -- Window + buffer = 'Xtest-functional-ex_cmds-cd_spec.buffer', -- Buffer global = 'Xtest-functional-ex_cmds-cd_spec.global', -- New global } +local tmpfile = 'Xtest-functional-ex_cmds-cd_spec-tmpfile' + -- Shorthand writing to get the current working directory local cwd = function(...) return call('getcwd', ...) @@ -28,6 +31,9 @@ end -- effective working dir local wcwd = function() return cwd(0) end -- window dir +local bcwd = function() + return cwd(-1, -1, 0) +end -- buffer dir local tcwd = function() return cwd(-1, 0) end -- tab dir @@ -39,6 +45,9 @@ end -- effective working dir local wlwd = function() return lwd(0) end -- window dir +local blwd = function() + return lwd(-1, -1, 0) +end -- buffer dir local tlwd = function() return lwd(-1, 0) end -- tab dir @@ -134,6 +143,45 @@ for _, cmd in ipairs { 'cd', 'chdir' } do eq(globalDir .. pathsep .. directories.tab, cwd(-1, localtab)) eq(1, lwd(-1, localtab)) end) + + it('for buffer', function() + local globalDir = directories.start + -- Create two buffers + command(('e %s1'):format(tmpfile)) + command(('e %s%s%s2'):format(directories.buffer, pathsep, tmpfile)) + + -- Initially matches globalDir + eq(globalDir, cwd()) + eq(0, lwd()) + + -- Change buffer-local directory to subdirectory + command('bcd ' .. directories.buffer) + eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + eq(1, blwd()) + + -- Verify Other buffer is unchanged + command('b# ') + eq(globalDir, cwd()) + eq(0, blwd()) + + -- A new buffer created with :edit inherits the buffer-local directory; editing an + -- existing buffer keeps that buffer's own directory. + command('b# ') + command(('e %s3'):format(tmpfile)) + eq(1, blwd()) + eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + command(('e ..%s%s1'):format(pathsep, tmpfile)) + eq(0, blwd()) + eq(globalDir, cwd()) + + -- getcwd({winnr}) falls through to the buffer shown in that window, not the current + -- buffer. + command(('split %s%s%s2'):format(directories.buffer, pathsep, tmpfile)) + command('wincmd p') + eq(globalDir, cwd()) + eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd(1)) + eq(globalDir, cwd(2)) + end) end) describe('getcwd(-1, -1)', function() @@ -153,6 +201,16 @@ for _, cmd in ipairs { 'cd', 'chdir' } do eq(directories.start, 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(0, lwd(-1, -1)) + + -- Must behave the same if bufnr is -1 + eq(directories.start, cwd(-1, -1, -1)) + eq(0, lwd(-1, -1, -1)) + end) end) describe('Local directory gets inherited', function() @@ -239,6 +297,137 @@ for _, cmd in ipairs { 'cd', 'chdir' } do eq(0, tlwd()) eq(globalDir .. pathsep .. directories.window, wcwd()) end) + + it('works when mixing tab-local and buffer-local directories', function() + local globalDir = directories.start + + -- Create two buffers for testing. One in each tab + command(('e %s1'):format(tmpfile)) + command('tabnew') + command(('e %s2'):format(tmpfile)) + + -- Verify that buffer 2 has the same working directory + eq(globalDir, cwd()) + eq(globalDir, tcwd()) -- Has no tab-local directory + eq(0, tlwd()) + eq(globalDir, bcwd()) -- Has no buffer-local directory + eq(0, blwd()) + + -- Change tab-local working directory and verify it is different + command(('silent t%s %s'):format(cmd, directories.tab)) + eq(('%s%s%s'):format(globalDir, pathsep, directories.tab), cwd()) + eq(cwd(), tcwd()) -- Working directory matches tab directory + eq(1, tlwd()) + eq(cwd(), bcwd()) -- Still no buffer-directory + eq(0, blwd()) + + -- Change buffer 2's buffer-local directory + command(('silent b%s ..%s%s'):format(cmd, pathsep, directories.buffer)) + eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + eq(('%s%s%s'):format(globalDir, pathsep, directories.tab), tcwd()) + eq(cwd(), bcwd()) -- Has no buffer-directory + eq(1, blwd()) + + -- Verify the first tab has no local-directory + command('tabfirst') + eq(0, tlwd()) + + -- Verify buffer 2 has buffer-local directory even in first tab + command(('b %s2'):format(tmpfile)) -- Switch to buffer 2 + eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), bcwd()) + eq(0, tlwd()) -- Still no tab-local directory + + -- Verify buffer 1 did not have its directory changed + command('b#') -- Switch to buffer 1 + eq(globalDir, cwd()) + eq(0, blwd()) -- No window-buffer directory + end) + it('works when mixing window local and buffer local directories', function() + local globalDir = directories.start + -- Create a new window first and verify that is has the same working directory + command('new') + eq(globalDir, cwd()) + eq(globalDir, wcwd()) -- Has no window-local directory + eq(0, tlwd()) + eq(globalDir, bcwd()) -- Has no buffer-local directory + eq(0, blwd()) + + -- Create a buffer in current window + command('e ' .. tmpfile) + + -- Change buffer-local working directory and verify it is different + command(('silent b%s %s'):format(cmd, directories.buffer)) + eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + eq(cwd(), bcwd()) -- Working directory matches buffer directory + eq(1, blwd()) + eq(cwd(), wcwd()) -- Still no window-directory + eq(0, wlwd()) + + -- Change window-local directory to test `:lcd` + command(('silent l%s ../%s'):format(cmd, directories.window)) + eq(('%s%s%s'):format(globalDir, pathsep, directories.window), cwd()) + eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), bcwd()) + eq(1, blwd()) + + -- Verify buffer has buffer-local directory in original window + command('wincmd w') + command('b ' .. tmpfile) + eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + + -- Verify going to second window uses window-local directory + command('wincmd w') + eq(('%s%s%s'):format(globalDir, pathsep, directories.window), cwd()) + end) + end) +end + +for _, cmd in ipairs { 'bcd', 'bchdir' } do + describe(':' .. cmd, function() + before_each(function() + clear() + for _, d in pairs(directories) do + mkdir(d) + end + directories.start = cwd() + end) + + after_each(function() + for _, d in pairs(directories) do + vim.uv.fs_rmdir(d) + end + end) + + it('works after deleting the only buffer', function() + command(('%s %s'):format(cmd, directories.buffer)) + command('bd') -- delete buffer + end) + + it('makes :new use the buffer-local directory', function() + local globalDir = directories.start + command(('%s %s'):format(cmd, directories.buffer)) + + command(':new') + eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + command('wincmd x') -- close :new window + + command(':vnew') + eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + command('wincmd x') -- close :vnew window + + command(':enew') + eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + end) + + it('makes :enew use the buffer-local directory in a split', function() + local globalDir = directories.start + command(('%s %s'):format(cmd, directories.buffer)) + + command(':vsplit') + eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + + command(':enew') + eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + end) end) end @@ -272,14 +461,23 @@ for _, cmd in ipairs { 'getcwd', 'haslocaldir' } do eq(err474, pcall_err(command, 'call ' .. cmd .. '(-2)')) end) local err5001 = 'Vim(call):E5001: Higher scope cannot be -1 if lower scope is >= 0.' + local err5006 = 'Vim(call):E5006: Window and tab scope must be -1 when using buffer scope' + local err5007 = 'Vim(call):E5007: Cannot find buffer number.' it('fails on -1 if previous arg is >=0', function() eq(err5001, pcall_err(command, 'call ' .. cmd .. '(0, -1)')) end) + it('fails when 1st and 2nd args != -1 when 3rd arg > -1', function() + eq(err5006, pcall_err(command, ('call %s(0, 0, 0)'):format(cmd))) + eq(err5006, pcall_err(command, ('call %s(1, 2, 3)'):format(cmd))) + end) + it('fails when passing a bufnr that does not exist', function() + eq(err5007, pcall_err(command, ('call %s(-1, -1, 99999)'):format(cmd))) + end) -- Test wrong number of arguments local err118 = 'Vim(call):E118: Too many arguments for function: ' .. cmd it('fails to parse more than one argument', function() - eq(err118, pcall_err(command, 'call ' .. cmd .. '(0, 0, 0)')) + eq(err118, pcall_err(command, ('call %s(0, 0, 0, 0)'):format(cmd))) end) end) end diff --git a/test/functional/ex_cmds/mksession_spec.lua b/test/functional/ex_cmds/mksession_spec.lua index 81e39889de..866950bf49 100644 --- a/test/functional/ex_cmds/mksession_spec.lua +++ b/test/functional/ex_cmds/mksession_spec.lua @@ -23,16 +23,19 @@ local file_prefix = 'Xtest-functional-ex_cmds-mksession_spec' describe(':mksession', function() local session_file = file_prefix .. '.vim' - local tab_dir = file_prefix .. '.d' + local tab_dir = file_prefix .. '.tab.d' + local buf_dir = file_prefix .. '.buf.d' before_each(function() clear() mkdir(tab_dir) + mkdir(buf_dir) end) after_each(function() os.remove(session_file) rmdir(tab_dir) + rmdir(buf_dir) end) it('restores same :terminal buf in splits', function() @@ -362,4 +365,66 @@ describe(':mksession', function() -- contains all splits eq(3, #api.nvim_tabpage_list_wins(0)) end) + + it('restores buffer-local working directories', function() + -- "'" and " " in the names must survive the session file's string quoting and escaping. + local tmpfile_base = file_prefix .. "-tmp'file" + local quote_dir = file_prefix .. ".b'uf d" + local session_file2 = session_file .. '2' + local cwd_dir = fn.getcwd() + mkdir(quote_dir) + finally(function() + rmdir(quote_dir) + os.remove(session_file2) + end) + + command(('edit %s1'):format(tmpfile_base)) + command(('edit %s2'):format(tmpfile_base)) + command('bcd ' .. fn.fnameescape(quote_dir)) + command('b# ') + command('mksession ' .. session_file) + command('set sessionoptions-=curdir') + command('mksession ' .. session_file2) + + -- Create a new test instance of Nvim. + clear() + + command('silent source ' .. session_file) + command(('b %s%s2'):format(get_pathsep(), tmpfile_base)) + eq(('%s%s%s'):format(cwd_dir, get_pathsep(), quote_dir), fn.getcwd()) + + -- Without "curdir" in 'sessionoptions' no buffer-local directory is restored. + clear() + command('silent source ' .. session_file2) + command(('b %s%s2'):format(get_pathsep(), tmpfile_base)) + eq(cwd_dir, fn.getcwd()) + eq(0, fn.haslocaldir(-1, -1, 0)) + end) + + it('restores buffer-local working directories when used with tabs', function() + local tmpfile_base = file_prefix .. '-tmpfile' + local cwd_dir = fn.getcwd() + + command(('edit %s1'):format(tmpfile_base)) + command('tabnew ') + command(('edit %s2'):format(tmpfile_base)) + command('tcd ' .. tab_dir) + command('bcd ..') + command('bcd ' .. buf_dir) + command('tabfirst') + command('mksession ' .. session_file) + + -- Create a new test instance of Nvim. + clear() + + command('source ' .. session_file) + -- First tab should have the original working directory. + command('tabnext 1') + eq(cwd_dir, fn.getcwd()) + -- Second tab should have the tab-local working directory. + command('tabnext 2') + eq(('%s%s%s'):format(cwd_dir, get_pathsep(), tab_dir), fn.getcwd(-1, 0)) + -- Buffer in second tab should have buffer-local working directory + eq(('%s%s%s'):format(cwd_dir, get_pathsep(), buf_dir), fn.getcwd(-1, -1, 0)) + end) end) From db2e86fba4f732dab43f70dd7f6d0a089ea9e98c Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Tue, 4 Aug 2026 15:16:17 +0200 Subject: [PATCH 3/5] refactor(editor): cleanup change-directory (:bcd) logic --- runtime/doc/editing.txt | 15 +- runtime/doc/vim_diff.txt | 2 +- runtime/doc/vimfn.txt | 4 +- runtime/lua/vim/_meta/vimfn.gen.lua | 1 - scripts/linterrcodes.lua | 3 - src/nvim/errors.h | 2 - src/nvim/eval.lua | 2 +- src/nvim/eval/fs.c | 377 ++++++++------------- src/nvim/ex_docmd.c | 34 +- src/nvim/ex_session.c | 79 +++-- src/nvim/window.c | 7 +- test/functional/ex_cmds/cd_spec.lua | 163 ++++----- test/functional/ex_cmds/mksession_spec.lua | 3 + 13 files changed, 274 insertions(+), 418 deletions(-) diff --git a/runtime/doc/editing.txt b/runtime/doc/editing.txt index cf2e6e96ec..04f88d8058 100644 --- a/runtime/doc/editing.txt +++ b/runtime/doc/editing.txt @@ -1348,15 +1348,16 @@ use has("browsefilter"): > ============================================================================== 7. The current directory *current-directory* -There are three current-directory "scopes": global, tab and window. You can -use |:cd|, |:tcd| and |:lcd| to change to another directory. This affects -anything that operates on relative paths, e.g. ":edit ./foo.txt" or shell -commands like ":!ls" or ":te ls". +There are four current-directory "scopes": global, tab, window and buffer. +You can use |:cd|, |:tcd|, |:lcd| and |:bcd| to change to another directory. +This affects anything that operates on relative paths, e.g. ":edit ./foo.txt" +or shell commands like ":!ls" or ":te ls". Use |getcwd()| to get the effective directory from the current scope. The -window-local working directory takes precedence over the tab-local working -directory, which in turn takes precedence over the global working directory. -If a local working directory (tab or window) does not exist, the next-higher +window-local working directory takes precedence over the buffer-local working +directory, which takes precedence over the tab-local working directory, which +in turn takes precedence over the global working directory. If a local +working directory (window, buffer or tab) does not exist, the next-higher scope in the hierarchy applies. *:cd* *E747* *E472* diff --git a/runtime/doc/vim_diff.txt b/runtime/doc/vim_diff.txt index 7570978367..698be950d0 100644 --- a/runtime/doc/vim_diff.txt +++ b/runtime/doc/vim_diff.txt @@ -696,7 +696,7 @@ Working directory (Vim implemented some of these after Nvim): - |DirChanged| and |DirChangedPre| can be triggered when switching to another window or tab. - |getcwd()| and |haslocaldir()| may throw errors if the tabpage, window, or - buffer cannot be found. *E5000* *E5001* *E5002* *E5006* *E5007* + buffer cannot be found. - |haslocaldir()| checks for tab-local directory if and only if -1 is passed as window number, and its only possible returns values are 0 and 1. - `getcwd(-1)` is equivalent to `getcwd(-1, 0)` instead of returning the global diff --git a/runtime/doc/vimfn.txt b/runtime/doc/vimfn.txt index f47ef1e82d..a7e840d093 100644 --- a/runtime/doc/vimfn.txt +++ b/runtime/doc/vimfn.txt @@ -3748,7 +3748,8 @@ getcursorcharpos([{winid}]) *getcursorcharpos()* Return: ~ (`any`) -getcwd([{winnr} [, {tabnr} [, {bufnr}]]]) *getcwd()* + *getcwd()* *E5000* *E5001* *E5002* *E5006* *E5007* +getcwd([{winnr} [, {tabnr} [, {bufnr}]]]) Lua: Prefer |uv.cwd()| for the global working directory; tab-local and window-local scopes differ. @@ -3782,7 +3783,6 @@ getcwd([{winnr} [, {tabnr} [, {bufnr}]]]) *getcwd()* getcwd(-1, -1, -1) " Get global directory getcwd(-1, -1) " Get global directory < Throw error if the arguments are invalid. - |E5000| |E5001| |E5002| |E5006| |E5007| Parameters: ~ • {winnr} (`integer?`) diff --git a/runtime/lua/vim/_meta/vimfn.gen.lua b/runtime/lua/vim/_meta/vimfn.gen.lua index 8eb79696ea..6112415d09 100644 --- a/runtime/lua/vim/_meta/vimfn.gen.lua +++ b/runtime/lua/vim/_meta/vimfn.gen.lua @@ -3350,7 +3350,6 @@ function vim.fn.getcursorcharpos(winid) end --- getcwd(-1, -1, -1) " Get global directory --- getcwd(-1, -1) " Get global directory --- : invalid buffer number")); EXTERN const char e_buffer_is_not_loaded[] INIT(= N_("E681: Buffer is not loaded")); -EXTERN const char e_getcwd_buffer_scope_requires_minus_one[] INIT(= N_("E5006: Window and tab scope must be -1 when using buffer scope")); -EXTERN const char e_cannot_find_buffer_number[] INIT(= N_("E5007: Cannot find buffer number.")); EXTERN const char e_endif[] INIT(= N_("E171: Missing :endif")); EXTERN const char e_endtry[] INIT(= N_("E600: Missing :endtry")); EXTERN const char e_endwhile[] INIT(= N_("E170: Missing :endwhile")); diff --git a/src/nvim/eval.lua b/src/nvim/eval.lua index 8ed0e37f67..9384b03933 100644 --- a/src/nvim/eval.lua +++ b/src/nvim/eval.lua @@ -4125,12 +4125,12 @@ M.funcs = { getcwd(-1, -1, -1) " Get global directory getcwd(-1, -1) " Get global directory = 0: + switch (argc) { + case 0: + break; + case 1: + if (argv[kWinArg] >= 0) { + *scope = kCdScopeWindow; // (X) + } else { + *scope = kCdScopeTabpage; // (-1) + } + break; + case 2: + if (argv[kWinArg] >= 0) { + *scope = kCdScopeWindow; // (X, ...) + } else if (argv[kTabArg] >= 0) { + *scope = kCdScopeTabpage; // (-1, X) + } else { + *scope = kCdScopeGlobal; // (-1, -1) + } + break; + case 3: + if (argv[kBufArg] >= 0) { + *scope = kCdScopeBuffer; // (-1, -1, X) + } else { + *scope = kCdScopeGlobal; // (..., ..., -1) + } + break; + } + + if (*scope == kCdScopeBuffer) { + if (argv[kWinArg] >= 0 || argv[kTabArg] >= 0) { + emsg(_("E5006: Window and tab scope must be -1 when using buffer scope")); + return false; + } + if (argv[kBufArg] > 0) { + Error err = ERROR_INIT; + *buf = find_buffer_by_handle(argv[kBufArg], &err); + if (ERROR_SET(&err)) { + emsg(_("E5007: Cannot find buffer number.")); + xfree(err.msg); + return false; + } + } + } + + // Find the tabpage by number. + if (argv[kTabArg] > 0) { + *tp = find_tabpage(argv[kTabArg]); + if (*tp == NULL) { + emsg(_("E5000: Cannot find tab number.")); + return false; + } + } + + // Find the window in `tp` by number. + if (argv[kWinArg] >= 0) { + if (argv[kTabArg] < 0) { + emsg(_("E5001: Higher scope cannot be -1 if lower scope is >= 0.")); + return false; + } + if (argv[kWinArg] > 0) { + *win = find_win_by_nr(&argvars[0], *tp); + if (*win == NULL) { + emsg(_("E5002: Cannot find window number.")); + return false; + } + } + // Window scope may fall through to the buffer shown in that window. + *buf = (*win)->w_buffer; + } + + return true; +} + /// `getcwd([{win}[, {tab}[, {buf}]]])` function /// /// Every scope not specified implies the currently selected scope object. @@ -655,149 +766,36 @@ void f_fnamemodify(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) /// @post The return value will be a string. void f_getcwd(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) { - // Possible scope of working directory to return. - CdScope scope = kCdScopeInvalid; - - // Numbers of the scope objects (window, buffer, tab) we want the working - // directory of. A `-1` means to skip this scope, a `0` means the current object. - - // getcwd() takes arguments in this order: (window, tab, buffer) - // Note that this is different from the order of CdScope - enum { - WINDOW_IDX = 0, - TABPAGE_IDX = 1, - BUFFER_IDX = 2, - }; - - int argv[] = { // arguments passed to getcwd(). - 0, // Number of window to look at. - 0, // Number of tab to look at. - 0, // Number of buffer to look at. - }; - int argc = 0; // number of arguments passed to getcwd(). - - char *cwd = NULL; // Current working directory to print - char *from = NULL; // The original string to copy - - tabpage_T *tp = curtab; // The tabpage to look at. - win_T *win = curwin; // The window to look at. - buf_T *buf = curbuf; // The buffer to look at. + CdScope scope; + win_T *win; + tabpage_T *tp; + buf_T *buf; rettv->v_type = VAR_STRING; rettv->vval.v_string = NULL; - // Pre-conditions - for (; argc < 3; argc++) { - // If there is no argument there are no more scopes after it, break out. - if (argvars[argc].v_type == VAR_UNKNOWN) { - break; - } - if (argvars[argc].v_type != VAR_NUMBER) { - emsg(_(e_invarg)); - return; - } - argv[argc] = (int)argvars[argc].vval.v_number; - // It is an error for the scope number to be less than `-1`. - if (argv[argc] < -1) { - emsg(_(e_invarg)); - return; - } + // Without arguments (kCdScopeInvalid), the effective working directory is returned. + if (!getcwd_scope_args(argvars, kCdScopeInvalid, &scope, &win, &tp, &buf)) { + return; } - // Scope extraction - // Imagine X >= 0 - switch (argc) { - case 0: - scope = kCdScopeInvalid; // getcwd() - break; - case 1: - if (argv[WINDOW_IDX] > -1) { - scope = kCdScopeWindow; // getcwd(X) - } else { - scope = kCdScopeTabpage; // getcwd(-1) - } - break; - case 2: - if (argv[WINDOW_IDX] > -1) { - scope = kCdScopeWindow; // getcwd(X, ...) - } else if (argv[TABPAGE_IDX] > -1) { - scope = kCdScopeTabpage; // getcwd(-1, X) - } else { - scope = kCdScopeGlobal; // getcwd(-1, -1) - } - break; - case 3: - if (argv[BUFFER_IDX] > -1) { - scope = kCdScopeBuffer; // getcwd(..., ..., X) - } else { - scope = kCdScopeGlobal; // getcwd(..., ..., -1) - } - break; - } - - // getcwd(-1, -1, X) - if (scope == kCdScopeBuffer) { - if (argv[WINDOW_IDX] >= 0 || argv[TABPAGE_IDX] >= 0) { - emsg(_(e_getcwd_buffer_scope_requires_minus_one)); - return; - } - if (argv[BUFFER_IDX] > 0) { - Error err = ERROR_INIT; - buf = find_buffer_by_handle(argv[BUFFER_IDX], &err); - if (ERROR_SET(&err)) { - emsg(_(e_cannot_find_buffer_number)); - xfree(err.msg); - return; - } - } - } - - // Find the tabpage by number - if (argv[TABPAGE_IDX] > 0) { - tp = find_tabpage(argv[TABPAGE_IDX]); - if (!tp) { - emsg(_("E5000: Cannot find tab number.")); - return; - } - } - - // Find the window in `tp` by number, `NULL` if none. - if (argv[WINDOW_IDX] >= 0) { - if (argv[TABPAGE_IDX] < 0) { - emsg(_("E5001: Higher scope cannot be -1 if lower scope is >= 0.")); - return; - } - - if (argv[WINDOW_IDX] > 0) { - win = find_win_by_nr(&argvars[0], tp); - if (!win) { - emsg(_("E5002: Cannot find window number.")); - return; - } - } - // Window scope may fall through to the buffer shown in that window. - buf = win->w_buffer; - } - - cwd = xmalloc(MAXPATHL); + char *from = NULL; // The local directory to copy. + char *cwd = xmalloc(MAXPATHL); switch (scope) { case kCdScopeWindow: - assert(win); from = win->w_localdir; if (from) { break; } FALLTHROUGH; case kCdScopeBuffer: - assert(buf); from = buf->b_localdir; if (from) { break; } FALLTHROUGH; case kCdScopeTabpage: - assert(tp); from = tp->tp_localdir; if (from) { break; @@ -1027,148 +1025,37 @@ void f_glob2regpat(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) /// `haslocaldir([{win}[, {tab}[, {buf}]]])` function /// /// Returns `1` if the scope object has a local directory, `0` otherwise. If a -/// scope object is not specified the current one is implied. This function -/// share a lot of code with `f_getcwd`. +/// scope object is not specified the current one is implied. /// /// @pre The arguments must be of type number. -/// @pre There may not be more than two arguments. +/// @pre There may not be more than three arguments. /// @pre An argument may not be -1 if preceding arguments are not all -1. /// /// @post The return value will be either the number `1` or `0`. void f_haslocaldir(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) { - // Possible scope of working directory to return. - CdScope scope = kCdScopeInvalid; - - // Numbers of the scope objects (window, tab) we want the working directory - // of. A `-1` means to skip this scope, a `0` means the current object. - - // haslocaldir() takes arguments in this order: (window, tab, buffer) - // Note that this is different from the order of CdScope - enum { - WINDOW_IDX = 0, - TABPAGE_IDX = 1, - BUFFER_IDX = 2, - }; - - int argv[] = { // arguments passed to haslocaldir(). - 0, // Number of window to look at. - 0, // Number of tab to look at. - 0, // Number of buffer to look at. - }; - int argc = 0; // number of arguments passed to haslocaldir. - - tabpage_T *tp = curtab; // The tabpage to look at. - win_T *win = curwin; // The window to look at. - buf_T *buf = curbuf; // The buffer to look at. + CdScope scope; + win_T *win; + tabpage_T *tp; + buf_T *buf; rettv->v_type = VAR_NUMBER; rettv->vval.v_number = 0; - // Pre-conditions - for (; argc < 3; argc++) { - if (argvars[argc].v_type == VAR_UNKNOWN) { - break; - } - if (argvars[argc].v_type != VAR_NUMBER) { - emsg(_(e_invarg)); - return; - } - argv[argc] = (int)argvars[argc].vval.v_number; - if (argv[argc] < -1) { - emsg(_(e_invarg)); - return; - } - } - - // Scope extraction - // Imagine X >= 0 - switch (argc) { - case 0: - // If the user didn't specify anything, default to window scope - scope = kCdScopeWindow; // haslocaldir() - break; - case 1: - if (argv[0] > -1) { - scope = kCdScopeWindow; // haslocaldir(X) - } else { - scope = kCdScopeTabpage; // haslocaldir(-1) - } - break; - case 2: - if (argv[0] > -1) { - scope = kCdScopeWindow; // haslocaldir(X, ...) - } else if (argv[1] > -1) { - scope = kCdScopeTabpage; // haslocaldir(-1, X) - } else { - scope = kCdScopeGlobal; // haslocaldir(-1, -1) - } - break; - case 3: - if (argv[2] > -1) { - scope = kCdScopeBuffer; // haslocaldir(..., ..., X) - } else { - scope = kCdScopeGlobal; // haslocaldir(..., ..., -1) - } - break; - } - - // haslocaldir(-1, -1, X) - if (scope == kCdScopeBuffer) { - if (argv[WINDOW_IDX] >= 0 || argv[TABPAGE_IDX] >= 0) { - emsg(_(e_getcwd_buffer_scope_requires_minus_one)); - return; - } - if (argv[BUFFER_IDX] > 0) { - Error err = ERROR_INIT; - buf = find_buffer_by_handle(argv[BUFFER_IDX], &err); - if (ERROR_SET(&err)) { - emsg(_(e_cannot_find_buffer_number)); - xfree(err.msg); - return; - } - } - } - - // Find the tabpage by number - if (argv[TABPAGE_IDX] >= 0) { - if (argv[TABPAGE_IDX] > 0) { - tp = find_tabpage(argv[TABPAGE_IDX]); - if (!tp) { - emsg(_("E5000: Cannot find tab number.")); - return; - } - } - } - - // Find the window in `tp` by number, `NULL` if none. - if (argv[WINDOW_IDX] >= 0) { - if (argv[TABPAGE_IDX] < 0) { - emsg(_("E5001: Higher scope cannot be -1 if lower scope is >= 0.")); - return; - } - - if (argv[WINDOW_IDX] > 0) { - win = find_win_by_nr(&argvars[0], tp); - if (!win) { - emsg(_("E5002: Cannot find window number.")); - return; - } - } + // Without arguments, default to window scope. + if (!getcwd_scope_args(argvars, kCdScopeWindow, &scope, &win, &tp, &buf)) { + return; } switch (scope) { case kCdScopeWindow: - assert(win); - rettv->vval.v_number = win->w_localdir ? 1 : 0; + rettv->vval.v_number = win->w_localdir != NULL; break; case kCdScopeBuffer: - assert(buf); - rettv->vval.v_number = buf->b_localdir ? 1 : 0; + rettv->vval.v_number = buf->b_localdir != NULL; break; case kCdScopeTabpage: - assert(tp); - rettv->vval.v_number = tp->tp_localdir ? 1 : 0; + rettv->vval.v_number = tp->tp_localdir != NULL; break; case kCdScopeGlobal: // The global scope never has a local directory diff --git a/src/nvim/ex_docmd.c b/src/nvim/ex_docmd.c index 8f9fa7fcfb..71412d548d 100644 --- a/src/nvim/ex_docmd.c +++ b/src/nvim/ex_docmd.c @@ -6205,21 +6205,18 @@ void free_cd_dir(void) #endif -/// Get the previous directory for the given chdir scope. -static char *get_prevdir(CdScope scope) +/// Gets the previous-directory slot for the given chdir scope. +static char **get_prevdir(CdScope scope) { switch (scope) { case kCdScopeTabpage: - return curtab->tp_prevdir; - break; + return &curtab->tp_prevdir; case kCdScopeBuffer: - return curbuf->b_prevdir; - break; + return &curbuf->b_prevdir; case kCdScopeWindow: - return curwin->w_prevdir; - break; + return &curwin->w_prevdir; default: - return prev_dir; + return &prev_dir; } } @@ -6241,7 +6238,7 @@ static void post_chdir(CdScope scope, bool trigger_dirchanged) } if (scope < kCdScopeGlobal) { - char *pdir = get_prevdir(scope); + char *pdir = *get_prevdir(scope); // If still in global directory, set CWD as the global directory. if (globaldir == NULL && pdir != NULL) { globaldir = xstrdup(pdir); @@ -6291,7 +6288,7 @@ bool changedir_func(char *new_dir, CdScope scope) char *pdir = NULL; // ":cd -": Change to previous directory if (strcmp(new_dir, "-") == 0) { - pdir = get_prevdir(scope); + pdir = *get_prevdir(scope); if (pdir == NULL) { emsg(_("E186: No previous directory")); return false; @@ -6327,20 +6324,7 @@ bool changedir_func(char *new_dir, CdScope scope) } xfree(new_dir); - char **pp; - switch (scope) { - case kCdScopeTabpage: - pp = &curtab->tp_prevdir; - break; - case kCdScopeWindow: - pp = &curwin->w_prevdir; - break; - case kCdScopeBuffer: - pp = &curbuf->b_prevdir; - break; - default: - pp = &prev_dir; - } + char **pp = get_prevdir(scope); xfree(*pp); *pp = pdir; diff --git a/src/nvim/ex_session.c b/src/nvim/ex_session.c index 97ce4a9e37..4320e431b0 100644 --- a/src/nvim/ex_session.c +++ b/src/nvim/ex_session.c @@ -675,6 +675,31 @@ static int makeopens(FILE *fd, char *dirnow) return FAIL; } + // Restore buffer-local CWD (:bcd) via one-shot BufEnter handlers: there is no reliable point + // during session-load where all buffers are entered. Each buffer's directory is applied when the + // buffer is first entered, which may happen after session-load for buffers that stay hidden. + if (ssop_flags & kOptSsopFlagCurdir) { + FOR_ALL_BUFFERS(buf) { + if (buf->b_localdir == NULL || buf->b_fname == NULL || !buf->b_p_bl + || (only_save_windows && buf->b_nwindows == 0) + || (buf->b_help && !(ssop_flags & kOptSsopFlagHelp)) + || (bt_terminal(buf) && !(ssop_flags & kOptSsopFlagTerminal))) { + continue; + } + // `bufadd()` finds the buffer by exact (literal) name. + if (fputs("lua vim.api.nvim_create_autocmd('BufEnter', { once = true, " + "buffer = vim.fn.bufadd([==[", fd) < 0 + || fputs(ses_get_fname(buf, &ssop_flags), fd) < 0 + || fputs("]==]), callback = function() vim.cmd.bcd({ [==[", fd) < 0 + || fputs(buf->b_localdir, fd) < 0 + || fputs("]==], magic = { file = false, bar = false } }) end })\n", fd) < 0) { + return FAIL; + } + // Cwd may change when a handler fires: filenames must be written absolute from here on. + did_lcd = true; + } + } + if (ssop_flags & kOptSsopFlagResize) { // Note: after the restore we still check it worked! if (fprintf(fd, "set lines=%" PRId64 " columns=%" PRId64 "\n", @@ -739,6 +764,23 @@ static int makeopens(FILE *fd, char *dirnow) tab_topframe = topframe; } + // Restore the tab-local working directory while the tab still shows its empty "placeholder" + // buffer: ":tcd" clears the b_localdir of the current buffer, so emitting it before any file is + // loaded keeps it away from directories restored by the ":bcd" handlers above. Do this before + // the windows and buffers, so the win-local / buf-local dir can override the tab-local dir. + if ((ssop_flags & kOptSsopFlagCurdir) && tp->tp_localdir != NULL) { + if (need_tabnext && put_line(fd, "tabnext") == FAIL) { + return FAIL; + } + need_tabnext = false; + if (fputs("tcd ", fd) < 0 + || ses_put_fname(fd, tp->tp_localdir, &ssop_flags) == FAIL + || put_eol(fd) == FAIL) { + return FAIL; + } + did_lcd = true; + } + // Before creating the window layout, try loading one file. If this // is aborted we don't end up with a number of useless windows. // This may have side effects! (e.g., compressed or network file). @@ -822,43 +864,6 @@ static int makeopens(FILE *fd, char *dirnow) return FAIL; } - // Restore the tab-local working directory if specified. Do this before the buffers and windows, - // so the buf-local / win-local directory can override the tab-local directory. - if ((ssop_flags & kOptSsopFlagCurdir) && tp->tp_localdir != NULL) { - if (fputs("tcd ", fd) < 0 - || ses_put_fname(fd, tp->tp_localdir, &ssop_flags) == FAIL - || put_eol(fd) == FAIL) { - return FAIL; - } - did_lcd = true; - } - - // Restore buffer-local CWD (:bcd) via a one-shot BufEnter handler: there is no reliable point - // during session-load where all buffers are entered. Re-applied per tab, AFTER the ":tcd" - // above: ":tcd" clears the b_localdir of the then-current buffer, which may be a buffer whose - // autocmd already fired. - if (ssop_flags & kOptSsopFlagCurdir) { - FOR_ALL_BUFFERS(buf) { - if (buf->b_localdir == NULL || buf->b_fname == NULL || !buf->b_p_bl - || (only_save_windows && buf->b_nwindows == 0) - || (buf->b_help && !(ssop_flags & kOptSsopFlagHelp)) - || (bt_terminal(buf) && !(ssop_flags & kOptSsopFlagTerminal))) { - continue; - } - // `bufadd()` finds the buffer by exact (literal) name. - if (fputs("lua vim.api.nvim_create_autocmd('BufEnter', { once = true, " - "buffer = vim.fn.bufadd([=[", fd) < 0 - || fputs(ses_get_fname(buf, &ssop_flags), fd) < 0 - || fputs("]=]), callback = function() vim.cmd.bcd({ [=[", fd) < 0 - || fputs(buf->b_localdir, fd) < 0 - || fputs("]=], magic = { file = false, bar = false } }) end })\n", fd) < 0) { - return FAIL; - } - // Cwd may change when the autocmd fires: filenames must be written absolute from here on. - did_lcd = true; - } - } - // Restore the view of the window (options, file, cursor, etc.). for (win_T *wp = tab_firstwin; wp != NULL; wp = wp->w_next) { if (!ses_do_win(wp)) { diff --git a/src/nvim/window.c b/src/nvim/window.c index d9b01af856..a4cdf68784 100644 --- a/src/nvim/window.c +++ b/src/nvim/window.c @@ -5355,12 +5355,7 @@ static void win_enter_ext(win_T *const wp, const int flags) /// Used after making another window or buffer the current one: change directory if needed. void fix_current_dir(bool caused_by_win) { - CdCause cause; - if (caused_by_win) { - cause = kCdCauseWindow; - } else { - cause = kCdCauseBuffer; - } + CdCause cause = caused_by_win ? kCdCauseWindow : kCdCauseBuffer; // New directory is either the local directory of the window, buffer, tab or NULL. char *new_dir; diff --git a/test/functional/ex_cmds/cd_spec.lua b/test/functional/ex_cmds/cd_spec.lua index 2881a4634c..2057866d09 100644 --- a/test/functional/ex_cmds/cd_spec.lua +++ b/test/functional/ex_cmds/cd_spec.lua @@ -24,6 +24,10 @@ local directories = { local tmpfile = 'Xtest-functional-ex_cmds-cd_spec-tmpfile' +local function join(...) + return table.concat({ ... }, pathsep) +end + -- Shorthand writing to get the current working directory local cwd = function(...) return call('getcwd', ...) @@ -53,22 +57,25 @@ local tlwd = function() end -- tab dir --local glwd = function() return eval('haslocaldir(-1, -1)') end -- global dir +local function before_test() + clear() + for _, d in pairs(directories) do + mkdir(d) + end + directories.start = cwd() +end + +local function remove_dirs() + for _, d in pairs(directories) do + vim.uv.fs_rmdir(d) + end +end + -- Test both the `cd` and `chdir` variants for _, cmd in ipairs { 'cd', 'chdir' } do describe(':' .. cmd, function() - before_each(function() - clear() - for _, d in pairs(directories) do - mkdir(d) - end - directories.start = cwd() - end) - - after_each(function() - for _, d in pairs(directories) do - vim.uv.fs_rmdir(d) - end - end) + before_each(before_test) + after_each(remove_dirs) describe('using explicit scope', function() it('for window', function() @@ -156,7 +163,15 @@ for _, cmd in ipairs { 'cd', 'chdir' } do -- Change buffer-local directory to subdirectory command('bcd ' .. directories.buffer) - eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + eq(join(globalDir, directories.buffer), cwd()) + eq(1, blwd()) + + -- ":bcd -" changes to the previous directory, still buffer-scoped. + command('bcd -') + eq(globalDir, cwd()) + eq(1, blwd()) + command('bcd -') + eq(join(globalDir, directories.buffer), cwd()) eq(1, blwd()) -- Verify Other buffer is unchanged @@ -169,7 +184,7 @@ for _, cmd in ipairs { 'cd', 'chdir' } do command('b# ') command(('e %s3'):format(tmpfile)) eq(1, blwd()) - eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + eq(join(globalDir, directories.buffer), cwd()) command(('e ..%s%s1'):format(pathsep, tmpfile)) eq(0, blwd()) eq(globalDir, cwd()) @@ -179,7 +194,7 @@ for _, cmd in ipairs { 'cd', 'chdir' } do command(('split %s%s%s2'):format(directories.buffer, pathsep, tmpfile)) command('wincmd p') eq(globalDir, cwd()) - eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd(1)) + eq(join(globalDir, directories.buffer), cwd(1)) eq(globalDir, cwd(2)) end) end) @@ -315,7 +330,7 @@ for _, cmd in ipairs { 'cd', 'chdir' } do -- Change tab-local working directory and verify it is different command(('silent t%s %s'):format(cmd, directories.tab)) - eq(('%s%s%s'):format(globalDir, pathsep, directories.tab), cwd()) + eq(join(globalDir, directories.tab), cwd()) eq(cwd(), tcwd()) -- Working directory matches tab directory eq(1, tlwd()) eq(cwd(), bcwd()) -- Still no buffer-directory @@ -323,8 +338,8 @@ for _, cmd in ipairs { 'cd', 'chdir' } do -- Change buffer 2's buffer-local directory command(('silent b%s ..%s%s'):format(cmd, pathsep, directories.buffer)) - eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) - eq(('%s%s%s'):format(globalDir, pathsep, directories.tab), tcwd()) + eq(join(globalDir, directories.buffer), cwd()) + eq(join(globalDir, directories.tab), tcwd()) eq(cwd(), bcwd()) -- Has no buffer-directory eq(1, blwd()) @@ -334,7 +349,7 @@ for _, cmd in ipairs { 'cd', 'chdir' } do -- Verify buffer 2 has buffer-local directory even in first tab command(('b %s2'):format(tmpfile)) -- Switch to buffer 2 - eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), bcwd()) + eq(join(globalDir, directories.buffer), bcwd()) eq(0, tlwd()) -- Still no tab-local directory -- Verify buffer 1 did not have its directory changed @@ -357,7 +372,7 @@ for _, cmd in ipairs { 'cd', 'chdir' } do -- Change buffer-local working directory and verify it is different command(('silent b%s %s'):format(cmd, directories.buffer)) - eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + eq(join(globalDir, directories.buffer), cwd()) eq(cwd(), bcwd()) -- Working directory matches buffer directory eq(1, blwd()) eq(cwd(), wcwd()) -- Still no window-directory @@ -365,68 +380,52 @@ for _, cmd in ipairs { 'cd', 'chdir' } do -- Change window-local directory to test `:lcd` command(('silent l%s ../%s'):format(cmd, directories.window)) - eq(('%s%s%s'):format(globalDir, pathsep, directories.window), cwd()) - eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), bcwd()) + eq(join(globalDir, directories.window), cwd()) + eq(join(globalDir, directories.buffer), bcwd()) eq(1, blwd()) -- Verify buffer has buffer-local directory in original window command('wincmd w') command('b ' .. tmpfile) - eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + eq(join(globalDir, directories.buffer), cwd()) -- Verify going to second window uses window-local directory command('wincmd w') - eq(('%s%s%s'):format(globalDir, pathsep, directories.window), cwd()) + eq(join(globalDir, directories.window), cwd()) end) end) end for _, cmd in ipairs { 'bcd', 'bchdir' } do describe(':' .. cmd, function() - before_each(function() - clear() - for _, d in pairs(directories) do - mkdir(d) - end - directories.start = cwd() - end) - - after_each(function() - for _, d in pairs(directories) do - vim.uv.fs_rmdir(d) - end - end) + 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('makes :new use the buffer-local directory', function() - local globalDir = directories.start + it('makes :new/:vnew/:enew use the buffer-local directory', function() + local bufdir = join(directories.start, directories.buffer) command(('%s %s'):format(cmd, directories.buffer)) command(':new') - eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + eq(bufdir, cwd()) command('wincmd x') -- close :new window command(':vnew') - eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + eq(bufdir, cwd()) command('wincmd x') -- close :vnew window command(':enew') - eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) - end) - - it('makes :enew use the buffer-local directory in a split', function() - local globalDir = directories.start - command(('%s %s'):format(cmd, directories.buffer)) + eq(bufdir, cwd()) + -- Also in a split. command(':vsplit') - eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) - + eq(bufdir, cwd()) command(':enew') - eq(('%s%s%s'):format(globalDir, pathsep, directories.buffer), cwd()) + eq(bufdir, cwd()) end) end) end @@ -438,46 +437,34 @@ for _, cmd in ipairs { 'getcwd', 'haslocaldir' } do clear() end) - -- Test invalid argument types - local err474 = 'Vim(call):E474: Invalid argument' - it('fails on string', function() - eq(err474, pcall_err(command, 'call ' .. cmd .. '("some string")')) - end) - it('fails on float', function() - eq(err474, pcall_err(command, 'call ' .. cmd .. '(1.0)')) - end) - it('fails on list', function() - eq(err474, pcall_err(command, 'call ' .. cmd .. '([1, 2])')) - end) - it('fails on dictionary', function() - eq(err474, pcall_err(command, 'call ' .. cmd .. '({"key": "value"})')) - end) - it('fails on funcref', function() - eq(err474, pcall_err(command, 'call ' .. cmd .. '(function("tr"))')) - end) + it('validation', function() + local err474 = 'Vim(call):E474: Invalid argument' + eq(err474, pcall_err(command, ('call %s("some string")'):format(cmd))) + eq(err474, pcall_err(command, ('call %s(1.0)'):format(cmd))) + eq(err474, pcall_err(command, ('call %s([1, 2])'):format(cmd))) + eq(err474, pcall_err(command, ('call %s({"key": "value"})'):format(cmd))) + eq(err474, pcall_err(command, ('call %s(function("tr"))'):format(cmd))) + eq(err474, pcall_err(command, ('call %s(-2)'):format(cmd))) - -- Test invalid numbers - it('fails on number less than -1', function() - eq(err474, pcall_err(command, 'call ' .. cmd .. '(-2)')) - end) - local err5001 = 'Vim(call):E5001: Higher scope cannot be -1 if lower scope is >= 0.' - local err5006 = 'Vim(call):E5006: Window and tab scope must be -1 when using buffer scope' - local err5007 = 'Vim(call):E5007: Cannot find buffer number.' - it('fails on -1 if previous arg is >=0', function() - eq(err5001, pcall_err(command, 'call ' .. cmd .. '(0, -1)')) - end) - it('fails when 1st and 2nd args != -1 when 3rd arg > -1', function() + -- -1 preceded by an argument >= 0 + eq( + 'Vim(call):E5001: Higher scope cannot be -1 if lower scope is >= 0.', + pcall_err(command, ('call %s(0, -1)'):format(cmd)) + ) + -- Buffer scope requires window and tab arguments of -1. + local err5006 = 'Vim(call):E5006: Window and tab scope must be -1 when using buffer scope' eq(err5006, pcall_err(command, ('call %s(0, 0, 0)'):format(cmd))) eq(err5006, pcall_err(command, ('call %s(1, 2, 3)'):format(cmd))) - end) - it('fails when passing a bufnr that does not exist', function() - eq(err5007, pcall_err(command, ('call %s(-1, -1, 99999)'):format(cmd))) - end) - - -- Test wrong number of arguments - local err118 = 'Vim(call):E118: Too many arguments for function: ' .. cmd - it('fails to parse more than one argument', function() - eq(err118, pcall_err(command, ('call %s(0, 0, 0, 0)'):format(cmd))) + -- Nonexistent buffer. + eq( + 'Vim(call):E5007: Cannot find buffer number.', + pcall_err(command, ('call %s(-1, -1, 99999)'):format(cmd)) + ) + -- Too many arguments. + eq( + ('Vim(call):E118: Too many arguments for function: %s'):format(cmd), + pcall_err(command, ('call %s(0, 0, 0, 0)'):format(cmd)) + ) end) end) end diff --git a/test/functional/ex_cmds/mksession_spec.lua b/test/functional/ex_cmds/mksession_spec.lua index 866950bf49..2c115f605e 100644 --- a/test/functional/ex_cmds/mksession_spec.lua +++ b/test/functional/ex_cmds/mksession_spec.lua @@ -418,6 +418,9 @@ describe(':mksession', function() clear() command('source ' .. session_file) + -- The ":tcd" during restore must not clear the buffer-local directory of the tab's + -- first-loaded buffer: it must be set before that tab is ever (re)visited. + eq(1, fn.haslocaldir(-1, -1, fn.bufnr(('%s2'):format(tmpfile_base)))) -- First tab should have the original working directory. command('tabnext 1') eq(cwd_dir, fn.getcwd()) From 46ca2365256e9d9fa7d845d6757b15ea96e47fbc Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Tue, 4 Aug 2026 15:45:45 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix(cwd):=20validate=20getcwd(=E2=80=A6,=20?= =?UTF-8?q?-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- runtime/doc/editing.txt | 55 +++++++++++++++++++---------- runtime/doc/index.txt | 2 ++ runtime/doc/vim_diff.txt | 1 + runtime/doc/vimfn.txt | 14 ++++---- runtime/lua/vim/_meta/vimfn.gen.lua | 14 ++++---- src/nvim/eval.lua | 14 ++++---- src/nvim/eval/fs.c | 16 ++++++--- src/nvim/ex_docmd.c | 8 ++--- src/nvim/vim_defs.h | 2 +- test/functional/ex_cmds/cd_spec.lua | 46 ++++++++++++------------ 10 files changed, 99 insertions(+), 73 deletions(-) diff --git a/runtime/doc/editing.txt b/runtime/doc/editing.txt index 04f88d8058..9e09bd4121 100644 --- a/runtime/doc/editing.txt +++ b/runtime/doc/editing.txt @@ -1357,7 +1357,7 @@ Use |getcwd()| to get the effective directory from the current scope. The window-local working directory takes precedence over the buffer-local working directory, which takes precedence over the tab-local working directory, which in turn takes precedence over the global working directory. If a local -working directory (window, buffer or tab) does not exist, the next-higher +working directory (tab, window or buffer) does not exist, the next-higher scope in the hierarchy applies. *:cd* *E747* *E472* @@ -1388,7 +1388,7 @@ scope in the hierarchy applies. *:tc* *:tcd* :tc[d][!] {path} Like |:cd|, but only set the directory for the current - tab. The current buffer and window will also use this + tab. The current window and buffer will also use this directory. The current directory is not changed for windows in other tabs and for windows in the current tab that have their own window-local directory, or for @@ -1401,15 +1401,10 @@ scope in the hierarchy applies. *:tch* *:tchdir* :tch[dir][!] Same as |:tcd|. - *:bc* *:bcd* -:bc[d][!] {path} Like |:cd|, but only set the current directory for the - current buffer. The current directory for other - windows or buffers or tabs is not changed. - *:lc* *:lcd* :lc[d][!] {path} Like |:cd|, but only set the current directory for the current window. The current directory for other - windows, tabs, or buffers is not changed. + tabs, windows, or buffers is not changed. *:lch* *:lchdir* :lch[dir][!] Same as |:lcd|. @@ -1418,6 +1413,18 @@ scope in the hierarchy applies. :lc[d][!] - Change to the previous current directory (before the previous ":lcd {path}" command). + *:bc* *:bcd* +:bc[d][!] [path] Like |:cd|, but only set the current directory for the + current buffer. The current directory for other + tabs, windows, or buffers is not changed. + + *:bcd-* +:bc[d][!] - Change to the previous buffer-local current directory + (before the last ":bcd [path]" command). + + *:bch* *:bchdir* +:bch[dir][!] Same as |:bcd|. + *:pw* *:pwd* *E187* :pw[d] Print the current directory name. Also see |getcwd()|. @@ -1429,24 +1436,34 @@ scope in the hierarchy applies. :verbose pwd [global] /path/to/current - " Set by :lcd - :verbose pwd - [window] /path/to/current - " Set by :tcd :verbose pwd [tabpage] /path/to/current -So long as no |:lcd| or |:tcd| command has been used, all windows share the -same current directory. Using a command to jump to another window doesn't -change anything for the current directory. + " Set by :lcd + :verbose pwd + [window] /path/to/current + + " Set by :bcd + :verbose pwd + [buffer] /path/to/current + +So long as no |:tcd|, |:lcd| or |:bcd| command has been used, all windows +share the same current directory. Using a command to jump to another window +doesn't change anything for the current directory. When |:lcd| has been used for a window, the specified directory becomes the current directory for that window. Windows where the |:lcd| command has not -been used stick to the global or tab-local directory. When jumping to another -window the current directory is changed to the last specified local current -directory. If none was specified, the global or tab-local directory is used. -When creating a new window it inherits the local directory of the current window. +been used stick to the buffer-local, tab-local or global directory. When +jumping to another window the current directory is changed to the last +specified local current directory. If none was specified, the buffer-local, +tab-local or global directory is used. When creating a new window it +inherits the local directory of the current window. + +When |:bcd| has been used for a buffer, the specified directory becomes the +current directory whenever that buffer is entered, unless the window has its +own window-local directory. A new buffer created with |:edit| or |:enew| +inherits the buffer-local directory of the previous current buffer. When changing tabs the same behaviour applies. If the current tab has no local working directory the global working directory is used. diff --git a/runtime/doc/index.txt b/runtime/doc/index.txt index ee667bed3e..e7b4470ba8 100644 --- a/runtime/doc/index.txt +++ b/runtime/doc/index.txt @@ -1181,6 +1181,8 @@ Tag Command Action ~ |:ball| :ba[ll] open a window for each buffer in the buffer list |:badd| :bad[d] add buffer to the buffer list |:balt| :balt like ":badd" but also set the alternate file +|:bcd| :bc[d] change directory for buffer +|:bchdir| :bch[dir] change directory for buffer |:bdelete| :bd[elete] remove a buffer from the buffer list |:belowright| :bel[owright] make split window appear right or below |:bfirst| :bf[irst] go to first buffer in the buffer list diff --git a/runtime/doc/vim_diff.txt b/runtime/doc/vim_diff.txt index 698be950d0..ee33bf2242 100644 --- a/runtime/doc/vim_diff.txt +++ b/runtime/doc/vim_diff.txt @@ -312,6 +312,7 @@ Command-line: is open. The |cmdwin-char| is shown via 'statuscolumn'. Commands: +- |:bcd| - |:checkhealth| - |:connect| - |:detach| diff --git a/runtime/doc/vimfn.txt b/runtime/doc/vimfn.txt index a7e840d093..38be4b38f3 100644 --- a/runtime/doc/vimfn.txt +++ b/runtime/doc/vimfn.txt @@ -3774,9 +3774,8 @@ getcwd([{winnr} [, {tabnr} [, {bufnr}]]]) directory of the window indicated by {winnr}. If {bufnr} is provided, {winnr} and {tabnr} must be -1 and the - working directory of that buffer is returned. If {bufnr} is - -1, it is ignored, and the global working directory is - returned. + working directory of that buffer is returned. An argument may + be -1 only if all preceding arguments are -1. Examples of buffer usage: >vim getcwd(-1, -1, 0) " Get current buffer's directory getcwd(-1, -1, 3) " Get directory of buffer #3 @@ -5013,15 +5012,15 @@ has_key({dict}, {key}) *has_key()* (`0|1`) haslocaldir([{winnr} [, {tabnr} [, {bufnr} ]]]) *haslocaldir()* - Checks whether the window, tabpage or buffer has set a local + Checks whether the tabpage, window or buffer has set a local working directory. Returns 1 when the window has set a local path via |:lcd|, or when {winnr} is -1 and the tabpage has set a local path via |:tcd|, or when {winnr} and {tabnr} are -1 and {bufnr} has set a local path via |:bcd|, otherwise 0. Tabs, windows and buffers are identified by their respective - numbers, 0 means current tab or window. Missing argument - implies 0. Thus the following are equivalent: >vim + numbers, 0 means current tab, window or buffer. Missing + argument implies 0. Thus the following are equivalent: >vim echo haslocaldir() echo haslocaldir(0) echo haslocaldir(0, 0) @@ -5030,7 +5029,8 @@ haslocaldir([{winnr} [, {tabnr} [, {bufnr} ]]]) *haslocaldir()* {winnr} is a |window-number| or |window-ID|. If {winnr} is -1 it is ignored, only the tab is resolved. If {bufnr} is provided, {winnr} and {tabnr} must be -1 and - only the buffer is resolved. + only the buffer is resolved. An argument may be -1 only if + all preceding arguments are -1. Examples of buffer usage: >vim haslocaldir(-1, -1, 0) " Current buf has a local directory? haslocaldir(-1, -1, 3) " Buf #3 has a local directory? diff --git a/runtime/lua/vim/_meta/vimfn.gen.lua b/runtime/lua/vim/_meta/vimfn.gen.lua index 6112415d09..1806838eb4 100644 --- a/runtime/lua/vim/_meta/vimfn.gen.lua +++ b/runtime/lua/vim/_meta/vimfn.gen.lua @@ -3341,9 +3341,8 @@ function vim.fn.getcursorcharpos(winid) end --- directory of the window indicated by {winnr}. --- --- If {bufnr} is provided, {winnr} and {tabnr} must be -1 and the ---- working directory of that buffer is returned. If {bufnr} is ---- -1, it is ignored, and the global working directory is ---- returned. +--- working directory of that buffer is returned. An argument may +--- be -1 only if all preceding arguments are -1. --- Examples of buffer usage: >vim --- getcwd(-1, -1, 0) " Get current buffer's directory --- getcwd(-1, -1, 3) " Get directory of buffer #3 @@ -4462,15 +4461,15 @@ function vim.fn.has(feature) end --- @return 0|1 function vim.fn.has_key(dict, key) end ---- Checks whether the window, tabpage or buffer has set a local +--- Checks whether the tabpage, window or buffer has set a local --- working directory. Returns 1 when the window has set a local --- path via |:lcd|, or when {winnr} is -1 and the tabpage has set --- a local path via |:tcd|, or when {winnr} and {tabnr} are -1 --- and {bufnr} has set a local path via |:bcd|, otherwise 0. --- --- Tabs, windows and buffers are identified by their respective ---- numbers, 0 means current tab or window. Missing argument ---- implies 0. Thus the following are equivalent: >vim +--- numbers, 0 means current tab, window or buffer. Missing +--- argument implies 0. Thus the following are equivalent: >vim --- echo haslocaldir() --- echo haslocaldir(0) --- echo haslocaldir(0, 0) @@ -4479,7 +4478,8 @@ function vim.fn.has_key(dict, key) end --- {winnr} is a |window-number| or |window-ID|. --- If {winnr} is -1 it is ignored, only the tab is resolved. --- If {bufnr} is provided, {winnr} and {tabnr} must be -1 and ---- only the buffer is resolved. +--- only the buffer is resolved. An argument may be -1 only if +--- all preceding arguments are -1. --- Examples of buffer usage: >vim --- haslocaldir(-1, -1, 0) " Current buf has a local directory? --- haslocaldir(-1, -1, 3) " Buf #3 has a local directory? diff --git a/src/nvim/eval.lua b/src/nvim/eval.lua index 9384b03933..4df067481e 100644 --- a/src/nvim/eval.lua +++ b/src/nvim/eval.lua @@ -4116,9 +4116,8 @@ M.funcs = { directory of the window indicated by {winnr}. If {bufnr} is provided, {winnr} and {tabnr} must be -1 and the - working directory of that buffer is returned. If {bufnr} is - -1, it is ignored, and the global working directory is - returned. + working directory of that buffer is returned. An argument may + be -1 only if all preceding arguments are -1. Examples of buffer usage: >vim getcwd(-1, -1, 0) " Get current buffer's directory getcwd(-1, -1, 3) " Get directory of buffer #3 @@ -5429,15 +5428,15 @@ M.funcs = { args = { 0, 3 }, base = 1, desc = [=[ - Checks whether the window, tabpage or buffer has set a local + Checks whether the tabpage, window or buffer has set a local working directory. Returns 1 when the window has set a local path via |:lcd|, or when {winnr} is -1 and the tabpage has set a local path via |:tcd|, or when {winnr} and {tabnr} are -1 and {bufnr} has set a local path via |:bcd|, otherwise 0. Tabs, windows and buffers are identified by their respective - numbers, 0 means current tab or window. Missing argument - implies 0. Thus the following are equivalent: >vim + numbers, 0 means current tab, window or buffer. Missing + argument implies 0. Thus the following are equivalent: >vim echo haslocaldir() echo haslocaldir(0) echo haslocaldir(0, 0) @@ -5446,7 +5445,8 @@ M.funcs = { {winnr} is a |window-number| or |window-ID|. If {winnr} is -1 it is ignored, only the tab is resolved. If {bufnr} is provided, {winnr} and {tabnr} must be -1 and - only the buffer is resolved. + only the buffer is resolved. An argument may be -1 only if + all preceding arguments are -1. Examples of buffer usage: >vim haslocaldir(-1, -1, 0) " Current buf has a local directory? haslocaldir(-1, -1, 3) " Buf #3 has a local directory? diff --git a/src/nvim/eval/fs.c b/src/nvim/eval/fs.c index 22ec8c7087..6826ea4a39 100644 --- a/src/nvim/eval/fs.c +++ b/src/nvim/eval/fs.c @@ -681,6 +681,16 @@ static bool getcwd_scope_args(typval_T *argvars, CdScope default_scope, CdScope } } + // An argument may only be -1 if all preceding arguments are -1: `(2, 3, -1)` is an error. + bool explicit_scope = false; + for (int i = 0; i < argc; i++) { + explicit_scope = explicit_scope || argv[i] >= 0; + if (explicit_scope && argv[i] < 0) { + emsg(_("E5001: Higher scope cannot be -1 if lower scope is >= 0.")); + return false; + } + } + // Narrowest requested scope. Imagine X >= 0: switch (argc) { case 0: @@ -705,7 +715,7 @@ static bool getcwd_scope_args(typval_T *argvars, CdScope default_scope, CdScope if (argv[kBufArg] >= 0) { *scope = kCdScopeBuffer; // (-1, -1, X) } else { - *scope = kCdScopeGlobal; // (..., ..., -1) + *scope = kCdScopeGlobal; // (-1, -1, -1) } break; } @@ -737,10 +747,6 @@ static bool getcwd_scope_args(typval_T *argvars, CdScope default_scope, CdScope // Find the window in `tp` by number. if (argv[kWinArg] >= 0) { - if (argv[kTabArg] < 0) { - emsg(_("E5001: Higher scope cannot be -1 if lower scope is >= 0.")); - return false; - } if (argv[kWinArg] > 0) { *win = find_win_by_nr(&argvars[0], *tp); if (*win == NULL) { diff --git a/src/nvim/ex_docmd.c b/src/nvim/ex_docmd.c index 71412d548d..1da6864edd 100644 --- a/src/nvim/ex_docmd.c +++ b/src/nvim/ex_docmd.c @@ -6222,7 +6222,7 @@ static char **get_prevdir(CdScope scope) /// Deal with the side effects of changing the current directory. /// -/// @param scope Scope of the function call (global, tab, buffer or window). +/// @param scope Scope of the function call (global, tab, window or buffer). static void post_chdir(CdScope scope, bool trigger_dirchanged) { // Always overwrite the window-local CWD. @@ -6275,9 +6275,9 @@ static void post_chdir(CdScope scope, bool trigger_dirchanged) } } -/// Change directory function used by :cd/:tcd/:bcd/:lcd Ex commands and the chdir() function. +/// Change directory function used by :cd/:tcd/:lcd/:bcd Ex commands and the chdir() function. /// @param new_dir The directory to change to. -/// @param scope Scope of the function call (global, tab, buffer or window). +/// @param scope Scope of the function call (global, tab, window or buffer). /// @return true if the directory is successfully changed. bool changedir_func(char *new_dir, CdScope scope) { @@ -6333,7 +6333,7 @@ bool changedir_func(char *new_dir, CdScope scope) return true; } -/// ":cd", ":tcd", ":bcd", ":lcd", ":chdir", "tchdir", ":bchdir", and ":lchdir". +/// ":cd", ":tcd", ":lcd", ":bcd", ":chdir", ":tchdir", ":lchdir", and ":bchdir". void ex_cd(exarg_T *eap) { char *new_dir = eap->arg; diff --git a/src/nvim/vim_defs.h b/src/nvim/vim_defs.h index ae33e4f2cd..41015e226e 100644 --- a/src/nvim/vim_defs.h +++ b/src/nvim/vim_defs.h @@ -45,7 +45,7 @@ typedef enum { /// What caused the current directory to change. typedef enum { kCdCauseOther = -1, - kCdCauseManual, ///< Using `:cd`, `:bcd`, `:tcd`, `:lcd` or `chdir()`. + kCdCauseManual, ///< Using `:cd`, `:tcd`, `:lcd`, `:bcd` or `chdir()`. kCdCauseWindow, ///< Switching to another window. kCdCauseBuffer, ///< Switching to another buffer. kCdCauseAuto, ///< On 'autochdir'. diff --git a/test/functional/ex_cmds/cd_spec.lua b/test/functional/ex_cmds/cd_spec.lua index 2057866d09..6385b6f93f 100644 --- a/test/functional/ex_cmds/cd_spec.lua +++ b/test/functional/ex_cmds/cd_spec.lua @@ -438,32 +438,32 @@ for _, cmd in ipairs { 'getcwd', 'haslocaldir' } do end) it('validation', function() - local err474 = 'Vim(call):E474: Invalid argument' - eq(err474, pcall_err(command, ('call %s("some string")'):format(cmd))) - eq(err474, pcall_err(command, ('call %s(1.0)'):format(cmd))) - eq(err474, pcall_err(command, ('call %s([1, 2])'):format(cmd))) - eq(err474, pcall_err(command, ('call %s({"key": "value"})'):format(cmd))) - eq(err474, pcall_err(command, ('call %s(function("tr"))'):format(cmd))) - eq(err474, pcall_err(command, ('call %s(-2)'):format(cmd))) + local err474 = 'Vim:E474: Invalid argument' + eq(err474, pcall_err(call, cmd, 'some string')) + eq(err474, pcall_err(call, cmd, 1.5)) + eq(err474, pcall_err(call, cmd, { 1, 2 })) + eq(err474, pcall_err(call, cmd, { key = 'value' })) + eq(err474, pcall_err(call, cmd, -2)) + -- Funcref is not representable over RPC. + eq( + 'Vim(call):E474: Invalid argument', + pcall_err(command, ('call %s(function("tr"))'):format(cmd)) + ) -- -1 preceded by an argument >= 0 + local err5001 = 'Vim:E5001: Higher scope cannot be -1 if lower scope is >= 0.' + eq(err5001, pcall_err(call, cmd, 0, -1)) + eq(err5001, pcall_err(call, cmd, 2, 3, -1)) + eq(err5001, pcall_err(call, cmd, -1, 0, -1)) + eq(err5001, pcall_err(call, cmd, 0, -1, 0)) + -- Buffer scope requires window and tab args to be -1. + local err5006 = 'Vim:E5006: Window and tab scope must be -1 when using buffer scope' + eq(err5006, pcall_err(call, cmd, 0, 0, 0)) + eq(err5006, pcall_err(call, cmd, 1, 2, 3)) + eq('Vim:E5007: Cannot find buffer number.', pcall_err(call, cmd, -1, -1, 99999)) eq( - 'Vim(call):E5001: Higher scope cannot be -1 if lower scope is >= 0.', - pcall_err(command, ('call %s(0, -1)'):format(cmd)) - ) - -- Buffer scope requires window and tab arguments of -1. - local err5006 = 'Vim(call):E5006: Window and tab scope must be -1 when using buffer scope' - eq(err5006, pcall_err(command, ('call %s(0, 0, 0)'):format(cmd))) - eq(err5006, pcall_err(command, ('call %s(1, 2, 3)'):format(cmd))) - -- Nonexistent buffer. - eq( - 'Vim(call):E5007: Cannot find buffer number.', - pcall_err(command, ('call %s(-1, -1, 99999)'):format(cmd)) - ) - -- Too many arguments. - eq( - ('Vim(call):E118: Too many arguments for function: %s'):format(cmd), - pcall_err(command, ('call %s(0, 0, 0, 0)'):format(cmd)) + ('Vim:E118: Too many arguments for function: %s'):format(cmd), + pcall_err(call, cmd, 0, 0, 0, 0) ) end) end) From 9cd4dd1c1934b21cf4acc7950da6b2b811c41b71 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Tue, 4 Aug 2026 17:13:32 +0200 Subject: [PATCH 5/5] fix(:bcd): do not "inherit" buffer-local dir Problem: Buffer-local CWD (:bcd) is "sticky", similar to window-local CWD (:lcd). But this contradicts one of its main benefits: per-buffer "project root" for LSP, OSC7. Other problems: - A buffer created with :edit/:enew/:new silently inherits b_localdir (and b_prevdir) from the previous buffer. - curbuf_reusable() refuses to recycle a scratch buffer that has `b_localdir`. - After :new/:vnew/:tabnew the CWD sticks to previous buffer's `b_localdir` even though the new curbuf has none, so :new is not equivalent to ":split | enew", and getcwd() disagrees with haslocaldir(). - Requires "which buffer spawned this buffer" semantics that no other buffer-local state has. Solution: Drop sticky/inherit behavior of buffer-local CWD (:bcd). - do_ecmd: always apply the new curbuf's dir (`fix_current_dir`), like `do_autochdir` already does. :tabnew from a :bcd buffer now reverts to global CWD (and fires DirChanged), same as :tabnew from a :lcd window. - curbuf_reusable(): recycling a scratch buffer frees its b_localdir. To get sticky/inherit behavior of CWD, use `:lcd`. --- runtime/doc/autocmd.txt | 12 ++-- runtime/doc/editing.txt | 22 +++--- runtime/doc/options.txt | 6 +- runtime/doc/vimfn.txt | 23 ++++--- runtime/lua/vim/_meta/options.gen.lua | 6 +- runtime/lua/vim/_meta/vimfn.gen.lua | 23 ++++--- src/nvim/buffer.c | 6 +- src/nvim/context.c | 2 +- src/nvim/eval.lua | 23 ++++--- src/nvim/ex_cmds.c | 19 +----- src/nvim/options.lua | 6 +- src/nvim/window.c | 11 +-- test/functional/autocmd/dirchanged_spec.lua | 76 ++++++++++----------- test/functional/ex_cmds/cd_spec.lua | 40 +++++------ 14 files changed, 140 insertions(+), 135 deletions(-) diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt index 02c4ce80da..98d903134d 100644 --- a/runtime/doc/autocmd.txt +++ b/runtime/doc/autocmd.txt @@ -615,14 +615,14 @@ DiffUpdated After diffs have been updated. Depending on *DirChanged* DirChanged After the |current-directory| was changed. The pattern can be: - "auto" to trigger on 'autochdir'. - "buffer" to trigger on `:bcd` - "global" to trigger on `:cd` - "tabpage" to trigger on `:tcd` - "window" to trigger on `:lcd` + "auto" triggered on 'autochdir'. + "buffer" triggered on `:bcd`. + "global" triggered on `:cd`. + "tabpage" triggered on `:tcd`. + "window" triggered on `:lcd`. Sets these |v:event| keys: cwd: current working directory - scope: "global", "tabpage", "window" + scope: "global", "tabpage", "window", "buffer" changed_window: v:true if we fired the event switching window (or tab) is set to the new directory name. diff --git a/runtime/doc/editing.txt b/runtime/doc/editing.txt index 9e09bd4121..952801d486 100644 --- a/runtime/doc/editing.txt +++ b/runtime/doc/editing.txt @@ -1418,6 +1418,10 @@ scope in the hierarchy applies. current buffer. The current directory for other tabs, windows, or buffers is not changed. + Unlike :lcd and :tcd, this is not "sticky": a new + buffer or window will not "inherit" a buffer-local + directory. + *:bcd-* :bc[d][!] - Change to the previous buffer-local current directory (before the last ":bcd [path]" command). @@ -1448,22 +1452,24 @@ scope in the hierarchy applies. :verbose pwd [buffer] /path/to/current -So long as no |:tcd|, |:lcd| or |:bcd| command has been used, all windows -share the same current directory. Using a command to jump to another window -doesn't change anything for the current directory. +So long as |:tcd|, |:lcd| or |:bcd| has not been used, all buffers and windows +share the same directory. Using a command to jump to another window doesn't +change anything for the current directory. When |:lcd| has been used for a window, the specified directory becomes the current directory for that window. Windows where the |:lcd| command has not been used stick to the buffer-local, tab-local or global directory. When jumping to another window the current directory is changed to the last -specified local current directory. If none was specified, the buffer-local, -tab-local or global directory is used. When creating a new window it -inherits the local directory of the current window. +specified window-local directory. If none was specified, the buffer-local, +tab-local or global directory is used. When creating a new window it inherits +the current window-local directory ("sticky" behavior). When |:bcd| has been used for a buffer, the specified directory becomes the current directory whenever that buffer is entered, unless the window has its -own window-local directory. A new buffer created with |:edit| or |:enew| -inherits the buffer-local directory of the previous current buffer. +own window-local directory. The buffer-local directory is never inherited +(not "sticky"): a buffer created with |:edit|, |:enew|, etc., ignores the +current buffer-local (:bcd) directory, if any. Use |:lcd| if you want "sticky" +behavior. When changing tabs the same behaviour applies. If the current tab has no local working directory the global working directory is used. diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt index c8f1d868a8..8086129f68 100644 --- a/runtime/doc/options.txt +++ b/runtime/doc/options.txt @@ -1323,7 +1323,7 @@ A jump table for the options with a short description can be found at |Q_op|. 'cdhome' 'cdh' boolean (default on on Unix, off on Windows) global Disallowed in |modeline|. |no-modeline-option| - When on, |:cd|, |:tcd| and |:lcd| without an argument changes the + When on, |:cd|, |:tcd|, |:lcd| and |:bcd| without an argument changes the current working directory to the |$HOME| directory like in Unix. When off, those commands just print the current directory name. @@ -1332,7 +1332,7 @@ A jump table for the options with a short description can be found at |Q_op|. global Disallowed in |modeline|. |no-modeline-option| This is a list of directories which will be searched when using the - |:cd|, |:tcd| and |:lcd| commands, provided that the directory being + |:cd|, |:tcd|, |:lcd| and |:bcd| commands, provided that the directory being searched for has a relative path, not an absolute part starting with "/", "./" or "../", the 'cdpath' option is not used then. The 'cdpath' option's value has the same form and semantics as @@ -2058,7 +2058,7 @@ A jump table for the options with a short description can be found at |Q_op|. following occurrence. *cpo-~* ~ When included, don't resolve symbolic links when - changing directory with |:cd|, |:lcd|, or |:tcd|. + changing directory with |:cd|, |:tcd|, |:lcd|, or |:bcd|. This preserves the symbolic link path in buffer names and when displaying the current directory. When excluded (default), symbolic links are resolved to diff --git a/runtime/doc/vimfn.txt b/runtime/doc/vimfn.txt index 38be4b38f3..806ee13d2e 100644 --- a/runtime/doc/vimfn.txt +++ b/runtime/doc/vimfn.txt @@ -1125,8 +1125,8 @@ chdir({dir} [, {scope}]) *chdir()* If {scope} is present, changes the current working directory for the specified scope: - "window" Changes the window local directory. |:lcd| "buffer" Changes the buffer local directory. |:bcd| + "window" Changes the window local directory. |:lcd| "tabpage" Changes the tabpage local directory. |:tcd| "global" Changes the global directory. |:cd| @@ -3759,23 +3759,27 @@ getcwd([{winnr} [, {tabnr} [, {bufnr}]]]) is ignored. Tabs, windows and buffers are identified by their respective - numbers, 0 means current tab or window or buffer. Missing tab - number - implies 0. Thus the following are equivalent: >vim + numbers, 0 means current tab/window/buffer. Missing {tabnr} + implies 0 (missing {bufnr} does not; see below). Thus the + following are equivalent: >vim getcwd(0) getcwd(0, 0) < If {winnr} is -1 it is ignored, only the tab is resolved. {winnr} is a |window-number| or |window-ID|. + If both {winnr} and {tabnr} are -1 and {bufnr} is missing the global working directory is returned. + Note: When {tabnr} is -1 Vim returns an empty string to signal that it is invalid, whereas Nvim returns either the global working directory if {winnr} is -1 or the working directory of the window indicated by {winnr}. - If {bufnr} is provided, {winnr} and {tabnr} must be -1 and the - working directory of that buffer is returned. An argument may - be -1 only if all preceding arguments are -1. + If {bufnr} is provided, {winnr} and {tabnr} must be -1, then + the buffer-local working directory is returned. + + An argument may be -1 only if all preceding arguments are -1. + Examples of buffer usage: >vim getcwd(-1, -1, 0) " Get current buffer's directory getcwd(-1, -1, 3) " Get directory of buffer #3 @@ -5019,8 +5023,9 @@ haslocaldir([{winnr} [, {tabnr} [, {bufnr} ]]]) *haslocaldir()* and {bufnr} has set a local path via |:bcd|, otherwise 0. Tabs, windows and buffers are identified by their respective - numbers, 0 means current tab, window or buffer. Missing - argument implies 0. Thus the following are equivalent: >vim + numbers, 0 means current tab/window/buffer. Missing {winnr} + or {tabnr} implies 0 (missing {bufnr} does not; see below). + Thus the following are equivalent: >vim echo haslocaldir() echo haslocaldir(0) echo haslocaldir(0, 0) diff --git a/runtime/lua/vim/_meta/options.gen.lua b/runtime/lua/vim/_meta/options.gen.lua index dd2d1a7468..6932bbfca5 100644 --- a/runtime/lua/vim/_meta/options.gen.lua +++ b/runtime/lua/vim/_meta/options.gen.lua @@ -734,7 +734,7 @@ vim.o.cmp = vim.o.casemap vim.go.casemap = vim.o.casemap vim.go.cmp = vim.go.casemap ---- When on, `:cd`, `:tcd` and `:lcd` without an argument changes the +--- When on, `:cd`, `:tcd`, `:lcd` and `:bcd` without an argument changes the --- current working directory to the `$HOME` directory like in Unix. --- When off, those commands just print the current directory name. --- @@ -745,7 +745,7 @@ vim.go.cdhome = vim.o.cdhome vim.go.cdh = vim.go.cdhome --- This is a list of directories which will be searched when using the ---- `:cd`, `:tcd` and `:lcd` commands, provided that the directory being +--- `:cd`, `:tcd`, `:lcd` and `:bcd` commands, provided that the directory being --- searched for has a relative path, not an absolute part starting with --- "/", "./" or "../", the 'cdpath' option is not used then. --- The 'cdpath' option's value has the same form and semantics as @@ -1572,7 +1572,7 @@ vim.bo.ci = vim.bo.copyindent --- following occurrence. --- *cpo-~* --- ~ When included, don't resolve symbolic links when ---- changing directory with `:cd`, `:lcd`, or `:tcd`. +--- changing directory with `:cd`, `:tcd`, `:lcd`, or `:bcd`. --- This preserves the symbolic link path in buffer names --- and when displaying the current directory. When --- excluded (default), symbolic links are resolved to diff --git a/runtime/lua/vim/_meta/vimfn.gen.lua b/runtime/lua/vim/_meta/vimfn.gen.lua index 1806838eb4..8444b6d6f4 100644 --- a/runtime/lua/vim/_meta/vimfn.gen.lua +++ b/runtime/lua/vim/_meta/vimfn.gen.lua @@ -960,8 +960,8 @@ function vim.fn.charidx(string, idx, countcc, utf16) end --- --- If {scope} is present, changes the current working directory --- for the specified scope: ---- "window" Changes the window local directory. |:lcd| --- "buffer" Changes the buffer local directory. |:bcd| +--- "window" Changes the window local directory. |:lcd| --- "tabpage" Changes the tabpage local directory. |:tcd| --- "global" Changes the global directory. |:cd| --- @@ -3326,23 +3326,27 @@ function vim.fn.getcursorcharpos(winid) end --- is ignored. --- --- Tabs, windows and buffers are identified by their respective ---- numbers, 0 means current tab or window or buffer. Missing tab ---- number ---- implies 0. Thus the following are equivalent: >vim +--- numbers, 0 means current tab/window/buffer. Missing {tabnr} +--- implies 0 (missing {bufnr} does not; see below). Thus the +--- following are equivalent: >vim --- getcwd(0) --- getcwd(0, 0) --- vim --- getcwd(-1, -1, 0) " Get current buffer's directory --- getcwd(-1, -1, 3) " Get directory of buffer #3 @@ -4468,8 +4472,9 @@ function vim.fn.has_key(dict, key) end --- and {bufnr} has set a local path via |:bcd|, otherwise 0. --- --- Tabs, windows and buffers are identified by their respective ---- numbers, 0 means current tab, window or buffer. Missing ---- argument implies 0. Thus the following are equivalent: >vim +--- numbers, 0 means current tab/window/buffer. Missing {winnr} +--- or {tabnr} implies 0 (missing {bufnr} does not; see below). +--- Thus the following are equivalent: >vim --- echo haslocaldir() --- echo haslocaldir(0) --- echo haslocaldir(0, 0) diff --git a/src/nvim/buffer.c b/src/nvim/buffer.c index 62f332740f..db94778e36 100644 --- a/src/nvim/buffer.c +++ b/src/nvim/buffer.c @@ -1778,7 +1778,7 @@ void set_curbuf(buf_T *buf, int action, bool update_jumplist) } // Maybe cd to buffer-local directory - fix_current_dir(false); + update_cwd(kCdCauseBuffer); } /// Enter a new current buffer. @@ -2031,8 +2031,6 @@ buf_T *buflist_new(char *ffname_arg, char *sfname_arg, linenr_T lnum, int flags) trigger_undo_ftplugin(buf, curwin); // It's like this buffer is deleted. Watch out for autocommands that // change curbuf! If that happens, allocate a new buffer anyway. - // We also ask it to not free the buffer-local directory so we can reuse - // it. buf_freeall(buf, BFA_WIPE | BFA_DEL); if (aborting()) { // autocmds may abort script processing xfree(ffname); @@ -2162,8 +2160,6 @@ bool curbuf_reusable(void) && curbuf->b_ffname == NULL && curbuf->b_nwindows <= 1 && !curbuf->terminal - && curbuf->b_localdir == NULL - && curbuf->b_prevdir == NULL && (curbuf->b_ml.ml_mfp == NULL || buf_is_empty(curbuf)) && !bt_quickfix(curbuf) && !curbufIsChanged()); diff --git a/src/nvim/context.c b/src/nvim/context.c index 769b4b31f5..911f2dd116 100644 --- a/src/nvim/context.c +++ b/src/nvim/context.c @@ -583,7 +583,7 @@ void ctx_restore(CtxSwitch *cs) // 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) { - fix_current_dir(true); + update_cwd(kCdCauseWindow); } if (bufref_valid(&cs->cs_new_curbuf)) { xfree(cs->cs_new_curbuf.br_buf->b_localdir); diff --git a/src/nvim/eval.lua b/src/nvim/eval.lua index 4df067481e..3181857b53 100644 --- a/src/nvim/eval.lua +++ b/src/nvim/eval.lua @@ -1265,8 +1265,8 @@ M.funcs = { If {scope} is present, changes the current working directory for the specified scope: - "window" Changes the window local directory. |:lcd| "buffer" Changes the buffer local directory. |:bcd| + "window" Changes the window local directory. |:lcd| "tabpage" Changes the tabpage local directory. |:tcd| "global" Changes the global directory. |:cd| @@ -4101,23 +4101,27 @@ M.funcs = { is ignored. Tabs, windows and buffers are identified by their respective - numbers, 0 means current tab or window or buffer. Missing tab - number - implies 0. Thus the following are equivalent: >vim + numbers, 0 means current tab/window/buffer. Missing {tabnr} + implies 0 (missing {bufnr} does not; see below). Thus the + following are equivalent: >vim getcwd(0) getcwd(0, 0) vim getcwd(-1, -1, 0) " Get current buffer's directory getcwd(-1, -1, 3) " Get directory of buffer #3 @@ -5435,8 +5439,9 @@ M.funcs = { and {bufnr} has set a local path via |:bcd|, otherwise 0. Tabs, windows and buffers are identified by their respective - numbers, 0 means current tab, window or buffer. Missing - argument implies 0. Thus the following are equivalent: >vim + numbers, 0 means current tab/window/buffer. Missing {winnr} + or {tabnr} implies 0 (missing {bufnr} does not; see below). + Thus the following are equivalent: >vim echo haslocaldir() echo haslocaldir(0) echo haslocaldir(0, 0) diff --git a/src/nvim/ex_cmds.c b/src/nvim/ex_cmds.c index e5584a5353..782dd9b9d8 100644 --- a/src/nvim/ex_cmds.c +++ b/src/nvim/ex_cmds.c @@ -2937,23 +2937,8 @@ int do_ecmd(int fnum, char *ffname, char *sfname, exarg_T *eap, linenr_T newlnum redraw_curbuf_later(UPD_NOT_VALID); // redraw this buffer later } - // A new buffer inherits the buffer-local directory of the buffer it was created from (unless - // autocommands already set one). - if (!oldbuf && curbuf->b_localdir == NULL - && bufref_valid(&old_curbuf) - && old_curbuf.br_buf != curbuf - && old_curbuf.br_buf->b_localdir != NULL) { - curbuf->b_localdir = xstrdup(old_curbuf.br_buf->b_localdir); - if (old_curbuf.br_buf->b_prevdir != NULL) { - curbuf->b_prevdir = xstrdup(old_curbuf.br_buf->b_prevdir); - } - } - - // If editing a buffer in the current window, make sure to update to the - // buffer's working directory. - if (oldwin == curwin) { - fix_current_dir(false); - } + // Update to the working directory of the new current buffer. + update_cwd(kCdCauseBuffer); // Change directories when the 'acd' option is set. do_autochdir(); diff --git a/src/nvim/options.lua b/src/nvim/options.lua index bf487a6d59..a070861233 100644 --- a/src/nvim/options.lua +++ b/src/nvim/options.lua @@ -1093,7 +1093,7 @@ local options = { doc = [[on on Unix, off on Windows]], }, desc = [=[ - When on, |:cd|, |:tcd| and |:lcd| without an argument changes the + When on, |:cd|, |:tcd|, |:lcd| and |:bcd| without an argument changes the current working directory to the |$HOME| directory like in Unix. When off, those commands just print the current directory name. ]=], @@ -1113,7 +1113,7 @@ local options = { deny_duplicates = true, desc = [=[ This is a list of directories which will be searched when using the - |:cd|, |:tcd| and |:lcd| commands, provided that the directory being + |:cd|, |:tcd|, |:lcd| and |:bcd| commands, provided that the directory being searched for has a relative path, not an absolute part starting with "/", "./" or "../", the 'cdpath' option is not used then. The 'cdpath' option's value has the same form and semantics as @@ -2119,7 +2119,7 @@ local options = { following occurrence. *cpo-~* ~ When included, don't resolve symbolic links when - changing directory with |:cd|, |:lcd|, or |:tcd|. + changing directory with |:cd|, |:tcd|, |:lcd|, or |:bcd|. This preserves the symbolic link path in buffer names and when displaying the current directory. When excluded (default), symbolic links are resolved to diff --git a/src/nvim/window.c b/src/nvim/window.c index a4cdf68784..d880fc2f8d 100644 --- a/src/nvim/window.c +++ b/src/nvim/window.c @@ -5300,7 +5300,7 @@ static void win_enter_ext(win_T *const wp, const int flags) win_fix_cursor(get_real_state() & (MODE_NORMAL|MODE_CMDLINE|MODE_TERMINAL)); } - fix_current_dir(true); + update_cwd(kCdCauseWindow); entering_window(curwin); // Careful: autocommands may close the window and make "wp" invalid @@ -5352,11 +5352,12 @@ static void win_enter_ext(win_T *const wp, const int flags) do_autochdir(); } -/// Used after making another window or buffer the current one: change directory if needed. -void fix_current_dir(bool caused_by_win) +/// Applies the effective current-directory of the current window (its window-local, buffer-local, +/// tab-local or the global directory). Called after setting curbuf/curwin. +/// +/// @param cause What caused the switch, reported by the DirChanged event. +void update_cwd(CdCause cause) { - CdCause cause = caused_by_win ? kCdCauseWindow : kCdCauseBuffer; - // New directory is either the local directory of the window, buffer, tab or NULL. char *new_dir; CdScope scope; diff --git a/test/functional/autocmd/dirchanged_spec.lua b/test/functional/autocmd/dirchanged_spec.lua index b743069ed2..6a7f7b9a6c 100644 --- a/test/functional/autocmd/dirchanged_spec.lua +++ b/test/functional/autocmd/dirchanged_spec.lua @@ -6,7 +6,6 @@ local describe, it, before_each, setup, teardown = local clear = n.clear local command = n.command local eq = t.eq -local pcall_err = t.pcall_err local eval = n.eval local request = n.request local is_os = t.is_os @@ -162,7 +161,7 @@ describe('autocmd DirChanged and DirChangedPre', function() eq('E344:', string.match(err3, 'E%d*:')) end) - it("are triggered by 'autochdir'", function() + it("triggered by 'autochdir'", function() command('set autochdir') command('split ' .. dirs[1] .. '/foo') @@ -181,7 +180,7 @@ describe('autocmd DirChanged and DirChangedPre', function() eq(2, eval('g:cdcount')) end) - it('do not trigger if directory has not changed', function() + it('not triggered if directory has not changed', function() command('lcd ' .. dirs[1]) eq({ directory = dirs[1], scope = 'window', changed_window = false }, eval('g:evpre')) eq({ cwd = dirs[1], scope = 'window', changed_window = false }, eval('g:ev')) @@ -299,7 +298,7 @@ describe('autocmd DirChanged and DirChangedPre', function() end end) - it('are triggered by switching to win/tab with different CWD #6054', function() + it('triggered by switching to win/tab with different CWD #6054', function() command('lcd ' .. dirs[3]) -- window 3 command('split ' .. dirs[2] .. '/foo') -- window 2 command('lcd ' .. dirs[2]) @@ -376,7 +375,7 @@ describe('autocmd DirChanged and DirChangedPre', function() end end) - it('are triggered by switching to buf/tab with different CWD', function() + it('triggered by switching to buf/tab with different CWD', function() local files = { dirs[1] .. '/file', dirs[2] .. '/file', @@ -400,9 +399,10 @@ describe('autocmd DirChanged and DirChangedPre', function() eq(5, eval('g:cdprecount')) eq(5, eval('g:cdcount')) - command('tabnew') -- tab 2 (tab-local CWD) - eq(5, eval('g:cdprecount')) -- same CWD, no DirChangedPre event - eq(5, eval('g:cdcount')) -- same CWD, no DirChanged event + command('tabnew') -- tab 2: its new empty buffer has no local CWD, reverts to global + eq({ cwd = curdir, scope = 'global', changed_window = false }, eval('g:ev')) + eq(6, eval('g:cdprecount')) + eq(6, eval('g:cdcount')) command('tcd ' .. dirs[2]) command('tabnext') -- tab 1 (no tab-local CWD) eq({ directory = dirs[3], scope = 'buffer', changed_window = true }, eval('g:evpre')) @@ -414,55 +414,55 @@ describe('autocmd DirChanged and DirChangedPre', function() eq({ cwd = dirs[2], scope = 'tabpage', changed_window = true }, eval('g:ev')) eq('tabpage', eval('g:amatchpre')) eq('tabpage', eval('g:amatch')) - eq(8, eval('g:cdprecount')) - eq(8, eval('g:cdcount')) + eq(9, eval('g:cdprecount')) + eq(9, eval('g:cdcount')) command('tabnext') -- tab 1 command('b ' .. files[2]) -- buffer 2 - eq(10, eval('g:cdprecount')) - eq(10, eval('g:cdcount')) + eq(11, eval('g:cdprecount')) + eq(11, eval('g:cdcount')) command('tabnext') -- tab 2 (has the *same* CWD) - eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event - eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + eq(11, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(11, eval('g:cdcount')) -- same CWD, no DirChanged event if is_os('win') then command('tabnew') -- tab 3 - eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event - eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + eq(11, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(11, eval('g:cdcount')) -- same CWD, no DirChanged event command('tcd ' .. dirs[2]) - eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event - eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + eq(11, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(11, eval('g:cdcount')) -- same CWD, no DirChanged event command('tabnext') -- tab 1 - eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event - eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + eq(11, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(11, eval('g:cdcount')) -- same CWD, no DirChanged event command('tabprevious') -- tab 3 - eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event - eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + eq(11, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(11, eval('g:cdcount')) -- same CWD, no DirChanged event command('tabprevious') -- tab 2 - eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event - eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + eq(11, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(11, eval('g:cdcount')) -- same CWD, no DirChanged event command('tabprevious') -- tab 1 - eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event - eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + eq(11, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(11, eval('g:cdcount')) -- same CWD, no DirChanged event command('bcd ' .. dirs[2]) -- buffer 2 - eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event - eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + eq(11, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(11, eval('g:cdcount')) -- same CWD, no DirChanged event command('tabnext') -- tab 2 - eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event - eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + eq(11, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(11, eval('g:cdcount')) -- same CWD, no DirChanged event command('tabnext') -- tab 3 - eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event - eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + eq(11, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(11, eval('g:cdcount')) -- same CWD, no DirChanged event command('tabnext') -- tab 1 - eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event - eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + eq(11, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(11, eval('g:cdcount')) -- same CWD, no DirChanged event command('tabprevious') -- tab 3 - eq(10, eval('g:cdprecount')) -- same CWD, no DirChangedPre event - eq(10, eval('g:cdcount')) -- same CWD, no DirChanged event + eq(11, eval('g:cdprecount')) -- same CWD, no DirChangedPre event + eq(11, eval('g:cdcount')) -- same CWD, no DirChanged event end end) - it('are triggered by switching to buf/win with different CWD', function() + it('triggered by switching to buf/win with different CWD', function() command('lcd ' .. dirs[3]) -- window 3 command(('split %s/file'):format(dirs[2])) -- window 2 command('lcd ' .. dirs[2]) @@ -511,7 +511,7 @@ describe('autocmd DirChanged and DirChangedPre', function() eq(8, eval('g:cdcount')) -- no DirChanged event, window-local CWD has higher priority end) - it('are triggered by nvim_set_current_dir()', function() + it('triggered by nvim_set_current_dir()', function() request('nvim_set_current_dir', dirs[1]) eq({ directory = dirs[1], scope = 'global', changed_window = false }, eval('g:evpre')) eq({ cwd = dirs[1], scope = 'global', changed_window = false }, eval('g:ev')) diff --git a/test/functional/ex_cmds/cd_spec.lua b/test/functional/ex_cmds/cd_spec.lua index 6385b6f93f..6c8ff1ceb4 100644 --- a/test/functional/ex_cmds/cd_spec.lua +++ b/test/functional/ex_cmds/cd_spec.lua @@ -179,13 +179,9 @@ for _, cmd in ipairs { 'cd', 'chdir' } do eq(globalDir, cwd()) eq(0, blwd()) - -- A new buffer created with :edit inherits the buffer-local directory; editing an - -- existing buffer keeps that buffer's own directory. + -- A new buffer created with :edit does not inherit the buffer-local directory. command('b# ') command(('e %s3'):format(tmpfile)) - eq(1, blwd()) - eq(join(globalDir, directories.buffer), cwd()) - command(('e ..%s%s1'):format(pathsep, tmpfile)) eq(0, blwd()) eq(globalDir, cwd()) @@ -406,26 +402,32 @@ for _, cmd in ipairs { 'bcd', 'bchdir' } do command('bd') -- delete buffer end) - it('makes :new/:vnew/:enew use the buffer-local directory', function() + it('buffer-local directory is NOT sticky/inherited', function() local bufdir = join(directories.start, directories.buffer) + + command('edit ' .. tmpfile) command(('%s %s'):format(cmd, directories.buffer)) - - command(':new') - eq(bufdir, cwd()) - command('wincmd x') -- close :new window - - command(':vnew') - eq(bufdir, cwd()) - command('wincmd x') -- close :vnew window - - command(':enew') eq(bufdir, cwd()) - -- Also in a split. - command(':vsplit') + -- A new buffer starts without a buffer-local directory. + command('new') + eq(directories.start, cwd()) + eq(0, blwd()) + command('close') eq(bufdir, cwd()) - command(':enew') + command('enew') + eq(directories.start, cwd()) + eq(0, blwd()) + command('b# ') eq(bufdir, cwd()) + + -- Recycling an empty unnamed buffer (:edit) drops its directory with it. + command('enew') + command(('%s %s'):format(cmd, directories.buffer)) + eq(bufdir, cwd()) + command('edit ' .. tmpfile .. '2') + eq(directories.start, cwd()) + eq(0, blwd()) end) end) end