mirror of
https://github.com/neovim/neovim.git
synced 2026-07-09 10:59:38 +00:00
Merge #40114 feat(vim.fs): dir(), find() error-reporting
This commit is contained in:
@@ -2596,6 +2596,14 @@ vim.fs.dir({path}, {opts}) *vim.fs.dir()*
|
||||
Gets an iterator over items found in `path` (normalized via
|
||||
|vim.fs.normalize()|).
|
||||
|
||||
Example: >lua
|
||||
for name, type, err in vim.fs.dir(path, { err = true }) do
|
||||
if err then
|
||||
-- Failed to scan directory {name} (may be the root {path} itself).
|
||||
end
|
||||
end
|
||||
<
|
||||
|
||||
Attributes: ~
|
||||
Since: 0.8.0
|
||||
|
||||
@@ -2604,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
|
||||
@@ -2612,10 +2623,14 @@ vim.fs.dir({path}, {opts}) *vim.fs.dir()*
|
||||
iterator over the items located in {path}
|
||||
|
||||
Return: ~
|
||||
(`Iterator`) over items in {path}. Each iteration yields two values:
|
||||
"name" and "type". "name" is the basename of the item relative to
|
||||
{path}. "type" is one of the following: "file", "directory", "link",
|
||||
"fifo", "socket", "char", "block", "unknown".
|
||||
(`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
|
||||
@@ -2708,9 +2723,10 @@ vim.fs.find({names}, {opts}) *vim.fs.find()*
|
||||
through parent directories. Otherwise, search child
|
||||
directories (recursively).
|
||||
|
||||
Return: ~
|
||||
Return (multiple): ~
|
||||
(`string[]`) Normalized paths |vim.fs.normalize()| of all matching
|
||||
items
|
||||
items.
|
||||
(`string[]`) Errors collected while searching.
|
||||
|
||||
vim.fs.joinpath({...}) *vim.fs.joinpath()*
|
||||
Concatenates partial paths (one absolute or relative path followed by zero
|
||||
|
||||
@@ -253,6 +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()| 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
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
local api = vim.api
|
||||
local fs = vim.fs
|
||||
local uv = vim.uv
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -50,17 +49,13 @@ end
|
||||
---@param dir string
|
||||
---@return boolean
|
||||
local function render(buf, dir)
|
||||
-- TODO(#39878): drop this scandir probe once vim.fs.dir() can report
|
||||
-- traversal errors.
|
||||
local handle, err = uv.fs_scandir(dir)
|
||||
if not handle then
|
||||
vim.notify('dir: ' .. (err or ('cannot read directory: ' .. dir)), vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
---@type { name: string, dir: boolean }[]
|
||||
local items = {}
|
||||
for name, type in fs.dir(dir) do
|
||||
for name, type, err in fs.dir(dir, { err = true }) do
|
||||
if err then
|
||||
vim.notify('dir: ' .. err, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
if type == 'link' and vim.fn.isdirectory(fs.joinpath(dir, name)) == 1 then
|
||||
type = 'directory'
|
||||
end
|
||||
|
||||
@@ -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,51 +191,73 @@ end
|
||||
--- (default: `false`)
|
||||
--- @field follow? boolean
|
||||
|
||||
---@alias Iterator fun(): string?, string?
|
||||
|
||||
--- Gets an iterator over items found in `path` (normalized via |vim.fs.normalize()|).
|
||||
---
|
||||
--- Example:
|
||||
---
|
||||
--- ```lua
|
||||
--- for name, type, err in vim.fs.dir(path, { err = true }) do
|
||||
--- if err then
|
||||
--- -- Failed to scan directory {name} (may be the root {path} itself).
|
||||
--- end
|
||||
--- 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 Iterator over items in {path}. Each iteration yields two values: "name" and "type".
|
||||
--- "name" is the basename of the item relative to {path}.
|
||||
--- "type" is one of the following:
|
||||
--- "file", "directory", "link", "fifo", "socket", "char", "block", "unknown".
|
||||
---@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.
|
||||
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)
|
||||
if not opts.depth or opts.depth == 1 then
|
||||
local fs = uv.fs_scandir(path)
|
||||
|
||||
local rootfs, rooterr = uv.fs_scandir(path)
|
||||
|
||||
if not rootfs then
|
||||
-- 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 not fs then
|
||||
return
|
||||
if done then
|
||||
return nil
|
||||
end
|
||||
return fs_scandir_next(fs, path)
|
||||
done = true
|
||||
return path, nil, rooterr
|
||||
end
|
||||
end
|
||||
|
||||
if not opts.depth or opts.depth == 1 then
|
||||
return function()
|
||||
return fs_scandir_next(rootfs, path)
|
||||
end
|
||||
end
|
||||
|
||||
--- @async
|
||||
return coroutine.wrap(function()
|
||||
local dirs = { { path, 1 } }
|
||||
local dirs = { { path, 1, rootfs } }
|
||||
while #dirs > 0 do
|
||||
--- @type string, integer
|
||||
local dir0, level = unpack(table.remove(dirs, 1))
|
||||
--- @type string, integer, any
|
||||
local dir0, level, fs = unpack(table.remove(dirs, 1))
|
||||
local dir = level == 1 and dir0 or M.joinpath(path, dir0)
|
||||
local fs = uv.fs_scandir(dir)
|
||||
while fs do
|
||||
local name, t = fs_scandir_next(fs, dir)
|
||||
if not name then
|
||||
break
|
||||
end
|
||||
local f = level == 1 and name or M.joinpath(dir0, name)
|
||||
coroutine.yield(f, t)
|
||||
local err_scan = nil
|
||||
if
|
||||
opts.depth
|
||||
and level < opts.depth
|
||||
@@ -240,8 +266,14 @@ function M.dir(path, opts)
|
||||
).type == 'directory'))
|
||||
and (not opts.skip or opts.skip(f) ~= false)
|
||||
then
|
||||
dirs[#dirs + 1] = { f, level + 1 }
|
||||
local fs_next, err = uv.fs_scandir(M.joinpath(path, f))
|
||||
if not fs_next then
|
||||
err_scan = opts.err and err or nil
|
||||
else
|
||||
dirs[#dirs + 1] = { f, level + 1, fs_next }
|
||||
end
|
||||
end
|
||||
coroutine.yield(f, t, err_scan)
|
||||
end
|
||||
end
|
||||
end)
|
||||
@@ -308,7 +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[]) # Normalized paths |vim.fs.normalize()| of all matching items
|
||||
---@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' })
|
||||
@@ -328,6 +361,7 @@ function M.find(names, opts)
|
||||
local limit = opts.limit or 1
|
||||
|
||||
local matches = {} --- @type string[]
|
||||
local errors = {} --- @type string[]
|
||||
|
||||
local function add(match)
|
||||
matches[#matches + 1] = M.normalize(match)
|
||||
@@ -342,8 +376,10 @@ function M.find(names, opts)
|
||||
if type(names) == 'function' then
|
||||
test = function(p)
|
||||
local t = {}
|
||||
for name, type in M.dir(p) do
|
||||
if (not opts.type or opts.type == type) and names(name, p) then
|
||||
for name, type, err in M.dir(p, { err = true }) do
|
||||
if err ~= nil then
|
||||
table.insert(errors, err)
|
||||
elseif (not opts.type or opts.type == type) and names(name, p) then
|
||||
table.insert(t, M.joinpath(p, name))
|
||||
end
|
||||
end
|
||||
@@ -352,6 +388,11 @@ function M.find(names, opts)
|
||||
else
|
||||
test = function(p)
|
||||
local t = {} --- @type string[]
|
||||
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
|
||||
end
|
||||
for _, name in ipairs(names) do
|
||||
local f = M.joinpath(p, name)
|
||||
local stat = uv.fs_stat(f)
|
||||
@@ -366,7 +407,7 @@ function M.find(names, opts)
|
||||
|
||||
for _, match in ipairs(test(path)) do
|
||||
if add(match) then
|
||||
return matches
|
||||
return matches, errors
|
||||
end
|
||||
end
|
||||
|
||||
@@ -377,7 +418,7 @@ function M.find(names, opts)
|
||||
|
||||
for _, match in ipairs(test(parent)) do
|
||||
if add(match) then
|
||||
return matches
|
||||
return matches, errors
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -389,35 +430,39 @@ function M.find(names, opts)
|
||||
break
|
||||
end
|
||||
|
||||
for other, type_ in M.dir(dir) do
|
||||
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
|
||||
end
|
||||
end
|
||||
for other, type_, err in M.dir(dir, { err = true }) do
|
||||
if err ~= nil then
|
||||
table.insert(errors, err)
|
||||
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
|
||||
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
|
||||
end
|
||||
|
||||
return matches
|
||||
return matches, errors
|
||||
end
|
||||
|
||||
--- Find the first parent directory containing a specific "marker", relative to a file path or
|
||||
|
||||
@@ -287,6 +287,61 @@ describe('vim.fs', function()
|
||||
eq('file', result['test.txt'])
|
||||
end)
|
||||
end)
|
||||
|
||||
it('reports errors', function()
|
||||
mkdir('testdir')
|
||||
mkdir('testdir/noaccess')
|
||||
mkdir('testdir/a')
|
||||
mkdir('testdir/a/noaccess')
|
||||
finally(function()
|
||||
rmdir('testdir')
|
||||
end)
|
||||
|
||||
-- With opts.err=false: errors are silent, unreadable root looks empty.
|
||||
eq(
|
||||
0,
|
||||
exec_lua(function()
|
||||
local n0 = 0
|
||||
for _ in vim.fs.dir('does-not-exist') do
|
||||
n0 = n0 + 1
|
||||
end
|
||||
return n0
|
||||
end)
|
||||
)
|
||||
|
||||
-- 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
|
||||
vim.uv.fs_scandir = function(path, ...)
|
||||
if path == 'testdir/noaccess' then
|
||||
return nil, 'EACCES: permission denied: testdir/noaccess'
|
||||
end
|
||||
return orig_scandir(path, ...)
|
||||
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
|
||||
return errors
|
||||
end)
|
||||
eq('EACCES: permission denied: testdir/noaccess', result['noaccess'])
|
||||
-- nil: with depth=2 we don't scan testdir/a/noaccess.
|
||||
eq(nil, result['a/noaccess'])
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('find()', function()
|
||||
@@ -394,6 +449,83 @@ describe('vim.fs', function()
|
||||
)
|
||||
)
|
||||
end)
|
||||
|
||||
it('reports errors', function()
|
||||
mkdir('testdir')
|
||||
mkdir('testdir/noaccess')
|
||||
mkdir('testdir/a')
|
||||
mkdir('testdir/a/noaccess')
|
||||
t.write_file('testdir/a/match.lua', '')
|
||||
finally(function()
|
||||
rmdir('testdir')
|
||||
end)
|
||||
|
||||
-- Scenarios run in a shared setup(clear) so the fs_scandir/fs_access stubs (chmod is unreliable on
|
||||
-- Windows) are installed and restored.
|
||||
local res = exec_lua(function()
|
||||
local orig_scandir = vim.uv.fs_scandir
|
||||
local orig_access = vim.uv.fs_access
|
||||
local function is_blocked(path)
|
||||
for _, prefix in ipairs({ 'testdir/noaccess', 'testdir/a/noaccess' }) do
|
||||
if path == prefix or vim.startswith(path, prefix .. '/') then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
vim.uv.fs_scandir = function(path, ...)
|
||||
if is_blocked(path) then
|
||||
return nil, 'EACCES: permission denied: ' .. path
|
||||
end
|
||||
return orig_scandir(path, ...)
|
||||
end
|
||||
vim.uv.fs_access = function(path, ...)
|
||||
if is_blocked(path) then
|
||||
return nil, 'EACCES: permission denied: ' .. path
|
||||
end
|
||||
return orig_access(path, ...)
|
||||
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
|
||||
return r
|
||||
end)
|
||||
|
||||
-- Nonexistent / unreadable root path: no matches, one error.
|
||||
eq({ {}, { 'ENOENT: no such file or directory: does-not-exist' } }, res.nonexistent)
|
||||
eq({ {}, { 'EACCES: permission denied: testdir/noaccess' } }, res.unreadable_root)
|
||||
|
||||
-- Downward search collects child errors, yet still returns the match found elsewhere.
|
||||
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({
|
||||
'EACCES: permission denied: testdir/noaccess/x',
|
||||
'EACCES: permission denied: testdir/noaccess',
|
||||
}, res.upward)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('root()', function()
|
||||
|
||||
Reference in New Issue
Block a user