feat(vim.fs): find(), dir() can "follow" symlinks #31551

Problem:
vim.fs.dir(), vim.fs.find() do not follow symlinks.

Solution:
- Add "follow" flag.
- Enable it by default.
This commit is contained in:
Mike
2025-01-15 01:39:17 +01:00
committed by GitHub
parent e8a6c1b021
commit 611ef35491
5 changed files with 113 additions and 13 deletions

View File

@@ -2983,6 +2983,7 @@ vim.fs.dir({path}, {opts}) *vim.fs.dir()*
• skip: (fun(dir_name: string): boolean)|nil Predicate to • skip: (fun(dir_name: string): boolean)|nil Predicate to
control traversal. Return false to stop searching the control traversal. Return false to stop searching the
current directory. Only useful when depth > 1 current directory. Only useful when depth > 1
• follow: boolean|nil Follow symbolic links. (default: true)
Return: ~ Return: ~
(`Iterator`) over items in {path}. Each iteration yields two values: (`Iterator`) over items in {path}. Each iteration yields two values:
@@ -3024,7 +3025,7 @@ vim.fs.find({names}, {opts}) *vim.fs.find()*
-- get all files ending with .cpp or .hpp inside lib/ -- get all files ending with .cpp or .hpp inside lib/
local cpp_hpp = vim.fs.find(function(name, path) local cpp_hpp = vim.fs.find(function(name, path)
return name:match('.*%.[ch]pp$') and path:match('[/\\\\]lib$') return name:match('.*%.[ch]pp$') and path:match('[/\\]lib$')
end, {limit = math.huge, type = 'file'}) end, {limit = math.huge, type = 'file'})
< <
@@ -3038,8 +3039,10 @@ vim.fs.find({names}, {opts}) *vim.fs.find()*
If {names} is a function, it is called for each traversed If {names} is a function, it is called for each traversed
item with args: item with args:
• name: base name of the current item • name: base name of the current item
• path: full path of the current item The function should • path: full path of the current item
return `true` if the given item is considered a match.
The function should return `true` if the given item is
considered a match.
• {opts} (`table`) Optional keyword arguments: • {opts} (`table`) Optional keyword arguments:
• {path}? (`string`) Path to begin searching from. If • {path}? (`string`) Path to begin searching from. If
omitted, the |current-directory| is used. omitted, the |current-directory| is used.
@@ -3053,6 +3056,8 @@ vim.fs.find({names}, {opts}) *vim.fs.find()*
• {limit}? (`number`, default: `1`) Stop the search after • {limit}? (`number`, default: `1`) Stop the search after
finding this many matches. Use `math.huge` to place no finding this many matches. Use `math.huge` to place no
limit on the number of matches. limit on the number of matches.
• {follow}? (`boolean`, default: `true`) Follow symbolic
links.
Return: ~ Return: ~
(`string[]`) Normalized paths |vim.fs.normalize()| of all matching (`string[]`) Normalized paths |vim.fs.normalize()| of all matching

View File

@@ -286,6 +286,8 @@ LUA
• |vim.json.encode()| has an option to enable forward slash escaping • |vim.json.encode()| has an option to enable forward slash escaping
• |vim.fs.abspath()| converts paths to absolute paths. • |vim.fs.abspath()| converts paths to absolute paths.
• |vim.fs.relpath()| gets relative path compared to base path. • |vim.fs.relpath()| gets relative path compared to base path.
• |vim.fs.dir()| and |vim.fs.find()| now follow symbolic links by default,
the behavior can be turn off using the new `follow` option.
OPTIONS OPTIONS

View File

