From 4e04dff2287eed576cdb1063d5b7c00bf91b9fad Mon Sep 17 00:00:00 2001 From: Rudrajeet Pal Date: Thu, 4 Jun 2026 16:33:03 +0530 Subject: [PATCH 1/4] feat(vim.fs): dir(), find() error-reporting Problem: vim.fs.dir() and vim.fs.find() drop errors returned by uv.fs_scandir(). Solution: - vim.fs.dir(): - Return root scan failures as a secondary return value. - Propagate recursive scan failures through the iterator. This allows callers to distinguish unreadable directories from empty ones. - vim.fs.find(): Collect errors during search, and return the list as a second retval. --- runtime/doc/lua.txt | 34 +++++-- runtime/doc/news.txt | 3 + runtime/lua/vim/fs.lua | 110 +++++++++++++++++----- test/functional/lua/fs_spec.lua | 159 ++++++++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 30 deletions(-) diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index aa9fb67ad6..130031ff4c 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -2596,6 +2596,20 @@ vim.fs.dir({path}, {opts}) *vim.fs.dir()* 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 + if err then + -- Failed to scan a child directory + end + end +< + Attributes: ~ Since: 0.8.0 @@ -2611,11 +2625,14 @@ 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: ~ + Return (multiple): ~ (`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". + "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 vim.fs.dirname({file}) *vim.fs.dirname()* Gets the parent directory of the given path (not expanded/resolved, the @@ -2708,9 +2725,12 @@ vim.fs.find({names}, {opts}) *vim.fs.find()* through parent directories. Otherwise, search child directories (recursively). - Return: ~ - (`string[]`) Normalized paths |vim.fs.normalize()| of all matching - items + 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 vim.fs.joinpath({...}) *vim.fs.joinpath()* Concatenates partial paths (one absolute or relative path followed by zero diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 09580a6644..6c1b58737a 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -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()| 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.filetype.inspect()| returns a copy of the internal tables used for filetype detection. • Added `__eq` metamethod to |vim.VersionRange|. 2 distinct but representing diff --git a/runtime/lua/vim/fs.lua b/runtime/lua/vim/fs.lua index 7e85e80056..4a524aa467 100644 --- a/runtime/lua/vim/fs.lua +++ b/runtime/lua/vim/fs.lua @@ -187,17 +187,36 @@ end --- (default: `false`) --- @field follow? boolean ----@alias Iterator fun(): string?, string? +---@alias Iterator fun(): string?, string?, string? --- 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 +--- if err then +--- -- Failed to scan a child directory +--- 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". +---@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 string? err Error encountered while scanning the base directory function M.dir(path, opts) opts = opts or {} @@ -207,31 +226,37 @@ function M.dir(path, opts) vim.validate('follow', opts.follow, 'boolean', true) path = M.normalize(path) + + local rootfs, rooterr = uv.fs_scandir(path) + + if not rootfs then + local empty = function() + return nil + end + + return empty, rooterr + end + if not opts.depth or opts.depth == 1 then - local fs = uv.fs_scandir(path) return function() - if not fs then - return - end - return fs_scandir_next(fs, path) + return fs_scandir_next(rootfs, path) end end --- @async - return coroutine.wrap(function() - local dirs = { { path, 1 } } + local it = coroutine.wrap(function() + 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,11 +265,19 @@ 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 = err + else + dirs[#dirs + 1] = { f, level + 1, fs_next } + end end + coroutine.yield(f, t, err_scan) end end end) + + return it, nil end --- @class vim.fs.find.Opts @@ -271,6 +304,15 @@ 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" @@ -308,7 +350,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[] matches # Normalized paths |vim.fs.normalize()| of all matching items +---@return vim.fs.find.Error[] errors # list of errors encountered during the search function M.find(names, opts) opts = opts or {} vim.validate('names', names, { 'string', 'table', 'function' }) @@ -328,6 +371,7 @@ function M.find(names, opts) local limit = opts.limit or 1 local matches = {} --- @type string[] + local errors = {} --- @type vim.fs.find.Error[] local function add(match) matches[#matches + 1] = M.normalize(match) @@ -342,9 +386,17 @@ function M.find(names, opts) if type(names) == 'function' then test = function(p) local t = {} - for name, type in M.dir(p) do + local it, base_err = M.dir(p) + if base_err ~= nil then + table.insert(errors, { path = p, err = 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 }) + end if (not opts.type or opts.type == type) and names(name, p) then - table.insert(t, M.joinpath(p, name)) + table.insert(t, f) end end return t @@ -352,6 +404,11 @@ 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 }) + return t + end for _, name in ipairs(names) do local f = M.joinpath(p, name) local stat = uv.fs_stat(f) @@ -366,7 +423,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 +434,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,19 +446,26 @@ function M.find(names, opts) break end - for other, type_ in M.dir(dir) do + local it, base_err = M.dir(dir) + if base_err ~= nil then + table.insert(errors, { path = dir, err = 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 }) + 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 + 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 + return matches, errors end end end @@ -417,7 +481,7 @@ function M.find(names, opts) end end - return matches + return matches, errors end --- Find the first parent directory containing a specific "marker", relative to a file path or diff --git a/test/functional/lua/fs_spec.lua b/test/functional/lua/fs_spec.lua index 8434a36373..302d1445ac 100644 --- a/test/functional/lua/fs_spec.lua +++ b/test/functional/lua/fs_spec.lua @@ -287,6 +287,58 @@ describe('vim.fs', function() eq('file', result['test.txt']) 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() + rmdir('testdir') + end) + + it('returns error for unreadable root directory', function() + local result = 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, ...) + 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) + end + return errors_or_err + 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']) + end) + end) end) describe('find()', function() @@ -394,6 +446,113 @@ 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) + 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 + end + return { matches = matches, errors = err_by_path } + 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']) + 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) + + 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) + end) end) describe('root()', function() From f141916e3d1ea61ddccb43b32a3d37b16cb640a0 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Wed, 1 Jul 2026 10:47:31 +0200 Subject: [PATCH 2/4] 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) From 971a0a0fe0393cca8ec389a661ca1ab290b18dfd Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Wed, 1 Jul 2026 12:05:15 +0200 Subject: [PATCH 3/4] feat(vim.fs): dir() ergonomics --- runtime/doc/lua.txt | 36 ++++++------ runtime/doc/news.txt | 6 +- runtime/lua/vim/fs.lua | 101 +++++++++++++++----------------- test/functional/lua/fs_spec.lua | 88 +++++++++++++++------------- 4 files changed, 113 insertions(+), 118 deletions(-) diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index f9f77c4063..c79366fcbb 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -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 diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 6c1b58737a..5dee6ad646 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -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 diff --git a/runtime/lua/vim/fs.lua b/runtime/lua/vim/fs.lua index 5dd87816b0..fc18d525dd 100644 --- a/runtime/lua/vim/fs.lua +++ b/runtime/lua/vim/fs.lua @@ -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 diff --git a/test/functional/lua/fs_spec.lua b/test/functional/lua/fs_spec.lua index 2b37f6a005..4a88e4b6d1 100644 --- a/test/functional/lua/fs_spec.lua +++ b/test/functional/lua/fs_spec.lua @@ -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 - for f, _, err in vim.fs.dir('testdir', { depth = 2 }) do - errors[f] = err - end - return errors - end) + local errors = {} ---@type table + 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({ From 1035a9fb5df1ed7c5cab99fa4c0686c2c9ef0e37 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Wed, 1 Jul 2026 12:32:15 +0200 Subject: [PATCH 4/4] refactor(dir): use vim.fs.dir() --- runtime/lua/nvim/dir.lua | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/runtime/lua/nvim/dir.lua b/runtime/lua/nvim/dir.lua index bd00b95cbe..cdc8713c77 100644 --- a/runtime/lua/nvim/dir.lua +++ b/runtime/lua/nvim/dir.lua @@ -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