feat(vim.fs): dir() ergonomics

This commit is contained in:
Justin M. Keyes
2026-07-01 12:05:15 +02:00
parent f141916e3d
commit 971a0a0fe0
4 changed files with 113 additions and 118 deletions

View File

@@ -2597,15 +2597,9 @@ vim.fs.dir({path}, {opts}) *vim.fs.dir()*
|vim.fs.normalize()|).
Example: >lua
local it, err = vim.fs.dir(path)
if err then
-- Failed to scan the root directory
end
for name, type, err in it do
for name, type, err in vim.fs.dir(path, { err = true }) do
if err then
-- Failed to scan a child directory
-- Failed to scan directory {name} (may be the root {path} itself).
end
end
<
@@ -2618,6 +2612,9 @@ vim.fs.dir({path}, {opts}) *vim.fs.dir()*
|vim.fs.normalize()|.
• {opts} (`table?`) Optional keyword arguments:
• {depth}? (`integer`, default: `1`) How deep to traverse.
• {err}? (`boolean`, default: `false`) Report errors via the
iterator's third value ("err"), instead of silently
skipping.
• {follow}? (`boolean`, default: `false`) Follow symbolic
links.
• {skip}? (`fun(dir_name: string): boolean`) Predicate to
@@ -2625,14 +2622,15 @@ vim.fs.dir({path}, {opts}) *vim.fs.dir()*
current directory. Only useful when depth > 1 Return an
iterator over the items located in {path}
Return (multiple): ~
(`Iterator`) over items in {path}. Each iteration yields two values:
"name" and "type", along with an optional value "err" "name" is the
basename of the item relative to {path}. "type" is one of the
following: "file", "directory", "link", "fifo", "socket", "char",
"block", "unknown". "err" is a string with the format {errname}:
{message}
(`string?`) err Error encountered while scanning the base directory
Return: ~
(`fun(): string?, string?, string?`) Iterator over items in {path},
yielding (name, type, err):
• name: Basename of the item relative to {path}.
• type: One of: "file", "directory", "link", "fifo", "socket", "char",
"block", "unknown".
• err: Error string, or nil. Only if `opts.err=true`. If the root
{path} itself could not be scanned, yields a single (name, nil, err)
item.
vim.fs.dirname({file}) *vim.fs.dirname()*
Gets the parent directory of the given path (not expanded/resolved, the
@@ -2726,9 +2724,9 @@ vim.fs.find({names}, {opts}) *vim.fs.find()*
directories (recursively).
Return (multiple): ~
(`string[]`) matches Normalized paths |vim.fs.normalize()| of all
matching items.
(`string[]`) errors Errors for directories that could not be searched.
(`string[]`) Normalized paths |vim.fs.normalize()| of all matching
items.
(`string[]`) Errors collected while searching.
vim.fs.joinpath({...}) *vim.fs.joinpath()*
Concatenates partial paths (one absolute or relative path followed by zero

View File

@@ -253,9 +253,9 @@ LUA
• |vim.net.request()| can now accept `method` param overload for multiple HTTP methods.
• |writefile()| treats Lua strings as "blob", so it can be used to write
binary data.
• |vim.fs.dir()| and |vim.fs.find()| report errors encountered while
scanning directories that cannot be read (e.g. due to permissions),
instead of treating them as empty.
• |vim.fs.dir()| with `opts.err=true`, reports errors. An inaccessible root
dir yields a single (name, nil, err) item.
• |vim.fs.find()| returns a list of errors as its second return value.
• |vim.filetype.inspect()| returns a copy of the internal tables used for
filetype detection.
• Added `__eq` metamethod to |vim.VersionRange|. 2 distinct but representing

View File

@@ -177,6 +177,10 @@ end
--- (default: `1`)
--- @field depth? integer
---
--- Report errors via the iterator's third value ("err"), instead of silently skipping.
--- (default: `false`)
--- @field err? boolean
---
--- Predicate to control traversal.
--- Return false to stop searching the current directory.
--- Only useful when depth > 1
@@ -187,21 +191,14 @@ end
--- (default: `false`)
--- @field follow? boolean
--- Gets an iterator over items found in `path` (normalized via |vim.fs.normalize()|).
---
--- Example:
---
--- ```lua
--- local it, err = vim.fs.dir(path)
---
--- if err then
--- -- Failed to scan the root directory
--- end
---
--- for name, type, err in it do
--- for name, type, err in vim.fs.dir(path, { err = true }) do
--- if err then
--- -- Failed to scan a child directory
--- -- Failed to scan directory {name} (may be the root {path} itself).
--- end
--- end
--- ```
@@ -209,29 +206,36 @@ end
---@since 10
---@param path (string) Directory to iterate over, normalized via |vim.fs.normalize()|.
---@param opts? vim.fs.dir.Opts Optional keyword arguments:
---@return fun(): string?, string?, string? # Iterator over items in {path}. Each iteration yields three values: name, type, err.
---@return fun(): string?, string?, string? # Iterator over items in {path}, yielding (name, type, err):
--- - name: Basename of the item relative to {path}.
--- - type: One of: "file", "directory", "link", "fifo", "socket", "char", "block", "unknown".
--- - err: Error string, or nil.
---@return string? err Error encountered while scanning the base directory
--- - err: Error string, or nil. Only if `opts.err=true`. If the root {path} itself could not
--- be scanned, yields a single (name, nil, err) item.
function M.dir(path, opts)
opts = opts or {}
vim.validate('path', path, 'string')
vim.validate('depth', opts.depth, 'number', true)
vim.validate('skip', opts.skip, 'function', true)
vim.validate('err', opts.err, 'boolean', true)
vim.validate('follow', opts.follow, 'boolean', true)
vim.validate('skip', opts.skip, 'function', true)
path = M.normalize(path)
local rootfs, rooterr = uv.fs_scandir(path)
if not rootfs then
local empty = function()
return nil
-- Root scan failed:
-- - If opts.err=false, behave as an empty listing (back-compat).
-- - If opts.err=true, surface yield a single (name, nil, err) result.
local done = not opts.err
return function()
if done then
return nil
end
done = true
return path, nil, rooterr
end
return empty, rooterr
end
if not opts.depth or opts.depth == 1 then
@@ -241,7 +245,7 @@ function M.dir(path, opts)
end
--- @async
local it = coroutine.wrap(function()
return coroutine.wrap(function()
local dirs = { { path, 1, rootfs } }
while #dirs > 0 do
--- @type string, integer, any
@@ -264,7 +268,7 @@ function M.dir(path, opts)
then
local fs_next, err = uv.fs_scandir(M.joinpath(path, f))
if not fs_next then
err_scan = err
err_scan = opts.err and err or nil
else
dirs[#dirs + 1] = { f, level + 1, fs_next }
end
@@ -273,8 +277,6 @@ function M.dir(path, opts)
end
end
end)
return it, nil
end
--- @class vim.fs.find.Opts
@@ -338,8 +340,8 @@ end
--- The function should return `true` if the given item is considered a match.
---
---@param opts? vim.fs.find.Opts Optional keyword arguments:
---@return string[] matches # Normalized paths |vim.fs.normalize()| of all matching items.
---@return string[] errors # Errors collected while searching.
---@return string[] # Normalized paths |vim.fs.normalize()| of all matching items.
---@return string[] # Errors collected while searching.
function M.find(names, opts)
opts = opts or {}
vim.validate('names', names, { 'string', 'table', 'function' })
@@ -374,17 +376,11 @@ function M.find(names, opts)
if type(names) == 'function' then
test = function(p)
local t = {}
local it, base_err = M.dir(p)
if base_err ~= nil then
table.insert(errors, base_err)
end
for name, type, err in it do
local f = M.joinpath(p, name)
for name, type, err in M.dir(p, { err = true }) do
if err ~= nil then
table.insert(errors, err)
end
if (not opts.type or opts.type == type) and names(name, p) then
table.insert(t, f)
elseif (not opts.type or opts.type == type) and names(name, p) then
table.insert(t, M.joinpath(p, name))
end
end
return t
@@ -392,7 +388,7 @@ function M.find(names, opts)
else
test = function(p)
local t = {} --- @type string[]
local ok, aerr = uv.fs_access(p, 'R') -- Check if the directory itself is readable
local ok, aerr = uv.fs_access(p, 'R') -- Check if the root dir is readable.
if not ok then
table.insert(errors, aerr)
return t
@@ -434,36 +430,33 @@ function M.find(names, opts)
break
end
local it, base_err = M.dir(dir)
if base_err ~= nil then
table.insert(errors, base_err)
end
for other, type_, err in it do
local f = M.joinpath(dir, other)
for other, type_, err in M.dir(dir, { err = true }) do
if err ~= nil then
table.insert(errors, err)
end
if type(names) == 'function' then
if (not opts.type or opts.type == type_) and names(other, dir) then
if add(f) then
return matches, errors
end
end
else
for _, name in ipairs(names) do
if name == other and (not opts.type or opts.type == type_) then
local f = M.joinpath(dir, other)
if type(names) == 'function' then
if (not opts.type or opts.type == type_) and names(other, dir) then
if add(f) then
return matches, errors
end
end
else
for _, name in ipairs(names) do
if name == other and (not opts.type or opts.type == type_) then
if add(f) then
return matches, errors
end
end
end
end
end
if
type_ == 'directory'
or (type_ == 'link' and opts.follow and (uv.fs_stat(f) or {}).type == 'directory')
then
dirs[#dirs + 1] = f
if
type_ == 'directory'
or (type_ == 'link' and opts.follow and (uv.fs_stat(f) or {}).type == 'directory')
then
dirs[#dirs + 1] = f
end
end
end
end

View File

@@ -297,16 +297,29 @@ describe('vim.fs', function()
rmdir('testdir')
end)
-- Unreadable root directory: error is the second return value.
-- With opts.err=false: errors are silent, unreadable root looks empty.
eq(
'ENOENT: no such file or directory: does-not-exist',
0,
exec_lua(function()
local _, err = vim.fs.dir('does-not-exist')
return err
local n0 = 0
for _ in vim.fs.dir('does-not-exist') do
n0 = n0 + 1
end
return n0
end)
)
-- Unreadable child directory: error is the iterator's third value.
-- With opts.err=true: unreadable root dir yields a single (name, nil, err).
eq(
{ name = 'does-not-exist', err = 'ENOENT: no such file or directory: does-not-exist' },
exec_lua(function()
for name, type, err in vim.fs.dir('does-not-exist', { err = true }) do
return { name = name, type = type, err = err }
end
end)
)
-- With opts.err=true: unreadable child dir.
local result = exec_lua(function()
-- Stub fs_scandir since chmod doesn't work reliably on Windows.
local orig_scandir = vim.uv.fs_scandir
@@ -317,20 +330,13 @@ describe('vim.fs', function()
return orig_scandir(path, ...)
end
local ok, errors_or_err = pcall(function()
local errors = {} ---@type table<string, string>
for f, _, err in vim.fs.dir('testdir', { depth = 2 }) do
errors[f] = err
end
return errors
end)
local errors = {} ---@type table<string, string>
for f, _, err in vim.fs.dir('testdir', { depth = 2, err = true }) do
errors[f] = err
end
vim.uv.fs_scandir = orig_scandir
if not ok then
error(errors_or_err)
end
return errors_or_err
return errors
end)
eq('EACCES: permission denied: testdir/noaccess', result['noaccess'])
-- nil: with depth=2 we don't scan testdir/a/noaccess.
@@ -473,7 +479,6 @@ describe('vim.fs', function()
end
return orig_scandir(path, ...)
end
-- find()'s upward search probes readability via fs_access (not fs_scandir), so stub it too.
vim.uv.fs_access = function(path, ...)
if is_blocked(path) then
return nil, 'EACCES: permission denied: ' .. path
@@ -481,29 +486,25 @@ describe('vim.fs', function()
return orig_access(path, ...)
end
local ok, out = pcall(function()
local r = {}
r.nonexistent = { vim.fs.find('foo', { path = 'does-not-exist' }) }
r.unreadable_root = { vim.fs.find('foo', { path = 'testdir/noaccess' }) }
r.downward =
{ vim.fs.find('match.lua', { path = 'testdir', limit = math.huge, type = 'file' }) }
r.upward = select(
2,
vim.fs.find('match.lua', {
path = 'testdir/noaccess/x',
upward = true,
stop = 'testdir',
})
)
return r
end)
local r = {}
r.nonexistent = { vim.fs.find('foo', { path = 'does-not-exist' }) }
r.unreadable_root = { vim.fs.find('foo', { path = 'testdir/noaccess' }) }
local dmatches, derrors =
vim.fs.find('match.lua', { path = 'testdir', limit = math.huge, type = 'file' })
table.sort(derrors) -- readdir order is not deterministic
r.downward = { dmatches, derrors }
r.upward = select(
2,
vim.fs.find('match.lua', {
path = 'testdir/noaccess/x',
upward = true,
stop = 'testdir',
})
)
vim.uv.fs_scandir = orig_scandir
vim.uv.fs_access = orig_access
if not ok then
error(out)
end
return out
return r
end)
-- Nonexistent / unreadable root path: no matches, one error.
@@ -511,10 +512,13 @@ describe('vim.fs', function()
eq({ {}, { 'EACCES: permission denied: testdir/noaccess' } }, res.unreadable_root)
-- Downward search collects child errors, yet still returns the match found elsewhere.
eq(1, #res.downward[1])
eq(2, #res.downward[2])
eq(true, vim.tbl_contains(res.downward[2], 'EACCES: permission denied: testdir/noaccess'))
eq(true, vim.tbl_contains(res.downward[2], 'EACCES: permission denied: testdir/a/noaccess'))
eq({
{ 'testdir/a/match.lua' },
{
'EACCES: permission denied: testdir/a/noaccess',
'EACCES: permission denied: testdir/noaccess',
},
}, res.downward)
-- Upward search reports an error for each unreadable ancestor, in traversal order.
eq({