@@ -136,6 +136,7 @@ end
--- - skip: (fun(dir_name: string): boolean)|nil Predicate --- - skip: (fun(dir_name: string): boolean)|nil Predicate
--- to control traversal. Return false to stop searching the current directory. --- to control traversal. Return false to stop searching the current directory.
--- Only useful when depth > 1 --- Only useful when depth > 1
--- - follow: boolean|nil Follow symbolic links. (default: true)
--- ---
---@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".
--- "name" is the basename of the item relative to {path}. --- "name" is the basename of the item relative to {path}.
@@ -147,6 +148,7 @@ function M.dir(path, opts)
vim.validate('path', path, 'string') vim.validate('path', path, 'string')
vim.validate('depth', opts.depth, 'number', true) vim.validate('depth', opts.depth, 'number', true)
vim.validate('skip', opts.skip, 'function', true) vim.validate('skip', opts.skip, 'function', true)
vim.validate('follow', opts.follow, 'boolean', true)
path = M.normalize(path) path = M.normalize(path)
if not opts.depth or opts.depth == 1 then if not opts.depth or opts.depth == 1 then
@@ -177,7 +179,9 @@ function M.dir(path, opts)
if if
opts.depth opts.depth
and level < opts.depth and level < opts.depth
and t == 'directory' and (t == 'directory' or (t == 'link' and opts.follow ~= false and (vim.uv.fs_stat(
M.joinpath(path, f)
) or {}).type == 'directory'))
and (not opts.skip or opts.skip(f) ~= false) and (not opts.skip or opts.skip(f) ~= false)
then then
dirs[#dirs + 1] = { f, level + 1 } dirs[#dirs + 1] = { f, level + 1 }
@@ -211,6 +215,10 @@ end
--- Use `math.huge` to place no limit on the number of matches. --- Use `math.huge` to place no limit on the number of matches.
--- (default: `1`) --- (default: `1`)
--- @field limit? number --- @field limit? number
---
--- Follow symbolic links.
--- (default: `true`)
--- @field follow? boolean
--- Find files or directories (or other items as specified by `opts.type`) in the given path. --- Find files or directories (or other items as specified by `opts.type`) in the given path.
--- ---
@@ -234,7 +242,7 @@ end
--- ---
--- -- get all files ending with .cpp or .hpp inside lib/ --- -- get all files ending with .cpp or .hpp inside lib/
--- local cpp_hpp = vim.fs.find(function(name, path) --- local cpp_hpp = vim.fs.find(function(name, path)
--- return name:match('.*%.[ch]pp$') and path:match('[/\\\\]lib$') --- return name:match('.*%.[ch]pp$') and path:match('[/\\]lib$')
--- end, {limit = math.huge, type = 'file'}) --- end, {limit = math.huge, type = 'file'})
--- ``` --- ```
--- ---
@@ -244,6 +252,7 @@ end
--- If {names} is a function, it is called for each traversed item with args: --- If {names} is a function, it is called for each traversed item with args:
--- - name: base name of the current item --- - name: base name of the current item
--- - path: full path of the current item --- - path: full path of the current item
---
--- The function should return `true` if the given item is considered a match. --- The function should return `true` if the given item is considered a match.
--- ---
---@param opts vim.fs.find.Opts Optional keyword arguments: ---@param opts vim.fs.find.Opts Optional keyword arguments:
@@ -256,6 +265,7 @@ function M.find(names, opts)
vim.validate('stop', opts.stop, 'string', true) vim.validate('stop', opts.stop, 'string', true)
vim.validate('type', opts.type, 'string', true) vim.validate('type', opts.type, 'string', true)
vim.validate('limit', opts.limit, 'number', true) vim.validate('limit', opts.limit, 'number', true)
vim.validate('follow', opts.follow, 'boolean', true)
if type(names) == 'string' then if type(names) == 'string' then
names = { names } names = { names }
@@ -345,7 +355,14 @@ function M.find(names, opts)
end end
end end
if type_ == 'directory' then if
type_ == 'directory'
or (
type_ == 'link'
and opts.follow ~= false
and (vim.uv.fs_stat(f) or {}).type == 'directory'
)
then
dirs[#dirs + 1] = f dirs[#dirs + 1] = f
end end
end end

View File

@@ -17,6 +17,8 @@ local mkdir = t.mkdir
local nvim_prog_basename = is_os('win') and 'nvim.exe' or 'nvim' local nvim_prog_basename = is_os('win') and 'nvim.exe' or 'nvim'
local link_limit = is_os('win') and 64 or (is_os('mac') or is_os('bsd')) and 33 or 41
local test_basename_dirname_eq = { local test_basename_dirname_eq = {
'~/foo/', '~/foo/',
'~/foo', '~/foo',
@@ -152,7 +154,7 @@ describe('vim.fs', function()
) )
end) end)
it('works with opts.depth and opts.skip', function() it('works with opts.depth, opts.skip and opts.follow', function()
io.open('testd/a1', 'w'):close() io.open('testd/a1', 'w'):close()
io.open('testd/b1', 'w'):close() io.open('testd/b1', 'w'):close()
io.open('testd/c1', 'w'):close() io.open('testd/c1', 'w'):close()
@@ -166,8 +168,8 @@ describe('vim.fs', function()
io.open('testd/a/b/c/b4', 'w'):close() io.open('testd/a/b/c/b4', 'w'):close()
io.open('testd/a/b/c/c4', 'w'):close() io.open('testd/a/b/c/c4', 'w'):close()
local function run(dir, depth, skip) local function run(dir, depth, skip, follow)
return exec_lua(function() return exec_lua(function(follow_)
local r = {} --- @type table<string, string> local r = {} --- @type table<string, string>
local skip_f --- @type function local skip_f --- @type function
if skip then if skip then
@@ -177,11 +179,11 @@ describe('vim.fs', function()
end end
end end
end end
for name, type_ in vim.fs.dir(dir, { depth = depth, skip = skip_f }) do for name, type_ in vim.fs.dir(dir, { depth = depth, skip = skip_f, follow = follow_ }) do
r[name] = type_ r[name] = type_
end end
return r return r
end) end, follow)
end end
local exp = {} local exp = {}
@@ -197,6 +199,7 @@ describe('vim.fs', function()
exp['a/b2'] = 'file' exp['a/b2'] = 'file'
exp['a/c2'] = 'file' exp['a/c2'] = 'file'
exp['a/b'] = 'directory' exp['a/b'] = 'directory'
local lexp = vim.deepcopy(exp)
eq(exp, run('testd', 2)) eq(exp, run('testd', 2))
@@ -213,6 +216,29 @@ describe('vim.fs', function()
exp['a/b/c/c4'] = 'file' exp['a/b/c/c4'] = 'file'
eq(exp, run('testd', 999)) eq(exp, run('testd', 999))
vim.uv.fs_symlink(vim.uv.fs_realpath('testd/a'), 'testd/l', { junction = true, dir = true })
lexp['l'] = 'link'
eq(lexp, run('testd', 2, nil, false))
lexp['l/a2'] = 'file'
lexp['l/b2'] = 'file'
lexp['l/c2'] = 'file'
lexp['l/b'] = 'directory'
eq(lexp, run('testd', 2, nil, true))
end)
it('follow=true handles symlink loop', function()
local cwd = 'testd/a/b/c'
local symlink = cwd .. '/link_loop' ---@type string
vim.uv.fs_symlink(vim.uv.fs_realpath(cwd), symlink, { junction = true, dir = true })
eq(
link_limit,
exec_lua(function()
return #vim.iter(vim.fs.dir(cwd, { depth = math.huge, follow = true })):totable()
end)
)
end) end)
end) end)
@@ -228,6 +254,53 @@ describe('vim.fs', function()
eq({ nvim_dir }, vim.fs.find(name, { path = parent, upward = true, type = 'directory' })) eq({ nvim_dir }, vim.fs.find(name, { path = parent, upward = true, type = 'directory' }))
end) end)
it('follows symlinks', function()
local build_dir = test_source_path .. '/build' ---@type string
local symlink = test_source_path .. '/build_link' ---@type string
vim.uv.fs_symlink(build_dir, symlink, { junction = true, dir = true })
finally(function()
vim.uv.fs_unlink(symlink)
end)
eq(
{ nvim_prog, symlink .. '/bin/' .. nvim_prog_basename },
vim.fs.find(nvim_prog_basename, {
path = test_source_path,
type = 'file',
limit = 2,
follow = true,
})
)
eq(
{ nvim_prog },
vim.fs.find(nvim_prog_basename, {
path = test_source_path,
type = 'file',
limit = 2,
follow = false,
})
)
end)
it('follow=true handles symlink loop', function()
local cwd = test_source_path ---@type string
local symlink = test_source_path .. '/loop_link' ---@type string
vim.uv.fs_symlink(cwd, symlink, { junction = true, dir = true })
finally(function()
vim.uv.fs_unlink(symlink)
end)
eq(link_limit, #vim.fs.find(nvim_prog_basename, {
path = test_source_path,
type = 'file',
limit = math.huge,
follow = true,
}))
end)
it('accepts predicate as names', function() it('accepts predicate as names', function()
local opts = { path = nvim_dir, upward = true, type = 'directory' } local opts = { path = nvim_dir, upward = true, type = 'directory' }
eq( eq(

View File

@@ -388,15 +388,18 @@ end
local sysname = uv.os_uname().sysname:lower() local sysname = uv.os_uname().sysname:lower()
--- @param s 'win'|'mac'|'freebsd'|'openbsd'|'bsd' --- @param s 'win'|'mac'|'linux'|'freebsd'|'openbsd'|'bsd'
--- @return boolean --- @return boolean
function M.is_os(s) function M.is_os(s)
if not (s == 'win' or s == 'mac' or s == 'freebsd' or s == 'openbsd' or s == 'bsd') then if
not (s == 'win' or s == 'mac' or s == 'linux' or s == 'freebsd' or s == 'openbsd' or s == 'bsd')
then
error('unknown platform: ' .. tostring(s)) error('unknown platform: ' .. tostring(s))
end end
return not not ( return not not (
(s == 'win' and (sysname:find('windows') or sysname:find('mingw'))) (s == 'win' and (sysname:find('windows') or sysname:find('mingw')))
or (s == 'mac' and sysname == 'darwin') or (s == 'mac' and sysname == 'darwin')
or (s == 'linux' and sysname == 'linux')
or (s == 'freebsd' and sysname == 'freebsd') or (s == 'freebsd' and sysname == 'freebsd')
or (s == 'openbsd' and sysname == 'openbsd') or (s == 'openbsd' and sysname == 'openbsd')
or (s == 'bsd' and sysname:find('bsd')) or (s == 'bsd' and sysname:find('bsd'))