Files
neovim/runtime/lua/vim/fs.lua
Yochem van Rosmalen 72a63346d8 feat(stdlib): vim.fs.ext() returns file extension #36997
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).
2026-03-20 05:08:00 -04:00

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