--- @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'))
--- ```

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
