From 3b88a8a65da17a49c6aad2d1bcb3eb2163577c06 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:40:13 -0500 Subject: [PATCH] fix(filetype): ensure directory bufname ends w/ slash sep #40552 Problem: `vim.filetype.match()` needs a cheap way to recognize directory buffers without doing filesystem stat work. Solution: Ensure full buffer names for directories end in a trailing slash. Now directory buffers can proceed through the normal 'filetype' path. Note side-effects: session and ShaDa buffer-list restore behavior must be compatible, so those + corresponding tests must be updated. --- runtime/doc/api.txt | 3 ++ runtime/doc/vimfn.txt | 4 ++- runtime/lua/nvim/dir.lua | 3 +- runtime/lua/vim/_meta/api.gen.lua | 3 ++ runtime/lua/vim/_meta/vimfn.gen.lua | 4 ++- runtime/lua/vim/filetype.lua | 3 ++ src/nvim/api/buffer.c | 3 ++ src/nvim/buffer.c | 42 +++++++++++++++++++++- src/nvim/eval.lua | 4 ++- src/nvim/path.c | 2 +- test/functional/api/buffer_spec.lua | 26 ++++++++++++++ test/functional/ex_cmds/mksession_spec.lua | 20 +++++++++++ test/functional/lua/filetype_spec.lua | 9 +++++ test/functional/plugin/dir_spec.lua | 5 +-- 14 files changed, 122 insertions(+), 9 deletions(-) diff --git a/runtime/doc/api.txt b/runtime/doc/api.txt index cfb30a7d30..f643bab7a0 100644 --- a/runtime/doc/api.txt +++ b/runtime/doc/api.txt @@ -2609,6 +2609,9 @@ nvim_buf_get_name({buf}) *nvim_buf_get_name()* Gets the full/absolute filepath of the buffer, or the buffer name for non-file buffers. + If the buffer represents a directory, the name ends with a path separator, + unless it was changed by |:file| or |nvim_buf_set_name()|. + Attributes: ~ Since: 0.1.0 diff --git a/runtime/doc/vimfn.txt b/runtime/doc/vimfn.txt index 239bbe89d7..371779adfa 100644 --- a/runtime/doc/vimfn.txt +++ b/runtime/doc/vimfn.txt @@ -692,7 +692,9 @@ bufname([{buf}]) *bufname()* The result is the name of a buffer. Mostly as it is displayed by the `:ls` command, but not using special names such as - "[No Name]". + "[No Name]". If the buffer represents a directory, the name + ends with a path separator, unless it was changed by |:file| or + |nvim_buf_set_name()|. If {buf} is omitted the current buffer is used. If {buf} is a Number, that buffer number's name is given. Number zero is the alternate buffer for the current window. diff --git a/runtime/lua/nvim/dir.lua b/runtime/lua/nvim/dir.lua index f1d326bf54..23d4d8c09d 100644 --- a/runtime/lua/nvim/dir.lua +++ b/runtime/lua/nvim/dir.lua @@ -158,8 +158,7 @@ end ---@param buf integer local function open_parent(buf) - local path = api.nvim_buf_get_name(buf) - -- TODO(barrettruth): simplify after #40552 + local path = fs.normalize(api.nvim_buf_get_name(buf)) local name = encode_name(fs.basename(path)) .. (vim.fn.isdirectory(path) == 1 and '/' or '') navigate(fs.dirname(path)) vim.fn.search([[\C\m^\V]] .. vim.fn.escape(name, [[\]]) .. [[\m$]], 'cw') diff --git a/runtime/lua/vim/_meta/api.gen.lua b/runtime/lua/vim/_meta/api.gen.lua index 78b3a28c18..5b207281d4 100644 --- a/runtime/lua/vim/_meta/api.gen.lua +++ b/runtime/lua/vim/_meta/api.gen.lua @@ -511,6 +511,9 @@ function vim.api.nvim_buf_get_mark(buf, name) end --- Gets the full/absolute filepath of the buffer, or the buffer name for non-file buffers. --- +--- If the buffer represents a directory, the name ends with a path separator, +--- unless it was changed by `:file` or `nvim_buf_set_name()`. +--- --- @param buf integer Buffer id, or 0 for current buffer --- @return string # Buffer name function vim.api.nvim_buf_get_name(buf) end diff --git a/runtime/lua/vim/_meta/vimfn.gen.lua b/runtime/lua/vim/_meta/vimfn.gen.lua index ede1efae4e..b7ba4d138d 100644 --- a/runtime/lua/vim/_meta/vimfn.gen.lua +++ b/runtime/lua/vim/_meta/vimfn.gen.lua @@ -605,7 +605,9 @@ function vim.fn.bufloaded(buf) end --- --- The result is the name of a buffer. Mostly as it is displayed --- by the `:ls` command, but not using special names such as ---- "[No Name]". +--- "[No Name]". If the buffer represents a directory, the name +--- ends with a path separator, unless it was changed by |:file| or +--- |nvim_buf_set_name()|. --- If {buf} is omitted the current buffer is used. --- If {buf} is a Number, that buffer number's name is given. --- Number zero is the alternate buffer for the current window. diff --git a/runtime/lua/vim/filetype.lua b/runtime/lua/vim/filetype.lua index d04a355cf5..1c355fe2e8 100644 --- a/runtime/lua/vim/filetype.lua +++ b/runtime/lua/vim/filetype.lua @@ -3254,6 +3254,9 @@ function M.match(args) end if name then + if name:sub(-1) == '/' then + return 'directory' + end name = normalize_path(name) local ok_abspath, path = pcall(vim.fs.abspath, name) diff --git a/src/nvim/api/buffer.c b/src/nvim/api/buffer.c index 2995bd64d8..8b73c5bc1b 100644 --- a/src/nvim/api/buffer.c +++ b/src/nvim/api/buffer.c @@ -942,6 +942,9 @@ void nvim_buf_del_var(Buffer buf, String name, Error *err) /// Gets the full/absolute filepath of the buffer, or the buffer name for non-file buffers. /// +/// If the buffer represents a directory, the name ends with a path separator, +/// unless it was changed by |:file| or |nvim_buf_set_name()|. +/// /// @param buf Buffer id, or 0 for current buffer /// @param[out] err Error details, if any /// @return Buffer name diff --git a/src/nvim/buffer.c b/src/nvim/buffer.c index 0903d729ce..f914e09591 100644 --- a/src/nvim/buffer.c +++ b/src/nvim/buffer.c @@ -404,6 +404,19 @@ int open_buffer(bool read_stdin, exarg_T *eap, int flags_arg) curbuf->b_flags |= BF_READERR; } + // Directory buf: + // readfile() returns NOTDONE without firing BufReadPost when it did not read a + // file (e.g. a directory). Since BufReadPost is what normally runs filetype + // detection, do it here so FileType fires before the BufEnter below. + if (retval == NOTDONE && *curbuf->b_p_ft == NUL + && curbuf->b_ffname != NULL + && after_pathsep(curbuf->b_ffname, + curbuf->b_ffname + strlen(curbuf->b_ffname))) { + if (augroup_exists("filetypedetect")) { + do_doautocmd("filetypedetect BufRead", false, NULL); + } + } + // Need to update automatic folding. Do this before the autocommands, // they may use the fold info. foldUpdateAll(curwin); @@ -2022,6 +2035,7 @@ buf_T *buflist_new(char *ffname_arg, char *sfname_arg, linenr_T lnum, int flags) } if (ffname != NULL) { + assert(sfname != NULL); buf->b_ffname = ffname; buf->b_sfname = xstrdup(sfname); } @@ -3597,6 +3611,9 @@ int append_arg_number(win_T *wp, char *buf, size_t buflen) } /// Make "*ffname" a full file name, set "*sfname" to "*ffname" if not NULL. +/// For a directory the resulting "*ffname" gets a trailing path separator. When +/// "*sfname" already ends with a separator the final component is not resolved, +/// so the symbolic link path is preserved. /// "*ffname" becomes a pointer to allocated memory (or NULL). /// When resolving a link both "*sfname" and "*ffname" will point to the same /// allocated memory. @@ -3610,7 +3627,30 @@ void fname_expand(buf_T *buf, char **ffname, char **sfname) if (*sfname == NULL) { // no short file name given, use ffname *sfname = *ffname; } - *ffname = fix_fname((*ffname)); // expand to full path + *ffname = fix_fname(*ffname); // expand to full path + if (*ffname == NULL) { + *sfname = NULL; + return; + } + // Preserve a symbolic link path for directory buffers. fix_fname("link/") + // resolves to the target, so re-expand without the trailing separator: if + // "link" is a symbolic link to directory "target", ":edit link/" produces + // ".../link/", not ".../target/". + if (os_isdir(*ffname)) { + char *name = xstrdup(*sfname); + size_t name_len = strlen(name); + if (name_len > 0 && after_pathsep(name, name + name_len) + && name + name_len > get_past_head(name)) { + *path_tail_with_sep(name) = NUL; + } + char *full = fix_fname(name); + xfree(name); + if (full != NULL) { + xfree(*ffname); + *ffname = full; + } + *ffname = concat_fnames_realloc(*ffname, "", true); + } #ifdef MSWIN if (!buf->b_p_bin) { diff --git a/src/nvim/eval.lua b/src/nvim/eval.lua index 980044fac3..a14d498d28 100644 --- a/src/nvim/eval.lua +++ b/src/nvim/eval.lua @@ -823,7 +823,9 @@ M.funcs = { desc = [=[ The result is the name of a buffer. Mostly as it is displayed by the `:ls` command, but not using special names such as - "[No Name]". + "[No Name]". If the buffer represents a directory, the name + ends with a path separator, unless it was changed by |:file| or + |nvim_buf_set_name()|. If {buf} is omitted the current buffer is used. If {buf} is a Number, that buffer number's name is given. Number zero is the alternate buffer for the current window. diff --git a/src/nvim/path.c b/src/nvim/path.c index 184783d25c..79391674c7 100644 --- a/src/nvim/path.c +++ b/src/nvim/path.c @@ -389,7 +389,7 @@ int path_fnamecmp(const char *fname1, const char *fname2) const size_t len2 = strlen(fname2); return path_fnamencmp(fname1, fname2, MAX(len1, len2)); #else - return mb_strcmp_ic((bool)p_fic, fname1, fname2); + return pathcmp(fname1, fname2, -1); #endif } diff --git a/test/functional/api/buffer_spec.lua b/test/functional/api/buffer_spec.lua index a44af2b4eb..ec8f8248e5 100644 --- a/test/functional/api/buffer_spec.lua +++ b/test/functional/api/buffer_spec.lua @@ -2392,6 +2392,32 @@ describe('api/buf', function() os.remove(new_name) end) + it('directory buffer-name always ends with slash', function() + local cwd = t.fix_slashes(fn.getcwd()) + local dir = 'Xtest_dir_name' + t.mkdir(dir) + finally(function() + n.rmdir(dir) + end) + api.nvim_buf_set_name(0, dir) + eq(cwd .. '/' .. dir .. '/', t.fix_slashes(api.nvim_buf_get_name(0))) + end) + + it('directory buffer-name preserves symbolic link path', function() + t.skip(t.is_os('win'), 'N/A for Windows') + local cwd = t.fix_slashes(fn.getcwd()) + local target = 'Xtest_dir_target' + local link = 'Xtest_dir_link' + t.mkdir(target) + assert(vim.uv.fs_symlink(assert(vim.uv.fs_realpath(target)), link, { dir = true })) + finally(function() + os.remove(link) + n.rmdir(target) + end) + api.nvim_buf_set_name(0, link .. '/') + eq(cwd .. '/' .. link .. '/', t.fix_slashes(api.nvim_buf_get_name(0))) + end) + describe("with 'autochdir'", function() local topdir local oldbuf diff --git a/test/functional/ex_cmds/mksession_spec.lua b/test/functional/ex_cmds/mksession_spec.lua index 3b4d17377b..f4819a1bc2 100644 --- a/test/functional/ex_cmds/mksession_spec.lua +++ b/test/functional/ex_cmds/mksession_spec.lua @@ -166,6 +166,26 @@ describe(':mksession', function() eq(cwd_dir .. '/' .. tmpfile_base .. '2', fn.expand('%:p')) end) + it('restores symlinked directory buffer names', function() + skip(is_os('win'), 'N/A for Windows') + + local cwd_dir = t.fix_slashes(fn.getcwd()) + local link_dir = file_prefix .. '-link' + assert(vim.uv.fs_symlink(assert(vim.uv.fs_realpath(tab_dir)), link_dir, { dir = true })) + finally(function() + os.remove(link_dir) + end) + + command('set sessionoptions=buffers') + command('edit ' .. link_dir) + local expected = cwd_dir .. '/' .. link_dir .. '/' + eq(expected, api.nvim_buf_get_name(0)) + command('mksession ' .. session_file) + command('%bwipeout!') + command('source ' .. session_file) + eq(expected, api.nvim_buf_get_name(0)) + end) + it('restores CWD for :terminal buffers #11288', function() local cwd_dir = fn.fnamemodify('.', ':p:~'):gsub([[[\/]*$]], '') cwd_dir = t.fix_slashes(cwd_dir) -- :mksession always uses unix slashes. diff --git a/test/functional/lua/filetype_spec.lua b/test/functional/lua/filetype_spec.lua index c174a8c065..2593a08d71 100644 --- a/test/functional/lua/filetype_spec.lua +++ b/test/functional/lua/filetype_spec.lua @@ -73,6 +73,15 @@ describe('vim.filetype', function() ) end) + it('detects directory filenames', function() + eq( + 'directory', + exec_lua(function() + return vim.filetype.match({ filename = '/tmp/example/' }) + end) + ) + end) + it('works without defined g:ft_ignore_pat', function() local match_opts = { filename = 'unknown-ft', buf = api.nvim_create_buf(false, true) } eq( diff --git a/test/functional/plugin/dir_spec.lua b/test/functional/plugin/dir_spec.lua index 68ee235c0a..53839b2cd1 100644 --- a/test/functional/plugin/dir_spec.lua +++ b/test/functional/plugin/dir_spec.lua @@ -37,8 +37,9 @@ local function bufopt(name) end local function assert_directory(path) - eq(path, api.nvim_buf_get_name(0)) - eq(path, fn.bufname('%')) + local ffname = path:sub(-1) == '/' and path or path .. '/' + eq(ffname, api.nvim_buf_get_name(0)) + eq(path, vim.fs.normalize(fn.bufname('%'))) eq('directory', bufopt('filetype')) eq(true, bufopt('buflisted')) end