local M = {} local iswin = vim.loop.os_uname().sysname == 'Windows_NT' --- Iterate over all the parents of the given file or directory. --- --- Example: ---
lua
--- local root_dir
--- for dir in vim.fs.parents(vim.api.nvim_buf_get_name(0)) do
---   if vim.fn.isdirectory(dir .. "/.git") == 1 then
---     root_dir = dir
---     break
---   end
--- end
---
--- if root_dir then
---   print("Found git repository at", root_dir)
--- end
--- 
--- ---@param start (string) Initial file or directory. ---@return (function) Iterator function M.parents(start) return function(_, dir) local parent = M.dirname(dir) if parent == dir then return nil end return parent end, nil, start end --- Return the parent directory of the given file or directory --- ---@param file (string) File or directory ---@return (string) Parent directory of {file} function M.dirname(file) if file == nil then return nil end vim.validate({ file = { file, 's' } }) if iswin and file:match('^%w:[\\/]?$') then return (file:gsub('\\', '/')) elseif not file:match('[\\/]') then return '.' elseif file == '/' or file:match('^/[^/]+$') then return '/' end local dir = file:match('[/\\]$') and file:sub(1, #file - 1) or file:match('^([/\\]?.+)[/\\]') if iswin and dir:match('^%w:$') then return dir .. '/' end return (dir:gsub('\\', '/')) end --- Return the basename of the given file or directory --- ---@param file (string) File or directory ---@return (string) Basename of {file} function M.basename(file) if file == nil then return nil end vim.validate({ file = { file, 's' } }) if iswin and file:match('^%w:[\\/]?$') then return '' end return file:match('[/\\]$') and '' or (file:match('[^\\/]*$'):gsub('\\', '/')) end ---@private local function join_paths(...) return (table.concat({ ... }, '/'):gsub('//+', '/')) end --- Return an iterator over the files and directories located in {path} --- ---@param path (string) An absolute or relative path to the directory to iterate --- over. The path is first normalized |vim.fs.normalize()|. --- @param opts table|nil Optional keyword arguments: --- - depth: integer|nil How deep the traverse (default 1) --- - skip: (fun(dir_name: string): boolean)|nil Predicate --- to control traversal. Return false to stop searching the current directory. --- Only useful when depth > 1 --- ---@return Iterator over files and directories in {path}. Each iteration yields --- two values: name and type. Each "name" is the basename of the file or --- directory relative to {path}. Type is one of "file" or "directory". function M.dir(path, opts) opts = opts or {} vim.validate({ path = { path, { 'string' } }, depth = { opts.depth, { 'number' }, true }, skip = { opts.skip, { 'function' }, true }, }) if not opts.depth or opts.depth == 1 then return function(fs) return vim.loop.fs_scandir_next(fs) end, vim.loop.fs_scandir(M.normalize(path)) end --- @async return coroutine.wrap(function() local dirs = { { path, 1 } } while #dirs > 0 do local dir0, level = unpack(table.remove(dirs, 1)) local dir = level == 1 and dir0 or join_paths(path, dir0) local fs = vim.loop.fs_scandir(M.normalize(dir)) while fs do local name, t = vim.loop.fs_scandir_next(fs) if not name then break end local f = level == 1 and name or join_paths(dir0, name) coroutine.yield(f, t) if opts.depth and level < opts.depth and t == 'directory' and (not opts.skip or opts.skip(f) ~= false) then dirs[#dirs + 1] = { f, level + 1 } end end end end) end --- Find files or directories in the given path. --- --- Finds any files or directories given in {names} starting from {path}. If --- {upward} is "true" then the search traverses upward through parent --- directories; otherwise, the search traverses downward. Note that downward --- searches are recursive and may search through many directories! If {stop} --- is non-nil, then the search stops when the directory given in {stop} is --- reached. The search terminates when {limit} (default 1) matches are found. --- The search can be narrowed to find only files or only directories by --- specifying {type} to be "file" or "directory", respectively. --- ---@param names (string|table|fun(name: string): boolean) Names of the files --- and directories to find. --- Must be base names, paths and globs are not supported. --- The function is called per file and directory within the --- traversed directories to test if they match {names}. --- ---@param opts (table) Optional keyword arguments: --- - path (string): Path to begin searching from. If --- omitted, the |current-directory| is used. --- - upward (boolean, default false): If true, search --- upward through parent directories. Otherwise, --- search through child directories --- (recursively). --- - stop (string): Stop searching when this directory is --- reached. The directory itself is not searched. --- - type (string): Find only files ("file") or --- directories ("directory"). If omitted, both --- files and directories that match {names} are --- included. --- - limit (number, default 1): Stop the search after --- finding this many matches. Use `math.huge` to --- place no limit on the number of matches. ---@return (table) Normalized paths |vim.fs.normalize()| of all matching files or directories function M.find(names, opts) opts = opts or {} vim.validate({ names = { names, { 's', 't', 'f' } }, path = { opts.path, 's', true }, upward = { opts.upward, 'b', true }, stop = { opts.stop, 's', true }, type = { opts.type, 's', true }, limit = { opts.limit, 'n', true }, }) names = type(names) == 'string' and { names } or names local path = opts.path or vim.loop.cwd() local stop = opts.stop local limit = opts.limit or 1 local matches = {} ---@private local function add(match) matches[#matches + 1] = match if #matches == limit then return true end end if opts.upward then local test if type(names) == 'function' then test = function(p) local t = {} for name, type in M.dir(p) do if names(name) and (not opts.type or opts.type == type) then table.insert(t, join_paths(p, name)) end end return t end else test = function(p) local t = {} for _, name in ipairs(names) do local f = join_paths(p, name) local stat = vim.loop.fs_stat(f) if stat and (not opts.type or opts.type == stat.type) then t[#t + 1] = f end end return t end end for _, match in ipairs(test(path)) do if add(match) then return matches end end for parent in M.parents(path) do if stop and parent == stop then break end for _, match in ipairs(test(parent)) do if add(match) then return matches end end end else local dirs = { path } while #dirs > 0 do local dir = table.remove(dirs, 1) if stop and dir == stop then break end for other, type_ in M.dir(dir) do local f = join_paths(dir, other) if type(names) == 'function' then if names(other) and (not opts.type or opts.type == type_) then if add(f) then return matches 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 end end end end if type_ == 'directory' then dirs[#dirs + 1] = f end end end end return matches end --- Normalize a path to a standard format. A tilde (~) character at the --- beginning of the path is expanded to the user's home directory and any --- backslash (\\) characters are converted to forward slashes (/). Environment --- variables are also expanded. --- --- Examples: ---
lua
---   vim.fs.normalize('C:\\\\Users\\\\jdoe')
---   --> 'C:/Users/jdoe'
---
---   vim.fs.normalize('~/src/neovim')
---   --> '/home/jdoe/src/neovim'
---
---   vim.fs.normalize('$XDG_CONFIG_HOME/nvim/init.vim')
---   --> '/Users/jdoe/.config/nvim/init.vim'
--- 
--- ---@param path (string) Path to normalize ---@return (string) Normalized path function M.normalize(path) vim.validate({ path = { path, 's' } }) return ( path :gsub('^~$', vim.loop.os_homedir()) :gsub('^~/', vim.loop.os_homedir() .. '/') :gsub('%$([%w_]+)', vim.loop.os_getenv) :gsub('\\', '/') ) end return M