--- @brief --- [vim.fs.copy()]() --- --- Use |filecopy()| or |uv.fs_copyfile()| to performantly copy an existing file. --- --- Example: --- --- ```lua --- vim.fn.filecopy('foo.txt', 'bar.txt') --- ``` --- --- [vim.fs.exists()]() --- --- Use |uv.fs_stat()| to check a file's type, and whether it exists. --- --- Example: --- --- ```lua --- if vim.uv.fs_stat(file) then --- vim.print('file exists') --- end --- ``` --- --- [vim.fs.read()]() --- --- You can use |readblob()| to get a file's contents without explicitly opening/closing it. --- Or use |io.lines()| to iterate lines in a text file. --- --- Example: --- ```lua --- vim.print(vim.fn.readblob('.git/config')) --- ``` --- --- [vim.fs.write()]() --- --- You can use |writefile()| to write a file without explicitly opening/closing it. --- --- Example: --- ```lua --- vim.fn.writefile('foo\0bar', 'data.bin', 'b') --- ``` local uv = vim.uv local M = {} local iswin = vim.fn.has('win32') == 1 local os_sep = iswin and '\\' or '/' --- Iterate over all the parents of the given path (not expanded/resolved, the caller must do that). --- --- 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 --- ``` --- ---@since 10 ---@param start (string) Initial path. ---@return fun(_, dir: string): string? # Iterator ---@return nil ---@return string|nil 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 --- Gets the parent directory of the given path (not expanded/resolved, the caller must do that). --- ---@since 10 ---@generic T : string|nil ---@param file T Path ---@return T # Parent directory of `file` function M.dirname(file) if file == nil then return nil end vim.validate('file', file, 'string') local dir = vim.fn.fnamemodify(file, ':h') if iswin then return (dir:gsub(os_sep, '/')) end return dir end --- Gets the basename of the given path (not expanded/resolved). --- ---@since 10 ---@generic T : string|nil ---@param file T Path ---@return T # Basename of `file` function M.basename(file) if file == nil then return nil end vim.validate('file', file, 'string') local name = vim.fn.fnamemodify(file, ':t') if iswin then return (name:gsub(os_sep, '/')) end return name end --- Concatenates partial paths (one absolute or relative path followed by zero or more relative --- paths). Slashes are normalized: redundant slashes are removed, and (on Windows) backslashes are --- replaced with forward-slashes. Empty segments are removed. Paths are not expanded/resolved. --- --- Examples: --- - "foo/", "/bar" => "foo/bar" --- - "", "after/plugin" => "after/plugin" --- - Windows: "a\foo\", "\bar" => "a/foo/bar" --- ---@since 12 ---@param ... string ---@return string function M.joinpath(...) local n = select('#', ...) ---@type string[] local segments = {} for i = 1, n do local s = select(i, ...) if s and #s > 0 then segments[#segments + 1] = s end end local path = table.concat(segments, '/') return (path:gsub(iswin and '[/\\][/\\]*' or '//+', '/')) end --- Gets a filesystem-safe, mnemonic slug (readable prefix + short hash) of an arbitrary filepath or --- other "identity string". --- --- - The input is normalized so equivalent paths produce the same result. --- - A hash of the normalized input is appended to prevent collisions. --- - Unsafe chars are replaced with "-". --- - `$HOME` is replaced with "~". --- - UNC paths (Windows) are prefixed with "=unc-". --- - If `opts.maxlen` is exceeded, the result will be truncated to `{head}~~~{tail}-{hash8}`. --- - If the sanitized name is empty, the reserved label `=special` will be used. --- --- Examples: --- --- ```lua --- vim.print(vim.fs.slug('/tmp/test/foo.md')) --> "tmp-test-foo.md-{hash}" --- vim.print(vim.fs.slug('C:/src/project/main.c')) --> "C--src-project-main.c-{hash}" --- vim.print(vim.fs.slug(vim.fn.expand('~/file.txt'))) --> "~-file.txt-{hash}" --- vim.print(vim.fs.slug('---')) --> "=special-{hash}" --- vim.print(vim.fs.slug(('/a/very/long/path'):rep(10) .. '/file.txt', { maxlen = 60 })) --- --> "a-very-long-~~~-path-a-very-long-path-file.txt-{hash}" --- ``` --- ---@since 15 ---@param path string Filepath (or other identity string). ---@param opts? table --- - maxlen: (integer, default: 180) Max length (bytes) of the result. ---@return string # Filesystem-safe, mnemonic slug. function M.slug(path, opts) vim.validate('path', path, 'string') opts = opts or {} vim.validate('maxlen', opts.maxlen, function(v) return type(v) == 'number' and v >= 8 end, true, '`opts.maxlen` must be >= 8') local maxlen = opts.maxlen or 180 -- Normalize before computing the hash so equivalent paths produce the same result path = vim.fs.normalize(path, { plain = true }) local s = path -- Replace $HOME with `~` -- `fnamemodify` resolves relative paths against CWD, so only call it on absolute paths if vim.startswith(s, '/') or (iswin and s:match('^%w:/')) then s = vim.fn.fnamemodify(s, ':~') end -- Replace UNC `//...` (Windows only) with `=unc-` -- `//?/` and `//./` are NT namespace prefixes, not UNC if iswin and vim.startswith(s, '//') and not vim.startswith(s, '//?/') and not vim.startswith(s, '//./') then s = '=unc-' .. s:sub(3) end -- Sanitize unsafe chars and trim trailing "-" and "." s = s:gsub('[%c%s/\\:*?"<>|]', '-') s = s:gsub('[.-]+$', '') -- Strip the leading "-" from an absolute path s = s:gsub('^-', '') -- Always compute the hash to prevent collisions local hash8 = vim.fn.sha256(path):sub(1, 8) -- Fully scrubbed path uses the reserved prefix if s == '' then s = '=special' end -- Within maxlen: "{name}-{hash8}" if #s + 1 + #hash8 <= maxlen then return s .. '-' .. hash8 end -- "{head}~~~{tail}-{hash8}" local budget = maxlen - 12 -- 3 for "~~~", 1 for "-", 8 for hash if budget < 1 then -- No room for a readable form: degrade to a plain hash (maxlen >= 8 == #hash8). return hash8 end local head_len = math.floor(budget / 3) local h = s:sub(1, head_len):match('^.*()-') or head_len -- byte position where {head} ends if h == head_len and h >= 1 then -- No "-" found in prefix: ensure we don't split a UTF-8 character. -- `vim.str_utf_start` returns an offset (<= 0) from the byte position to the character start. -- `vim.str_utf_end` returns an offset (>= 0) to the character's last byte. local char_start = h + vim.str_utf_start(s, h) ---@type integer if char_start + vim.str_utf_end(s, char_start) > h then h = char_start - 1 end end -- Always in [h + 4, #s]: the "{name}-{hash8}" case above handled #s <= budget + 3. local tail_start = #s - budget + h + 1 local t = s:find('-', tail_start, true) or tail_start -- byte position where {tail} starts -- If we fall back to a byte position, step forward past a split character if t == tail_start then local offset_start = vim.str_utf_start(s, t) if offset_start < 0 then local char_start = t + offset_start ---@type integer t = char_start + vim.str_utf_end(s, char_start) + 1 end end return s:sub(1, h) .. '~~~' .. s:sub(t) .. '-' .. hash8 end --- Wrapper around `uv.fs_scandir_next()` that ensures a file type is returned. --- --- @param fs uv.uv_fs_t --- @param path string --- @return string? --- @return string? local function fs_scandir_next(fs, path) -- use uv.fs_lstat instead of uv.fs_stat to avoid descending into a symlink entry as a directory/file local name, etype = uv.fs_scandir_next(fs) if not name then return end if etype == nil then local stat = vim.uv.fs_lstat(M.joinpath(path, name)) -- Workaround #39612 https://github.com/luvit/luv/issues/660 etype = stat and stat.type or 'unknown' end return name, etype end --- @class vim.fs.dir.Opts --- @inlinedoc --- --- How deep to traverse. --- (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 --- Return an iterator over the items located in {path} --- @field skip? (fun(dir_name: string): boolean) --- --- Follow symbolic links. --- (default: `false`) --- @field follow? boolean --- --- Do not expand special forms like "~" and "$" in {path}. --- (default: `false`) --- @field plain? boolean --- 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, expanded (unless --- `opts.plain=true`) and normalized. ---@param opts? vim.fs.dir.Opts ---@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('err', opts.err, 'boolean', true) vim.validate('follow', opts.follow, 'boolean', true) vim.validate('skip', opts.skip, 'function', true) vim.validate('plain', opts.plain, 'boolean', true) path = M.normalize(path, { plain = opts.plain }) 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 done then return nil end 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, rootfs } } while #dirs > 0 do --- @type string, integer, any local dir0, level, fs = unpack(table.remove(dirs, 1)) local dir = level == 1 and dir0 or M.joinpath(path, dir0) 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) local err_scan = nil if opts.depth and level < opts.depth and (t == 'directory' or (t == 'link' and opts.follow and ( uv.fs_stat(M.joinpath(path, f)) or {} ).type == 'directory')) and (not opts.skip or opts.skip(f) ~= false) then 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) end --- @class vim.fs.find.Opts --- @inlinedoc --- --- Path to begin searching from, defaults to |current-directory|. Not expanded. --- @field path? string --- --- Search upward through parent directories. Otherwise, search child directories (recursively). --- (default: `false`) --- @field upward? boolean --- --- Stop searching when this directory is reached. The directory itself is not searched. --- @field stop? string --- --- Find only items of the given type. If omitted, all items that match {names} are included. --- @field type? string --- --- Stop searching after this many matches. Use `math.huge` for "unlimited". --- (default: `1`) --- @field limit? number --- --- Follow symbolic links. --- (default: `false`) --- @field follow? boolean --- 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" --- 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. You can set {type} --- to "file", "directory", "link", "socket", "char", "block", or "fifo" --- to narrow the search to find only that type. --- --- Examples: --- --- ```lua --- -- List all test directories under the runtime directory. --- local dirs = vim.fs.find( --- { 'test', 'tst', 'testdir' }, --- { limit = math.huge, type = 'directory', path = './runtime/' } --- ) --- --- -- Get all "lib/*.cpp" and "lib/*.hpp" files, using Lua patterns. --- -- Or use `vim.glob.to_lpeg(…):match(…)` for glob/wildcard matching. --- local files = vim.fs.find(function(name, path) --- return name:match('.*%.[ch]pp$') and path:match('[/\\]lib$') --- end, { limit = math.huge, type = 'file' }) --- ``` --- ---@since 10 ---@param names (string|string[]|fun(name: string, path: string): boolean) Names of the items to find. --- Must be base names, paths and globs are not supported when {names} is a string or a table. --- If {names} is a function, it is called for each traversed item with args: --- - name: base name of the current item --- - path: full path of the current item --- --- 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[] # Errors collected while searching. function M.find(names, opts) opts = opts or {} vim.validate('names', names, { 'string', 'table', 'function' }) vim.validate('path', opts.path, 'string', true) vim.validate('upward', opts.upward, 'boolean', true) vim.validate('stop', opts.stop, 'string', true) vim.validate('type', opts.type, 'string', true) vim.validate('limit', opts.limit, 'number', true) vim.validate('follow', opts.follow, 'boolean', true) if type(names) == 'string' then names = { names } end local path = opts.path or assert(uv.cwd()) local stop = opts.stop local limit = opts.limit or 1 local matches = {} --- @type string[] local errors = {} --- @type string[] local function add(match) matches[#matches + 1] = M.normalize(match) if #matches == limit then return true end end if opts.upward then local test --- @type fun(p: string): string[] if type(names) == 'function' then test = function(p) local t = {} 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 return t end 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) 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, errors 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, errors 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_, err in M.dir(dir, { err = true }) do if err ~= nil then table.insert(errors, err) else 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 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, errors end --- Find the first parent directory containing a specific "marker", relative to a file path or --- buffer. --- --- If the buffer is unnamed (has no backing file) or has a non-empty 'buftype' then the search --- begins from Nvim's |current-directory|. --- --- Examples: --- --- ```lua --- -- Find the root of a Python project, starting from file 'main.py' --- vim.fs.root(vim.fs.joinpath(vim.env.PWD, 'main.py'), {'pyproject.toml', 'setup.py' }) --- --- -- Find the root of a git repository --- vim.fs.root(0, '.git') --- --- -- Find the parent directory containing any file with a .csproj extension --- vim.fs.root(0, function(name, path) --- return vim.fs.ext(name) == 'csproj' --- end) --- --- -- Find the first ancestor directory containing EITHER "stylua.toml" or ".luarc.json"; if --- -- not found, find the first ancestor containing ".git": --- vim.fs.root(0, { { 'stylua.toml', '.luarc.json' }, '.git' }) --- ``` --- --- @since 12 --- @param source integer|string Buffer number (0 for current buffer) or file path (absolute or --- relative, expanded via `abspath()`) to begin the search from. --- @param marker (string|string[]|fun(name: string, path: string): boolean)[]|string|fun(name: string, path: string): boolean --- Filename, function, or list thereof, that decides how to find the root. To --- indicate "equal priority", specify items in a nested list `{ { 'a.txt', 'b.lua' }, … }`. --- A function item must return true if `name` and `path` are a match. Each item --- (which may itself be a nested list) is evaluated in-order against all ancestors, --- until a match is found. --- @return string? # Directory path containing one of the given markers, or nil if no directory was --- found. function M.root(source, marker) assert(source, 'missing required argument: source') assert(marker, 'missing required argument: marker') local path ---@type string if type(source) == 'string' then path = source elseif type(source) == 'number' then if vim.bo[source].buftype ~= '' then path = assert(uv.cwd()) else path = vim.api.nvim_buf_get_name(source) end else error('invalid type for argument "source": expected string or buffer number') end local markers = type(marker) == 'table' and marker or { marker } for _, mark in ipairs(markers) do local paths = M.find(mark, { upward = true, path = M.abspath(path), }) if #paths ~= 0 then local dir = M.dirname(paths[1]) return dir and M.abspath(dir) or nil end end return nil end --- Split a Windows path into a prefix and a body, such that the body can be processed like a POSIX --- path. The path must use forward slashes as path separator. --- --- Does not check if the path is a valid Windows path. Invalid paths will give invalid results. --- --- Examples: --- - `//./C:/foo/bar` -> `//./C:`, `/foo/bar` --- - `//?/UNC/server/share/foo/bar` -> `//?/UNC/server/share`, `/foo/bar` --- - `//./system07/C$/foo/bar` -> `//./system07`, `/C$/foo/bar` --- - `C:/foo/bar` -> `C:`, `/foo/bar` --- - `C:foo/bar` -> `C:`, `foo/bar` --- --- @param path string Path to split. --- @return string, string, boolean : prefix, body, whether path is invalid. local function split_windows_path(path) local prefix = '' --- Match pattern. If there is a match, move the matched pattern from the path to the prefix. --- Returns the matched pattern. --- --- @param pattern string Pattern to match. --- @return string|nil Matched pattern local function match_to_prefix(pattern) local match = path:match(pattern) if match then prefix = prefix .. match --[[ @as string ]] path = path:sub(#match + 1) end return match end local function process_unc_path() return match_to_prefix('[^/]+/+[^/]+/+') end if match_to_prefix('^//[?.]/') then -- Device paths local device = match_to_prefix('[^/]+/+') -- Return early if device pattern doesn't match, or if device is UNC and it's not a valid path if not device or (device:match('^UNC/+$') and not process_unc_path()) then return prefix, path, false end elseif match_to_prefix('^//') then -- Process UNC path, return early if it's invalid if not process_unc_path() then return prefix, path, false end elseif path:match('^%w:') then -- Drive paths prefix, path = path:sub(1, 2), path:sub(3) end -- If there are slashes at the end of the prefix, move them to the start of the body. This is to -- ensure that the body is treated as an absolute path. For paths like C:foo/bar, there are no -- slashes at the end of the prefix, so it will be treated as a relative path, as it should be. local trailing_slash = prefix:match('/+$') if trailing_slash then prefix = prefix:sub(1, -1 - #trailing_slash) path = trailing_slash .. path --[[ @as string ]] end return prefix, path, true end --- Resolve `.` and `..` components in a POSIX-style path. This also removes extraneous slashes. --- `..` is not resolved if the path is relative and resolving it requires the path to be absolute. --- If a relative path resolves to the current directory, an empty string is returned. --- --- @see M.normalize() --- @param path string Path to resolve. --- @return string Resolved path. local function path_resolve_dot(path) local is_path_absolute = vim.startswith(path, '/') local new_path_components = {} for component in vim.gsplit(path, '/') do if component == '.' or component == '' then -- luacheck: ignore 542 -- Skip `.` components and empty components elseif component == '..' then if #new_path_components > 0 and new_path_components[#new_path_components] ~= '..' then -- For `..`, remove the last component if we're still inside the current directory, except -- when the last component is `..` itself table.remove(new_path_components) elseif is_path_absolute then -- luacheck: ignore 542 -- Reached the root directory in absolute path, do nothing else -- Reached current directory in relative path, add `..` to the path table.insert(new_path_components, component) end else table.insert(new_path_components, component) end end return (is_path_absolute and '/' or '') .. table.concat(new_path_components, '/') end --- Expand tilde (~) character at the beginning of the path to the user's home directory. --- --- @param path string Path to expand. --- @param sep string|nil Path separator to use. Uses os_sep by default. --- @return string Expanded path. local function expand_home(path, sep) sep = sep or os_sep if vim.startswith(path, '~') then local home = uv.os_homedir() or '~' --- @type string if home:sub(-1) == sep then home = home:sub(1, -2) end path = home .. path:sub(2) --- @type string end return path end --- @class vim.fs.normalize.Opts --- @inlinedoc --- --- Expand environment variables (deprecated). --- (default: `true`) --- @field package expand_env? boolean --- --- Do not expand environment variables and leading tildes "~". --- (default: `false`) --- @field plain? boolean --- --- @field package _fast? boolean --- --- Path is a Windows path. --- (default: `true` in Windows, `false` otherwise) --- @field win? boolean --- Normalize a path to a standard format. Expands environment variables, and tilde "~" at the --- beginning of the path. Resolves "." and ".." components, except when the path is relative and --- resolving it would produce an absolute path. --- --- - "." as the only part in a relative path: --- - "." => "." --- - "././" => "." --- - ".." when it leads outside the current directory --- - "foo/../../bar" => "../bar" --- - "../../foo" => "../../foo" --- - ".." in the root directory returns the root directory. --- - "/../../" => "/" --- --- On Windows, backslashes (`\`) are converted to forward slashes (`/`). --- --- Examples: --- ```lua --- [[C:\Users\jdoe]] --> "C:/Users/jdoe" --- "~/src/neovim" --> "/home/jdoe/src/neovim" --- "$XDG_CONFIG_HOME/nvim/init.vim" --> "/Users/jdoe/.config/nvim/init.vim" --- "~/src/nvim/api/../tui/./tui.c" --> "/home/jdoe/src/nvim/tui/tui.c" --- "./foo/bar" --> "foo/bar" --- "foo/../../../bar" --> "../../bar" --- "/home/jdoe/../../../bar" --> "/bar" --- "C:foo/../../baz" --> "C:../baz" --- "C:/foo/../../baz" --> "C:/baz" --- [[\\?\UNC\server\share\foo\..\..\..\bar]] --> "//?/UNC/server/share/bar" --- ``` --- ---@since 10 ---@param path (string) Path to normalize ---@param opts? vim.fs.normalize.Opts ---@return (string) : Normalized path function M.normalize(path, opts) opts = opts or {} if not opts._fast then vim.validate('path', path, 'string') vim.validate('plain', opts.plain, 'boolean', true) vim.validate('win', opts.win, 'boolean', true) end local win = opts.win == nil and iswin or not not opts.win local os_sep_local = win and '\\' or '/' -- Empty path is already normalized if path == '' then return '' end if not opts.plain then -- Expand ~ to user's home directory path = expand_home(path, os_sep_local) -- Expand environment variables if opts.expand_env == nil or opts.expand_env then path = path:gsub('%$([%w_]+)', uv.os_getenv) --- @type string end end if win then -- Convert path separator to `/` path = path:gsub(os_sep_local, '/') --- @type string end -- Check for double slashes at the start of the path because they have special meaning local double_slash = false if not opts._fast then double_slash = vim.startswith(path, '//') and not vim.startswith(path, '///') end local prefix = '' if win then local is_valid --- @type boolean -- Split Windows paths into prefix and body to make processing easier prefix, path, is_valid = split_windows_path(path) -- If path is not valid, return it as-is if not is_valid then return prefix .. path end -- Ensure capital drive and remove extraneous slashes from the prefix prefix = prefix:gsub('^%a:', string.upper):gsub('/+', '/') end if not opts._fast then -- Resolve `.` and `..` components and remove extraneous slashes from path, then recombine prefix -- and path. path = path_resolve_dot(path) end -- Preserve leading double slashes as they indicate UNC paths and DOS device paths in -- Windows and have implementation-defined behavior in POSIX. path = (double_slash and '/' or '') .. prefix .. path -- Change empty path to `.` if path == '' then path = '.' end return path end --- @class vim.fs.mkdir.Opts --- @inlinedoc --- --- Create intermediate directories as necessary. --- (default: `false`) --- @field parents? boolean --- --- Permission bits for newly-created directories. --- (default: `493`) --- @field mode? integer --- Creates a directory. --- ---@since 15 ---@param path string Path to create (not expanded/resolved). ---@param opts? vim.fs.mkdir.Opts Optional keyword arguments. function M.mkdir(path, opts) vim.validate('path', path, 'string') vim.validate('opts', opts, 'table', true) opts = opts or {} vim.validate('parents', opts.parents, 'boolean', true) vim.validate('mode', opts.mode, 'number', true) vim.fn.mkdir(path, opts.parents and 'p' or '', tostring(opts.mode or 493)) end --- @param path string Path to remove --- @param ty string type of path --- @param recursive? boolean --- @param force? boolean local function rm(path, ty, recursive, force) --- @diagnostic disable-next-line:no-unknown local rm_fn if ty == 'directory' then if recursive then for file, fty in vim.fs.dir(path) do rm(M.joinpath(path, file), fty, true, force) end elseif not force then error(string.format('%s is a directory', path)) end rm_fn = uv.fs_rmdir else rm_fn = uv.fs_unlink end local ret, err, errnm = rm_fn(path) if ret == nil and (not force or errnm ~= 'ENOENT') then error(err) end end --- @class vim.fs.rm.Opts --- @inlinedoc --- --- Remove directory contents recursively. --- @field recursive? boolean --- --- Ignore nonexistent files and arguments. --- @field force? boolean --- Removes a file or directory. --- --- Removes symlinks without touching the origin. To remove the origin, resolve it explicitly --- with |uv.fs_realpath()|: --- ```lua --- vim.fs.rm(vim.uv.fs_realpath('symlink-dir'), { recursive = true }) --- ``` --- --- @since 13 --- @param path string Path to remove (not expanded/resolved). --- @param opts? vim.fs.rm.Opts function M.rm(path, opts) opts = opts or {} local stat, err, errnm = uv.fs_lstat(path) if stat then rm(path, stat.type, opts.recursive, opts.force) elseif not opts.force or errnm ~= 'ENOENT' then error(err) end end --- @class vim.fs.abspath.Opts --- @inlinedoc --- --- Resolve the path relative to this directory. --- @field cwd? string --- --- Do not expand tilde (~). --- @field plain? boolean --- Converts `path` to an absolute path. Expands tilde (~) at the beginning of the path (unless --- plain=true). Does not check if the path exists, normalize the path, resolve symlinks or --- hardlinks (including "." and ".."), or expand environment variables. If the path is already --- absolute, it is returned unchanged. Converts `\` path separators to `/`. --- --- @since 13 --- @param path string Path --- @param opts? vim.fs.abspath.Opts --- @return string Absolute path function M.abspath(path, opts) -- TODO(justinmk): mark f_fnamemodify as API_FAST and use it, ":p:h" should be safe... -- opts = opts or {} vim.validate('path', path, 'string') vim.validate('cwd', opts.cwd, 'string', true) vim.validate('plain', opts.plain, 'boolean', true) -- Expand ~ to user's home directory if not opts.plain then path = expand_home(path) end -- Convert path separator to `/` path = path:gsub(os_sep, '/') local prefix = '' if iswin then prefix, path = split_windows_path(path) end if prefix == '//' or vim.startswith(path, '/') then -- Path is already absolute, do nothing return prefix .. path end -- Windows allows paths like C:foo/bar, these paths are relative to the current working directory -- of the drive specified in the path local cwd = assert((iswin and prefix:match('^%w:$')) and uv.fs_realpath(prefix) or opts.cwd or uv.cwd()) -- Convert cwd path separator to `/` cwd = cwd:gsub(os_sep, '/') if path == '.' then return cwd end -- Prefix is not needed for expanding relative paths, `cwd` already contains it. return M.joinpath(cwd, path) end --- Gets `target` path relative to `base`, or `nil` if `base` is not an ancestor. --- --- Example: --- --- ```lua --- vim.fs.relpath('/var', '/var/lib') -- 'lib' --- vim.fs.relpath('/var', '/usr/bin') -- nil --- ``` --- --- @since 13 --- @param base string --- @param target string --- @param opts table? Reserved for future use --- @return string|nil function M.relpath(base, target, opts) vim.validate('base', base, 'string') vim.validate('target', target, 'string') vim.validate('opts', opts, 'table', true) base = M.normalize(M.abspath(base)) target = M.normalize(M.abspath(target)) if base == target then return '.' end local prefix = '' if iswin then prefix, base = split_windows_path(base) end base = prefix .. base .. (base ~= '/' and '/' or '') return vim.startswith(target, base) and target:sub(#base + 1) or nil end --- Return the file's last extension, if any. --- --- Similar to |fnamemodify()| with the |::e| modifier. The extension does not include a leading --- period. --- --- Examples: --- --- ```lua --- vim.fs.ext('archive.tar.gz') -- 'gz' --- vim.fs.ext('~/.git') -- '' --- vim.fs.ext('plugin/myplug.lua') -- 'lua' --- ``` --- ---@since 14 ---@param file string Path ---@param opts table? Reserved for future use ---@return string Extension of {file} function M.ext(file, opts) vim.validate('file', file, 'string') vim.validate('opts', opts, 'table', true) return vim.fn.fnamemodify(file, ':e') end return M