mirror of
https://github.com/neovim/neovim.git
synced 2026-09-09 15:35:51 +00:00
Problem: LuaLS struggles with the generics used in Nvim's runtime, requiring broad diagnostic suppressions. Indexing is also slow. Solution: Use EmmyLua for type checks in the build and CI. It offers more sophisticated type checking, substantially better support for generics, and much better flow analysis. Correct the affected annotations. Use `@internal`, supported directly by EmmyLua, instead of `@nodoc` for shared internal declarations, and support it in the help parser. AI-assisted
1104 lines
34 KiB
Lua
1104 lines
34 KiB
Lua
--- @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 "~".
|
|
--- - URIs (`scheme://...`) are prefixed with "=uri-{scheme}-".
|
|
--- - 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('term://foo/bar//123:bash')) --> "=uri-term-foo-bar-123-bash-{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? { maxlen?: integer } #
|
|
--- - 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
|
|
|
|
-- Capture URI scheme before normalize() collapses "://" to ":/" (which looks like "C:/")
|
|
local uri_scheme = path:match('^([a-zA-Z][a-zA-Z0-9.+-]*)://') ---@type string?
|
|
|
|
-- Normalize before computing the hash so equivalent paths produce the same result
|
|
path = vim.fs.normalize(path, { plain = true })
|
|
local s = path
|
|
|
|
if uri_scheme then
|
|
-- Replace scheme prefix with "=uri-{scheme}-"
|
|
s = '=uri-' .. uri_scheme .. '-' .. s:sub(#uri_scheme + 3) -- `+ 3` skips`:/`
|
|
elseif vim.startswith(s, '/') or (iswin and s:match('^%w:/')) then
|
|
-- Replace $HOME with `~`
|
|
-- `fnamemodify` resolves relative paths against CWD, so only call it on absolute paths
|
|
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
|