Files
neovim/test/functional/plugin/dir_spec.lua
Barrett Ruth 73bb6a8a5e fix(dir): let 3P dir-browser plugin rename the dir buffer #40529
Problem:
`FileType directory` fires *before* the `BufEnter` path where `dir.lua`
populates the buffer, in order to allow a 3P dir-browser plugin to handle the
event and (optionally) rename the buffer to e.g. `example:///tmp/foo/`.

But currently, `open_buffer()` continues after `filetypedetect BufRead` as if
the original directory buffer is *still* current and valid, which may fall
through to the built-in `nvim.dir` path after a 3P plugin ALREADY handled the
directory.

Solution:
Keep a buf ref around the early `filetypedetect BufRead` call for directory
buffers.

If the `FileType directory` handler switches away from/deletes the original
directory buffer, stop the open path instead of continuing into the built-in
`nvim.dir` flow.
2026-07-13 15:10:19 -04:00

477 lines
12 KiB
Lua

local n = require('test.functional.testnvim')()
local t = require('test.testutil')
local api = n.api
local command = n.command
local eq = t.eq
local exec_capture = n.exec_capture
local exec_lua = n.exec_lua
local feed = n.feed
local fn = n.fn
local ok = t.ok
local poke_eventloop = n.poke_eventloop
local function lines()
return api.nvim_buf_get_lines(0, 0, -1, true)
end
local function edit(path)
api.nvim_cmd({ cmd = 'edit', args = { path }, magic = { file = false, bar = false } }, {})
end
local function cd(path)
api.nvim_cmd({ cmd = 'cd', args = { path }, magic = { file = false, bar = false } }, {})
end
local function line_of(text)
for i, line in ipairs(lines()) do
if line == text then
return i
end
end
error(('missing line %q in %s'):format(text, vim.inspect(lines())))
end
local function bufopt(name)
return api.nvim_get_option_value(name, { buf = 0 })
end
local function has_syntax_group(name)
return exec_capture('syntax list ' .. name):find(name, 1, true) ~= nil
end
local function assert_directory(path)
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
local function filesystem_root(path)
local root = vim.fs.normalize(vim.fs.abspath(path))
for parent in vim.fs.parents(root) do
root = parent
end
return root
end
---@param args? string[]
---@return string[]
local function with_buftype_optionset(args)
return vim.list_extend({
'--cmd',
'let g:nvim_dir_events = []',
'--cmd',
[[autocmd OptionSet buftype call add(g:nvim_dir_events, v:option_new)]],
}, args or {})
end
local function expect_buftype_optionset(path)
assert_directory(path)
eq({ 'nowrite' }, exec_lua('return vim.g.nvim_dir_events'))
end
describe('nvim.dir', function()
local root
local subdir
local file
local function make_fixture()
root = vim.fs.normalize(t.tmpname(false) .. ' space%#')
subdir = root .. '/subdir'
file = root .. '/alpha.txt'
t.mkdir(root)
t.mkdir(subdir)
t.write_file(file, 'alpha', true)
t.write_file(root .. '/.hidden', 'hidden', true)
end
after_each(function()
if root then
n.rmdir(root)
end
root = nil
end)
it('opens a startup directory argument', function()
make_fixture()
n.clear({ args_rm = { '-u' }, args = { root } })
assert_directory(root)
eq(false, vim.tbl_contains(lines(), '../'))
eq('subdir/', lines()[1])
line_of('.hidden')
line_of('alpha.txt')
end)
it('3P dir-browser can handle `FileType directory` event and rename buf', function()
make_fixture()
n.clear({ args_rm = { '-u' } })
exec_lua(function()
_G.nvim_dir_loaded_in_filetype = nil
vim.api.nvim_create_autocmd('FileType', {
pattern = 'directory',
callback = function(ev)
_G.nvim_dir_loaded_in_filetype = package.loaded['nvim.dir'] ~= nil
local dir = vim.api.nvim_buf_get_name(ev.buf)
local browser = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_name(browser, 'example://' .. dir)
vim.api.nvim_buf_set_lines(browser, 0, -1, false, {
'plugin browser for: ' .. dir,
})
vim.api.nvim_set_current_buf(browser)
vim.api.nvim_buf_delete(ev.buf, { force = true })
end,
})
end)
edit(root)
eq(false, exec_lua('return _G.nvim_dir_loaded_in_filetype'))
eq('example://' .. root .. '/', api.nvim_buf_get_name(0))
eq({ 'plugin browser for: ' .. root .. '/' }, lines())
eq(false, exec_lua([[return package.loaded['nvim.dir'] ~= nil]]))
eq('', exec_capture('messages'))
end)
it('triggers nested autocmds when opening directory buffers', function()
make_fixture()
n.clear({
args_rm = { '-u' },
args = with_buftype_optionset({ root }),
})
expect_buftype_optionset(root)
n.clear({
args_rm = { '-u' },
args = with_buftype_optionset(),
})
edit(root)
expect_buftype_optionset(root)
end)
it('handles nested autocmds deleting the directory buffer', function()
make_fixture()
n.clear({
args_rm = { '-u' },
args = {
'--cmd',
'let g:nvim_dir_wiped = 0',
'--cmd',
[[autocmd OptionSet buftype let g:nvim_dir_wiped = 1 | bwipeout!]],
root,
},
})
eq(1, exec_lua('return vim.g.nvim_dir_wiped'))
eq('', exec_capture('messages'))
end)
it('does not load the module until opening a directory', function()
make_fixture()
n.clear({ args_rm = { '-u' } })
eq(false, exec_lua([[return package.loaded['nvim.dir'] ~= nil]]))
edit(root)
eq(true, exec_lua([[return package.loaded['nvim.dir'] ~= nil]]))
assert_directory(root)
end)
it('maps - to open parent directories', function()
make_fixture()
n.clear({ args_rm = { '-u', '--cmd' } })
edit(file)
feed('-')
poke_eventloop()
assert_directory(root)
line_of('alpha.txt')
-- Ensure the cursor stays on the entry we navigated up from.
eq('alpha.txt', api.nvim_get_current_line())
end)
it('startup plugins can replace the `-` mapping', function()
local plugin_file = vim.fs.joinpath(vim.fn.stdpath('config'), 'plugin/dirvish.lua')
vim.fs.mkdir(vim.fs.dirname(plugin_file), { parents = true })
finally(function()
os.remove(plugin_file) -- XXX: Remove file only, to avoid n.rmdir() hang on Windows.
end)
vim.fn.writefile(
[[
if vim.fn.mapcheck('-', 'n') == '' and vim.fn.hasmapto('<Plug>(dirvish_up)', 'n') == 0 then
vim.keymap.set('n', '-', '<Plug>(dirvish_up)')
end
]],
plugin_file
)
n.clear({ args_rm = { '-u', '--cmd' } })
eq(1, fn.exists('g:loaded_nvim_dir_plugin')) -- Avoid false negatives.
eq('<Plug>(dirvish_up)', fn.mapcheck('-', 'n'))
end)
it('preserves alternate buffer when opening a parent directory', function()
make_fixture()
n.clear({ args_rm = { '-u', '--cmd' } })
edit(file)
feed('-')
poke_eventloop()
assert_directory(root)
-- Keep the alternate buffer on the file we navigated up from.
eq(file, api.nvim_buf_get_name(fn.bufnr('#')))
end)
it('uses an absolute buffer name for a relative startup directory argument', function()
make_fixture()
local cwd = assert(vim.uv.cwd())
assert(vim.uv.chdir(root))
n.clear({ args_rm = { '-u' }, args = { '.' } })
assert(vim.uv.chdir(cwd))
assert_directory(root)
end)
it('normalizes edited directory names', function()
make_fixture()
n.clear({ args_rm = { '-u' } })
edit(root .. '///')
assert_directory(root)
end)
it('does not show a parent entry at the filesystem root', function()
n.clear({ args_rm = { '-u' } })
local root_dir = filesystem_root(fn.getcwd())
edit(root_dir)
assert_directory(root_dir)
eq(false, vim.tbl_contains(lines(), '../'))
end)
it('navigates entries and refreshes the listing', function()
make_fixture()
n.clear({ args_rm = { '-u' } })
edit(root)
assert_directory(root)
api.nvim_win_set_cursor(0, { line_of('alpha.txt'), 0 })
feed('<CR>')
poke_eventloop()
eq(file, api.nvim_buf_get_name(0))
eq({ 'alpha' }, lines())
edit(subdir)
assert_directory(subdir)
eq({ '' }, lines())
feed('-')
poke_eventloop()
assert_directory(root)
t.write_file(root .. '/beta.txt', 'beta', true)
feed('R')
poke_eventloop()
line_of('beta.txt')
end)
it("follows global 'hidden' when abandoned", function()
make_fixture()
n.clear({ args_rm = { '-u' } })
command('set hidden')
edit(root)
local root_buf = api.nvim_get_current_buf()
eq('', bufopt('bufhidden'))
edit(subdir)
eq(true, api.nvim_buf_is_loaded(root_buf))
eq(1, fn.getbufinfo(root_buf)[1].hidden)
n.clear({ args_rm = { '-u' } })
command('set nohidden')
edit(root)
root_buf = api.nvim_get_current_buf()
eq('', bufopt('bufhidden'))
edit(subdir)
eq(false, api.nvim_buf_is_loaded(root_buf))
end)
it('reloads directory buffers', function()
make_fixture()
n.clear({
args_rm = { '-u' },
args = { '--clean', '--cmd', [[autocmd FileType directory ++once setlocal bufhidden=delete]] },
})
edit(root)
assert_directory(root)
eq('delete', bufopt('bufhidden'))
eq(true, has_syntax_group('directoryDirectory'))
local buf = api.nvim_get_current_buf()
t.write_file(root .. '/beta.txt', 'beta', true)
feed('R')
poke_eventloop()
eq('delete', bufopt('bufhidden'))
eq(true, has_syntax_group('directoryDirectory'))
line_of('beta.txt')
t.write_file(root .. '/gamma.txt', 'gamma', true)
command('edit')
eq(buf, api.nvim_get_current_buf())
assert_directory(root)
eq(true, has_syntax_group('directoryDirectory'))
line_of('subdir/')
line_of('alpha.txt')
line_of('gamma.txt')
end)
it('reports an error and keeps the buffer when reloading a removed directory', function()
make_fixture()
n.clear({ args_rm = { '-u' } })
edit(subdir)
assert_directory(subdir)
n.rmdir(subdir)
feed('R')
poke_eventloop()
ok(exec_capture('messages'):find('ENOENT', 1, true) ~= nil)
assert_directory(subdir)
end)
it('refreshes a directory when navigated into again', function()
make_fixture()
n.clear({ args_rm = { '-u' } })
edit(root)
api.nvim_win_set_cursor(0, { line_of('subdir/'), 0 })
feed('<CR>')
poke_eventloop()
assert_directory(subdir)
eq({ '' }, lines())
t.write_file(subdir .. '/new.txt', 'new', true)
feed('-')
poke_eventloop()
assert_directory(root)
api.nvim_win_set_cursor(0, { line_of('subdir/'), 0 })
feed('<CR>')
poke_eventloop()
assert_directory(subdir)
line_of('new.txt')
end)
it('displays filenames as buffer text and opens them from the buffer', function()
make_fixture()
n.clear({ args_rm = { '-u' } })
edit(root)
line_of('.hidden')
line_of('subdir/')
api.nvim_win_set_cursor(0, { line_of('alpha.txt'), 0 })
feed('<CR>')
poke_eventloop()
eq(file, api.nvim_buf_get_name(0))
eq({ 'alpha' }, lines())
end)
it('encodes special filename characters in directory buffers', function()
-- Windows reserves backslash as a separator and disallows control characters in filenames.
-- https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
t.skip(t.is_os('win'), 'N/A: Windows filenames cannot contain these characters')
make_fixture()
n.clear({ args_rm = { '-u' } })
local name = 'line\nbreak.txt'
local raw_names = {
'back\\slash.txt',
'tab\tname.txt',
'carriage\rreturn.txt',
'ctrl\1name.txt',
'del\127name.txt',
}
for _, raw_name in ipairs(raw_names) do
t.write_file(root .. '/' .. raw_name, 'raw', true)
end
t.write_file(root .. '/' .. name, 'newline', true)
edit(root)
for _, raw_name in ipairs(raw_names) do
line_of(raw_name)
end
api.nvim_win_set_cursor(0, { line_of('line\0break.txt'), 0 })
feed('<CR>')
poke_eventloop()
eq(root .. '/' .. name, api.nvim_buf_get_name(0))
eq({ 'newline' }, lines())
end)
it('leaves existing special buffers alone', function()
make_fixture()
n.clear({ args_rm = { '-u' } })
api.nvim_set_option_value('buftype', 'nofile', { buf = 0 })
api.nvim_buf_set_name(0, root)
command('doautocmd BufEnter')
eq('nofile', api.nvim_get_option_value('buftype', { buf = 0 }))
eq('', api.nvim_get_option_value('filetype', { buf = 0 }))
end)
it('coexists with netrw and can be disabled', function()
make_fixture()
n.clear({ args_rm = { '-u' } })
local cwd = fn.getcwd()
ok(fn.exists(':Explore') > 0)
edit(root)
eq('directory', api.nvim_get_option_value('filetype', { buf = 0 }))
cd(root)
command('Explore .')
cd(cwd)
eq('netrw', api.nvim_get_option_value('filetype', { buf = 0 }))
n.clear({
args_rm = { '-u' },
args = { '--cmd', 'let g:loaded_nvim_dir_plugin = 1' },
})
edit(root)
eq('netrw', api.nvim_get_option_value('filetype', { buf = 0 }))
end)
it('supports the FileExplorer browse contract', function()
if t.is_zig_build() then
return pending('broken with build.zig: TMPDIR relative cwd')
end
make_fixture()
n.clear({ args_rm = { '-u' } })
local cwd = fn.getcwd()
cd(root)
command('browse edit .')
cd(cwd)
assert_directory(root)
line_of('alpha.txt')
end)
end)