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.
This commit is contained in:
Barrett Ruth
2026-07-10 11:40:13 -05:00
committed by GitHub
parent 20a4b1bc5e
commit 3b88a8a65d
14 changed files with 122 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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