From f141916e3d1ea61ddccb43b32a3d37b16cb640a0 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Wed, 1 Jul 2026 10:47:31 +0200 Subject: [PATCH] fix(vim.fs): dir(), find() error-reporting --- runtime/doc/lua.txt | 6 +- runtime/lua/vim/fs.lua | 40 ++---- test/functional/lua/fs_spec.lua | 239 ++++++++++++++------------------ 3 files changed, 120 insertions(+), 165 deletions(-) diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index 130031ff4c..f9f77c4063 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -2727,10 +2727,8 @@ vim.fs.find({names}, {opts}) *vim.fs.find()* Return (multiple): ~ (`string[]`) matches Normalized paths |vim.fs.normalize()| of all - matching items - (`table[]`) errors list of errors encountered during the search - • {err} (`string`) Error message - • {path} (`string`) Path at which error was encountered + matching items. + (`string[]`) errors Errors for directories that could not be searched. vim.fs.joinpath({...}) *vim.fs.joinpath()* Concatenates partial paths (one absolute or relative path followed by zero diff --git a/runtime/lua/vim/fs.lua b/runtime/lua/vim/fs.lua index 4a524aa467..5dd87816b0 100644 --- a/runtime/lua/vim/fs.lua +++ b/runtime/lua/vim/fs.lua @@ -187,7 +187,6 @@ end --- (default: `false`) --- @field follow? boolean ----@alias Iterator fun(): string?, string?, string? --- Gets an iterator over items found in `path` (normalized via |vim.fs.normalize()|). --- @@ -210,12 +209,10 @@ 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", 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} +---@return fun(): string?, string?, string? # Iterator over items in {path}. Each iteration yields three values: 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 function M.dir(path, opts) opts = opts or {} @@ -304,15 +301,6 @@ end --- (default: `false`) --- @field follow? boolean ---- @class vim.fs.find.Error ---- @inlinedoc ---- ---- Path at which error was encountered ---- @field path string ---- ---- Error message ---- @field err string - --- Find files or directories (or other items as specified by `opts.type`) in the given path. --- --- Finds items given in {names} starting from {path}. If {upward} is "true" @@ -350,8 +338,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 vim.fs.find.Error[] errors # list of errors encountered during the search +---@return string[] matches # Normalized paths |vim.fs.normalize()| of all matching items. +---@return string[] errors # Errors collected while searching. function M.find(names, opts) opts = opts or {} vim.validate('names', names, { 'string', 'table', 'function' }) @@ -371,7 +359,7 @@ function M.find(names, opts) local limit = opts.limit or 1 local matches = {} --- @type string[] - local errors = {} --- @type vim.fs.find.Error[] + local errors = {} --- @type string[] local function add(match) matches[#matches + 1] = M.normalize(match) @@ -388,12 +376,12 @@ function M.find(names, opts) local t = {} local it, base_err = M.dir(p) if base_err ~= nil then - table.insert(errors, { path = p, err = base_err }) + table.insert(errors, base_err) end for name, type, err in it do local f = M.joinpath(p, name) if err ~= nil then - table.insert(errors, { path = f, err = err }) + table.insert(errors, err) end if (not opts.type or opts.type == type) and names(name, p) then table.insert(t, f) @@ -404,9 +392,9 @@ function M.find(names, opts) else test = function(p) local t = {} --- @type string[] - local _, base_err = M.dir(p) - if base_err ~= nil then -- Check if the directory itself is readable - table.insert(errors, { path = p, err = base_err }) + local ok, aerr = uv.fs_access(p, 'R') -- Check if the directory itself is readable + if not ok then + table.insert(errors, aerr) return t end for _, name in ipairs(names) do @@ -448,12 +436,12 @@ function M.find(names, opts) local it, base_err = M.dir(dir) if base_err ~= nil then - table.insert(errors, { path = dir, err = base_err }) + table.insert(errors, base_err) end for other, type_, err in it do local f = M.joinpath(dir, other) if err ~= nil then - table.insert(errors, { path = f, err = err }) + table.insert(errors, err) end if type(names) == 'function' then if (not opts.type or opts.type == type_) and names(other, dir) then diff --git a/test/functional/lua/fs_spec.lua b/test/functional/lua/fs_spec.lua index 302d1445ac..2b37f6a005 100644 --- a/test/functional/lua/fs_spec.lua +++ b/test/functional/lua/fs_spec.lua @@ -288,56 +288,53 @@ describe('vim.fs', function() end) end) - describe('error handling', function() - before_each(function() - mkdir('testdir') - mkdir('testdir/noaccess') - mkdir('testdir/a') - mkdir('testdir/a/noaccess') - end) - after_each(function() + it('reports errors', function() + mkdir('testdir') + mkdir('testdir/noaccess') + mkdir('testdir/a') + mkdir('testdir/a/noaccess') + finally(function() rmdir('testdir') end) - it('returns error for unreadable root directory', function() - local result = exec_lua(function() + -- Unreadable root directory: error is the second return value. + eq( + 'ENOENT: no such file or directory: does-not-exist', + exec_lua(function() local _, err = vim.fs.dir('does-not-exist') return err end) - eq('ENOENT: no such file or directory: does-not-exist', result) - end) + ) - it('returns iterator errors for unreadable child directories', function() - 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, ...) + -- Unreadable child directory: error is the iterator's third value. + 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 ok, errors_or_err = pcall(function() - local errors = {} ---@type table - local it = vim.fs.dir('testdir', { depth = 2 }) - for f, _, err in it do - errors[f] = err - end - return errors - end) - - vim.uv.fs_scandir = orig_scandir - - if not ok then - error(errors_or_err) + local ok, errors_or_err = pcall(function() + local errors = {} ---@type table + for f, _, err in vim.fs.dir('testdir', { depth = 2 }) do + errors[f] = err end - return errors_or_err + return errors end) - eq('EACCES: permission denied: testdir/noaccess', result['noaccess']) - -- This should be nil because with a depth of 2 we shouldn't be scanning it - eq(nil, result['a/noaccess']) + + vim.uv.fs_scandir = orig_scandir + + if not ok then + error(errors_or_err) + end + return errors_or_err 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) @@ -447,111 +444,83 @@ describe('vim.fs', function() ) end) - describe('error handling', function() - before_each(function() - mkdir('testdir') - mkdir('testdir/noaccess') - mkdir('testdir/a') - mkdir('testdir/a/noaccess') - t.write_file('testdir/a/match.lua', '') - - exec_lua(function() - -- Stub fs_scandir since chmod doesn't work reliably on Windows. - local blocked_prefixes = { 'testdir/noaccess', 'testdir/a/noaccess' } - local function is_blocked(path) - for _, prefix in ipairs(blocked_prefixes) do - if path == prefix or vim.startswith(path, prefix .. '/') then - return true - end - end - return false - end - _G.__orig_fs_scandir = vim.uv.fs_scandir - vim.uv.fs_scandir = function(path, ...) - if is_blocked(path) then - return nil, 'EACCES: permission denied: ' .. path - end - return _G.__orig_fs_scandir(path, ...) - end - end) - end) - - after_each(function() - exec_lua(function() - vim.uv.fs_scandir = _G.__orig_fs_scandir - _G.__orig_fs_scandir = nil - 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) - it('returns an error for a nonexistent root path', function() - local result = exec_lua(function() - local matches, errors = vim.fs.find('foo', { path = 'does-not-exist' }) - return { matches = matches, errors = errors } - end) - eq({}, result.matches) - eq(1, #result.errors) - eq('does-not-exist', result.errors[1].path) - eq('ENOENT: no such file or directory: does-not-exist', result.errors[1].err) - end) - - it('returns an error for an unreadable root path', function() - local result = exec_lua(function() - local matches, errors = vim.fs.find('foo', { path = 'testdir/noaccess' }) - return { matches = matches, errors = errors } - end) - eq({}, result.matches) - eq(1, #result.errors) - eq('testdir/noaccess', result.errors[1].path) - eq('EACCES: permission denied: testdir/noaccess', result.errors[1].err) - end) - - it('collects errors for unreadable child directories during downward search', function() - local result = exec_lua(function() - local matches, errors = vim.fs.find('match.lua', { - path = 'testdir', - limit = math.huge, - type = 'file', - }) - --- @type table - local err_by_path = {} - for _, e in ipairs(errors) do - err_by_path[e.path] = e.err + -- 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 { matches = matches, errors = err_by_path } + 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 + -- 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 + end + 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) - eq(1, #result.matches) - eq('EACCES: permission denied: testdir/noaccess', result.errors['testdir/noaccess']) - eq('EACCES: permission denied: testdir/a/noaccess', result.errors['testdir/a/noaccess']) + + vim.uv.fs_scandir = orig_scandir + vim.uv.fs_access = orig_access + if not ok then + error(out) + end + return out end) - it('still returns matches found despite errors elsewhere in the tree', function() - local result = exec_lua(function() - local matches, errors = vim.fs.find('match.lua', { - path = 'testdir', - limit = math.huge, - type = 'file', - }) - return { matches = matches, has_errors = #errors > 0 } - end) - eq(1, #result.matches) - eq(true, result.has_errors) - 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) - it('reports errors for unreadable directories during upward search', function() - local result = exec_lua(function() - local matches, errors = vim.fs.find('match.lua', { - path = 'testdir/noaccess/x', - upward = true, - stop = 'testdir', - }) - return { matches = matches, errors = errors } - end) - eq('EACCES: permission denied: testdir/noaccess/x', result.errors[1].err) - eq('EACCES: permission denied: testdir/noaccess', result.errors[2].err) - eq('testdir/noaccess/x', result.errors[1].path) - eq('testdir/noaccess', result.errors[2].path) - end) + -- 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')) + + -- 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)