mirror of
https://github.com/neovim/neovim.git
synced 2026-03-28 19:32:01 +00:00
Problem: Checking the extension of a file is done often, e.g. in Nvim's codebase for differentiating Lua and Vimscript files in the runtime. The current way to do this in Lua is (1) a Lua pattern match, which has pitfalls such as not considering filenames starting with a dot, or (2) fnamemodify() which is both hard to discover and hard to use / read if not very familiar with the possible modifiers. vim.fs.ext() returns the file extension including the leading dot of the extension. Similar to the "file extension" implementation of many other stdlibs (including fnamemodify(file, ":e")), a leading dot doesn't indicate the start of the extension. E.g.: the .git folder in a repository doesn't have the extension .git, but it simply has no extension, similar to a folder named git or any other filename without dot(s).
862 lines
26 KiB
Lua
862 lines
26 KiB
Lua
--- @brief <pre>help
|
|
--- *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.
|
|
---
|
|
--- Example:
|
|
---
|
|
--- >lua
|
|
--- vim.print(vim.fn.readblob('.git/config'))
|
|
--- <
|
|
|
|
local uv = vim.uv
|
|
|
|
local M = {}
|
|
|
|
-- Can't use `has('win32')` because the `nvim -ll` test runner doesn't support `vim.fn` yet.
|
|
local sysname = uv.os_uname().sysname:lower()
|
|
local iswin = not not (sysname:find('windows') or sysname:find('mingw'))
|
|
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')
|
|
if iswin then
|
|
file = file:gsub(os_sep, '/') --[[@as string]]
|
|
if file:match('^%w:/?$') then
|
|
return file
|
|
end
|
|
end
|
|
if not file:match('/') then
|
|
return '.'
|
|
elseif file == '/' or file:match('^/[^/]+$') then
|
|
return '/'
|
|
end
|
|
---@type string
|
|
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
|
|
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')
|
|
if iswin then
|
|
file = file:gsub(os_sep, '/') --[[@as string]]
|
|
if file:match('^%w:/?$') then
|
|
return ''
|
|
end
|
|
end
|
|
return file:match('/$') and '' or (file:match('[^/]*$'))
|
|
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
|
|
|
|
--- @class vim.fs.dir.Opts
|
|
--- @inlinedoc
|
|
---
|
|
--- How deep to traverse.
|
|
--- (default: `1`)
|
|
--- @field depth? integer
|
|
---
|
|
--- 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
|
|
|
|
---@alias Iterator fun(): string?, string?
|
|
|
|
--- Gets an iterator over items found in `path` (normalized via |vim.fs.normalize()|).
|
|
---
|
|
---@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".
|
|
--- "name" is the basename of the item relative to {path}.
|
|
--- "type" is one of the following:
|
|
--- "file", "directory", "link", "fifo", "socket", "char", "block", "unknown".
|
|
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('follow', opts.follow, 'boolean', true)
|
|
|
|
path = M.normalize(path)
|
|
if not opts.depth or opts.depth == 1 then
|
|
local fs = uv.fs_scandir(path)
|
|
return function()
|
|
if not fs then
|
|
return
|
|
end
|
|
return uv.fs_scandir_next(fs)
|
|
end
|
|
end
|
|
|
|
--- @async
|
|
return coroutine.wrap(function()
|
|
local dirs = { { path, 1 } }
|
|
while #dirs > 0 do
|
|
--- @type string, integer
|
|
local dir0, level = 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 = uv.fs_scandir_next(fs)
|
|
if not name then
|
|
break
|
|
end
|
|
local f = level == 1 and name or M.joinpath(dir0, name)
|
|
coroutine.yield(f, t)
|
|
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
|
|
dirs[#dirs + 1] = { f, level + 1 }
|
|
end
|
|
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
|
|
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 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 in M.dir(p) do
|
|
if (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[]
|
|
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
|
|
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 = 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
|
|
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'
|
|
or (type_ == 'link' and opts.follow and (uv.fs_stat(f) or {}).type == 'directory')
|
|
then
|
|
dirs[#dirs + 1] = f
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return matches
|
|
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.
|
|
--- (default: `true`)
|
|
--- @field expand_env? 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. A tilde (~) character at the beginning of the path is
|
|
--- expanded to the user's home directory and environment variables are also expanded. "." and ".."
|
|
--- components are also resolved, except when the path is relative and trying to resolve it would
|
|
--- result in 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, backslash (\) characters 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('expand_env', opts.expand_env, '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
|
|
|
|
-- Expand ~ to user's home directory
|
|
path = expand_home(path, os_sep_local)
|
|
|
|
-- Expand environment variables if `opts.expand_env` isn't `false`
|
|
if opts.expand_env == nil or opts.expand_env then
|
|
path = path:gsub('%$([%w_]+)', uv.os_getenv) --- @type string
|
|
end
|
|
|
|
if win then
|
|
-- Convert path separator to `/`
|
|
path = path:gsub(os_sep_local, '/')
|
|
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
|
|
|
|
--- @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
|
|
|
|
--- Converts `path` to an absolute path. Expands tilde (~) at the beginning of the path
|
|
--- to the user's home directory. 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. Also converts `\` path separators to `/`.
|
|
---
|
|
--- @since 13
|
|
--- @param path string Path
|
|
--- @return string Absolute path
|
|
function M.abspath(path)
|
|
-- TODO(justinmk): mark f_fnamemodify as API_FAST and use it, ":p:h" should be safe...
|
|
|
|
vim.validate('path', path, 'string')
|
|
|
|
-- Expand ~ to user's home directory
|
|
path = expand_home(path)
|
|
|
|
-- 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 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
|