neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

fs.lua (25830B)


      1 --- @brief <pre>help
      2 --- *vim.fs.exists()*
      3 --- Use |uv.fs_stat()| to check a file's type, and whether it exists.
      4 ---
      5 --- Example:
      6 ---
      7 --- >lua
      8 ---   if vim.uv.fs_stat(file) then
      9 ---     vim.print('file exists')
     10 ---   end
     11 --- <
     12 ---
     13 --- *vim.fs.read()*
     14 --- You can use |readblob()| to get a file's contents without explicitly opening/closing it.
     15 ---
     16 --- Example:
     17 ---
     18 --- >lua
     19 ---   vim.print(vim.fn.readblob('.git/config'))
     20 --- <
     21 
     22 local uv = vim.uv
     23 
     24 local M = {}
     25 
     26 -- Can't use `has('win32')` because the `nvim -ll` test runner doesn't support `vim.fn` yet.
     27 local sysname = uv.os_uname().sysname:lower()
     28 local iswin = not not (sysname:find('windows') or sysname:find('mingw'))
     29 local os_sep = iswin and '\\' or '/'
     30 
     31 --- Iterate over all the parents of the given path (not expanded/resolved, the caller must do that).
     32 ---
     33 --- Example:
     34 ---
     35 --- ```lua
     36 --- local root_dir
     37 --- for dir in vim.fs.parents(vim.api.nvim_buf_get_name(0)) do
     38 ---   if vim.fn.isdirectory(dir .. '/.git') == 1 then
     39 ---     root_dir = dir
     40 ---     break
     41 ---   end
     42 --- end
     43 ---
     44 --- if root_dir then
     45 ---   print('Found git repository at', root_dir)
     46 --- end
     47 --- ```
     48 ---
     49 ---@since 10
     50 ---@param start (string) Initial path.
     51 ---@return fun(_, dir: string): string? # Iterator
     52 ---@return nil
     53 ---@return string|nil
     54 function M.parents(start)
     55  return function(_, dir)
     56    local parent = M.dirname(dir)
     57    if parent == dir then
     58      return nil
     59    end
     60 
     61    return parent
     62  end,
     63    nil,
     64    start
     65 end
     66 
     67 --- Gets the parent directory of the given path (not expanded/resolved, the caller must do that).
     68 ---
     69 ---@since 10
     70 ---@generic T : string|nil
     71 ---@param file T Path
     72 ---@return T Parent directory of {file}
     73 function M.dirname(file)
     74  if file == nil then
     75    return nil
     76  end
     77  vim.validate('file', file, 'string')
     78  if iswin then
     79    file = file:gsub(os_sep, '/') --[[@as string]]
     80    if file:match('^%w:/?$') then
     81      return file
     82    end
     83  end
     84  if not file:match('/') then
     85    return '.'
     86  elseif file == '/' or file:match('^/[^/]+$') then
     87    return '/'
     88  end
     89  ---@type string
     90  local dir = file:match('/$') and file:sub(1, #file - 1) or file:match('^(/?.+)/')
     91  if iswin and dir:match('^%w:$') then
     92    return dir .. '/'
     93  end
     94  return dir
     95 end
     96 
     97 --- Gets the basename of the given path (not expanded/resolved).
     98 ---
     99 ---@since 10
    100 ---@generic T : string|nil
    101 ---@param file T Path
    102 ---@return T Basename of {file}
    103 function M.basename(file)
    104  if file == nil then
    105    return nil
    106  end
    107  vim.validate('file', file, 'string')
    108  if iswin then
    109    file = file:gsub(os_sep, '/') --[[@as string]]
    110    if file:match('^%w:/?$') then
    111      return ''
    112    end
    113  end
    114  return file:match('/$') and '' or (file:match('[^/]*$'))
    115 end
    116 
    117 --- Concatenates partial paths (one absolute or relative path followed by zero or more relative
    118 --- paths). Slashes are normalized: redundant slashes are removed, and (on Windows) backslashes are
    119 --- replaced with forward-slashes. Empty segments are removed. Paths are not expanded/resolved.
    120 ---
    121 --- Examples:
    122 --- - "foo/", "/bar" => "foo/bar"
    123 --- - "", "after/plugin" => "after/plugin"
    124 --- - Windows: "a\foo\", "\bar" => "a/foo/bar"
    125 ---
    126 ---@since 12
    127 ---@param ... string
    128 ---@return string
    129 function M.joinpath(...)
    130  local n = select('#', ...)
    131  ---@type string[]
    132  local segments = {}
    133  for i = 1, n do
    134    local s = select(i, ...)
    135    if s and #s > 0 then
    136      segments[#segments + 1] = s
    137    end
    138  end
    139 
    140  local path = table.concat(segments, '/')
    141 
    142  return (path:gsub(iswin and '[/\\][/\\]*' or '//+', '/'))
    143 end
    144 
    145 --- @class vim.fs.dir.Opts
    146 --- @inlinedoc
    147 ---
    148 --- How deep to traverse.
    149 --- (default: `1`)
    150 --- @field depth? integer
    151 ---
    152 --- Predicate to control traversal.
    153 --- Return false to stop searching the current directory.
    154 --- Only useful when depth > 1
    155 --- Return an iterator over the items located in {path}
    156 --- @field skip? (fun(dir_name: string): boolean)
    157 ---
    158 --- Follow symbolic links.
    159 --- (default: `false`)
    160 --- @field follow? boolean
    161 
    162 ---@alias Iterator fun(): string?, string?
    163 
    164 --- Gets an iterator over items found in `path` (normalized via |vim.fs.normalize()|).
    165 ---
    166 ---@since 10
    167 ---@param path (string) Directory to iterate over, normalized via |vim.fs.normalize()|.
    168 ---@param opts? vim.fs.dir.Opts Optional keyword arguments:
    169 ---@return Iterator over items in {path}. Each iteration yields two values: "name" and "type".
    170 ---        "name" is the basename of the item relative to {path}.
    171 ---        "type" is one of the following:
    172 ---        "file", "directory", "link", "fifo", "socket", "char", "block", "unknown".
    173 function M.dir(path, opts)
    174  opts = opts or {}
    175 
    176  vim.validate('path', path, 'string')
    177  vim.validate('depth', opts.depth, 'number', true)
    178  vim.validate('skip', opts.skip, 'function', true)
    179  vim.validate('follow', opts.follow, 'boolean', true)
    180 
    181  path = M.normalize(path)
    182  if not opts.depth or opts.depth == 1 then
    183    local fs = uv.fs_scandir(path)
    184    return function()
    185      if not fs then
    186        return
    187      end
    188      return uv.fs_scandir_next(fs)
    189    end
    190  end
    191 
    192  --- @async
    193  return coroutine.wrap(function()
    194    local dirs = { { path, 1 } }
    195    while #dirs > 0 do
    196      --- @type string, integer
    197      local dir0, level = unpack(table.remove(dirs, 1))
    198      local dir = level == 1 and dir0 or M.joinpath(path, dir0)
    199      local fs = uv.fs_scandir(dir)
    200      while fs do
    201        local name, t = uv.fs_scandir_next(fs)
    202        if not name then
    203          break
    204        end
    205        local f = level == 1 and name or M.joinpath(dir0, name)
    206        coroutine.yield(f, t)
    207        if
    208          opts.depth
    209          and level < opts.depth
    210          and (t == 'directory' or (t == 'link' and opts.follow and (
    211            uv.fs_stat(M.joinpath(path, f)) or {}
    212          ).type == 'directory'))
    213          and (not opts.skip or opts.skip(f) ~= false)
    214        then
    215          dirs[#dirs + 1] = { f, level + 1 }
    216        end
    217      end
    218    end
    219  end)
    220 end
    221 
    222 --- @class vim.fs.find.Opts
    223 --- @inlinedoc
    224 ---
    225 --- Path to begin searching from, defaults to |current-directory|. Not expanded.
    226 --- @field path? string
    227 ---
    228 --- Search upward through parent directories. Otherwise, search child directories (recursively).
    229 --- (default: `false`)
    230 --- @field upward? boolean
    231 ---
    232 --- Stop searching when this directory is reached. The directory itself is not searched.
    233 --- @field stop? string
    234 ---
    235 --- Find only items of the given type. If omitted, all items that match {names} are included.
    236 --- @field type? string
    237 ---
    238 --- Stop searching after this many matches. Use `math.huge` for "unlimited".
    239 --- (default: `1`)
    240 --- @field limit? number
    241 ---
    242 --- Follow symbolic links.
    243 --- (default: `false`)
    244 --- @field follow? boolean
    245 
    246 --- Find files or directories (or other items as specified by `opts.type`) in the given path.
    247 ---
    248 --- Finds items given in {names} starting from {path}. If {upward} is "true"
    249 --- then the search traverses upward through parent directories; otherwise,
    250 --- the search traverses downward. Note that downward searches are recursive
    251 --- and may search through many directories! If {stop} is non-nil, then the
    252 --- search stops when the directory given in {stop} is reached. The search
    253 --- terminates when {limit} (default 1) matches are found. You can set {type}
    254 --- to "file", "directory", "link", "socket", "char", "block", or "fifo"
    255 --- to narrow the search to find only that type.
    256 ---
    257 --- Examples:
    258 ---
    259 --- ```lua
    260 --- -- List all test directories under the runtime directory.
    261 --- local dirs = vim.fs.find(
    262 ---   { 'test', 'tst', 'testdir' },
    263 ---   { limit = math.huge, type = 'directory', path = './runtime/' }
    264 --- )
    265 ---
    266 --- -- Get all "lib/*.cpp" and "lib/*.hpp" files, using Lua patterns.
    267 --- -- Or use `vim.glob.to_lpeg(…):match(…)` for glob/wildcard matching.
    268 --- local files = vim.fs.find(function(name, path)
    269 ---   return name:match('.*%.[ch]pp$') and path:match('[/\\]lib$')
    270 --- end, { limit = math.huge, type = 'file' })
    271 --- ```
    272 ---
    273 ---@since 10
    274 ---@param names (string|string[]|fun(name: string, path: string): boolean) Names of the items to find.
    275 ---             Must be base names, paths and globs are not supported when {names} is a string or a table.
    276 ---             If {names} is a function, it is called for each traversed item with args:
    277 ---             - name: base name of the current item
    278 ---             - path: full path of the current item
    279 ---
    280 ---             The function should return `true` if the given item is considered a match.
    281 ---
    282 ---@param opts? vim.fs.find.Opts Optional keyword arguments:
    283 ---@return (string[]) # Normalized paths |vim.fs.normalize()| of all matching items
    284 function M.find(names, opts)
    285  opts = opts or {}
    286  vim.validate('names', names, { 'string', 'table', 'function' })
    287  vim.validate('path', opts.path, 'string', true)
    288  vim.validate('upward', opts.upward, 'boolean', true)
    289  vim.validate('stop', opts.stop, 'string', true)
    290  vim.validate('type', opts.type, 'string', true)
    291  vim.validate('limit', opts.limit, 'number', true)
    292  vim.validate('follow', opts.follow, 'boolean', true)
    293 
    294  if type(names) == 'string' then
    295    names = { names }
    296  end
    297 
    298  local path = opts.path or assert(uv.cwd())
    299  local stop = opts.stop
    300  local limit = opts.limit or 1
    301 
    302  local matches = {} --- @type string[]
    303 
    304  local function add(match)
    305    matches[#matches + 1] = M.normalize(match)
    306    if #matches == limit then
    307      return true
    308    end
    309  end
    310 
    311  if opts.upward then
    312    local test --- @type fun(p: string): string[]
    313 
    314    if type(names) == 'function' then
    315      test = function(p)
    316        local t = {}
    317        for name, type in M.dir(p) do
    318          if (not opts.type or opts.type == type) and names(name, p) then
    319            table.insert(t, M.joinpath(p, name))
    320          end
    321        end
    322        return t
    323      end
    324    else
    325      test = function(p)
    326        local t = {} --- @type string[]
    327        for _, name in ipairs(names) do
    328          local f = M.joinpath(p, name)
    329          local stat = uv.fs_stat(f)
    330          if stat and (not opts.type or opts.type == stat.type) then
    331            t[#t + 1] = f
    332          end
    333        end
    334 
    335        return t
    336      end
    337    end
    338 
    339    for _, match in ipairs(test(path)) do
    340      if add(match) then
    341        return matches
    342      end
    343    end
    344 
    345    for parent in M.parents(path) do
    346      if stop and parent == stop then
    347        break
    348      end
    349 
    350      for _, match in ipairs(test(parent)) do
    351        if add(match) then
    352          return matches
    353        end
    354      end
    355    end
    356  else
    357    local dirs = { path }
    358    while #dirs > 0 do
    359      local dir = table.remove(dirs, 1)
    360      if stop and dir == stop then
    361        break
    362      end
    363 
    364      for other, type_ in M.dir(dir) do
    365        local f = M.joinpath(dir, other)
    366        if type(names) == 'function' then
    367          if (not opts.type or opts.type == type_) and names(other, dir) then
    368            if add(f) then
    369              return matches
    370            end
    371          end
    372        else
    373          for _, name in ipairs(names) do
    374            if name == other and (not opts.type or opts.type == type_) then
    375              if add(f) then
    376                return matches
    377              end
    378            end
    379          end
    380        end
    381 
    382        if
    383          type_ == 'directory'
    384          or (type_ == 'link' and opts.follow and (uv.fs_stat(f) or {}).type == 'directory')
    385        then
    386          dirs[#dirs + 1] = f
    387        end
    388      end
    389    end
    390  end
    391 
    392  return matches
    393 end
    394 
    395 --- Find the first parent directory containing a specific "marker", relative to a file path or
    396 --- buffer.
    397 ---
    398 --- If the buffer is unnamed (has no backing file) or has a non-empty 'buftype' then the search
    399 --- begins from Nvim's |current-directory|.
    400 ---
    401 --- Examples:
    402 ---
    403 --- ```lua
    404 --- -- Find the root of a Python project, starting from file 'main.py'
    405 --- vim.fs.root(vim.fs.joinpath(vim.env.PWD, 'main.py'), {'pyproject.toml', 'setup.py' })
    406 ---
    407 --- -- Find the root of a git repository
    408 --- vim.fs.root(0, '.git')
    409 ---
    410 --- -- Find the parent directory containing any file with a .csproj extension
    411 --- vim.fs.root(0, function(name, path)
    412 ---   return name:match('%.csproj$') ~= nil
    413 --- end)
    414 ---
    415 --- -- Find the first ancestor directory containing EITHER "stylua.toml" or ".luarc.json"; if
    416 --- -- not found, find the first ancestor containing ".git":
    417 --- vim.fs.root(0, { { 'stylua.toml', '.luarc.json' }, '.git' })
    418 --- ```
    419 ---
    420 --- @since 12
    421 --- @param source integer|string Buffer number (0 for current buffer) or file path (absolute or
    422 ---               relative, expanded via `abspath()`) to begin the search from.
    423 --- @param marker (string|string[]|fun(name: string, path: string): boolean)[]|string|fun(name: string, path: string): boolean
    424 ---               Filename, function, or list thereof, that decides how to find the root. To
    425 ---               indicate "equal priority", specify items in a nested list `{ { 'a.txt', 'b.lua' }, … }`.
    426 ---               A function item must return true if `name` and `path` are a match. Each item
    427 ---               (which may itself be a nested list) is evaluated in-order against all ancestors,
    428 ---               until a match is found.
    429 --- @return string? # Directory path containing one of the given markers, or nil if no directory was
    430 ---                   found.
    431 function M.root(source, marker)
    432  assert(source, 'missing required argument: source')
    433  assert(marker, 'missing required argument: marker')
    434 
    435  local path ---@type string
    436  if type(source) == 'string' then
    437    path = source
    438  elseif type(source) == 'number' then
    439    if vim.bo[source].buftype ~= '' then
    440      path = assert(uv.cwd())
    441    else
    442      path = vim.api.nvim_buf_get_name(source)
    443    end
    444  else
    445    error('invalid type for argument "source": expected string or buffer number')
    446  end
    447 
    448  local markers = type(marker) == 'table' and marker or { marker }
    449  for _, mark in ipairs(markers) do
    450    local paths = M.find(mark, {
    451      upward = true,
    452      path = M.abspath(path),
    453    })
    454 
    455    if #paths ~= 0 then
    456      local dir = M.dirname(paths[1])
    457      return dir and M.abspath(dir) or nil
    458    end
    459  end
    460 
    461  return nil
    462 end
    463 
    464 --- Split a Windows path into a prefix and a body, such that the body can be processed like a POSIX
    465 --- path. The path must use forward slashes as path separator.
    466 ---
    467 --- Does not check if the path is a valid Windows path. Invalid paths will give invalid results.
    468 ---
    469 --- Examples:
    470 --- - `//./C:/foo/bar` -> `//./C:`, `/foo/bar`
    471 --- - `//?/UNC/server/share/foo/bar` -> `//?/UNC/server/share`, `/foo/bar`
    472 --- - `//./system07/C$/foo/bar` -> `//./system07`, `/C$/foo/bar`
    473 --- - `C:/foo/bar` -> `C:`, `/foo/bar`
    474 --- - `C:foo/bar` -> `C:`, `foo/bar`
    475 ---
    476 --- @param path string Path to split.
    477 --- @return string, string, boolean : prefix, body, whether path is invalid.
    478 local function split_windows_path(path)
    479  local prefix = ''
    480 
    481  --- Match pattern. If there is a match, move the matched pattern from the path to the prefix.
    482  --- Returns the matched pattern.
    483  ---
    484  --- @param pattern string Pattern to match.
    485  --- @return string|nil Matched pattern
    486  local function match_to_prefix(pattern)
    487    local match = path:match(pattern)
    488 
    489    if match then
    490      prefix = prefix .. match --[[ @as string ]]
    491      path = path:sub(#match + 1)
    492    end
    493 
    494    return match
    495  end
    496 
    497  local function process_unc_path()
    498    return match_to_prefix('[^/]+/+[^/]+/+')
    499  end
    500 
    501  if match_to_prefix('^//[?.]/') then
    502    -- Device paths
    503    local device = match_to_prefix('[^/]+/+')
    504 
    505    -- Return early if device pattern doesn't match, or if device is UNC and it's not a valid path
    506    if not device or (device:match('^UNC/+$') and not process_unc_path()) then
    507      return prefix, path, false
    508    end
    509  elseif match_to_prefix('^//') then
    510    -- Process UNC path, return early if it's invalid
    511    if not process_unc_path() then
    512      return prefix, path, false
    513    end
    514  elseif path:match('^%w:') then
    515    -- Drive paths
    516    prefix, path = path:sub(1, 2), path:sub(3)
    517  end
    518 
    519  -- If there are slashes at the end of the prefix, move them to the start of the body. This is to
    520  -- ensure that the body is treated as an absolute path. For paths like C:foo/bar, there are no
    521  -- slashes at the end of the prefix, so it will be treated as a relative path, as it should be.
    522  local trailing_slash = prefix:match('/+$')
    523 
    524  if trailing_slash then
    525    prefix = prefix:sub(1, -1 - #trailing_slash)
    526    path = trailing_slash .. path --[[ @as string ]]
    527  end
    528 
    529  return prefix, path, true
    530 end
    531 
    532 --- Resolve `.` and `..` components in a POSIX-style path. This also removes extraneous slashes.
    533 --- `..` is not resolved if the path is relative and resolving it requires the path to be absolute.
    534 --- If a relative path resolves to the current directory, an empty string is returned.
    535 ---
    536 --- @see M.normalize()
    537 --- @param path string Path to resolve.
    538 --- @return string Resolved path.
    539 local function path_resolve_dot(path)
    540  local is_path_absolute = vim.startswith(path, '/')
    541  local new_path_components = {}
    542 
    543  for component in vim.gsplit(path, '/') do
    544    if component == '.' or component == '' then -- luacheck: ignore 542
    545      -- Skip `.` components and empty components
    546    elseif component == '..' then
    547      if #new_path_components > 0 and new_path_components[#new_path_components] ~= '..' then
    548        -- For `..`, remove the last component if we're still inside the current directory, except
    549        -- when the last component is `..` itself
    550        table.remove(new_path_components)
    551      elseif is_path_absolute then -- luacheck: ignore 542
    552        -- Reached the root directory in absolute path, do nothing
    553      else
    554        -- Reached current directory in relative path, add `..` to the path
    555        table.insert(new_path_components, component)
    556      end
    557    else
    558      table.insert(new_path_components, component)
    559    end
    560  end
    561 
    562  return (is_path_absolute and '/' or '') .. table.concat(new_path_components, '/')
    563 end
    564 
    565 --- Expand tilde (~) character at the beginning of the path to the user's home directory.
    566 ---
    567 --- @param path string Path to expand.
    568 --- @param sep string|nil Path separator to use. Uses os_sep by default.
    569 --- @return string Expanded path.
    570 local function expand_home(path, sep)
    571  sep = sep or os_sep
    572 
    573  if vim.startswith(path, '~') then
    574    local home = uv.os_homedir() or '~' --- @type string
    575 
    576    if home:sub(-1) == sep then
    577      home = home:sub(1, -2)
    578    end
    579 
    580    path = home .. path:sub(2) --- @type string
    581  end
    582 
    583  return path
    584 end
    585 
    586 --- @class vim.fs.normalize.Opts
    587 --- @inlinedoc
    588 ---
    589 --- Expand environment variables.
    590 --- (default: `true`)
    591 --- @field expand_env? boolean
    592 ---
    593 --- @field package _fast? boolean
    594 ---
    595 --- Path is a Windows path.
    596 --- (default: `true` in Windows, `false` otherwise)
    597 --- @field win? boolean
    598 
    599 --- Normalize a path to a standard format. A tilde (~) character at the beginning of the path is
    600 --- expanded to the user's home directory and environment variables are also expanded. "." and ".."
    601 --- components are also resolved, except when the path is relative and trying to resolve it would
    602 --- result in an absolute path.
    603 --- - "." as the only part in a relative path:
    604 ---   - "." => "."
    605 ---   - "././" => "."
    606 --- - ".." when it leads outside the current directory
    607 ---   - "foo/../../bar" => "../bar"
    608 ---   - "../../foo" => "../../foo"
    609 --- - ".." in the root directory returns the root directory.
    610 ---   - "/../../" => "/"
    611 ---
    612 --- On Windows, backslash (\) characters are converted to forward slashes (/).
    613 ---
    614 --- Examples:
    615 --- ```lua
    616 --- [[C:\Users\jdoe]]                         => "C:/Users/jdoe"
    617 --- "~/src/neovim"                            => "/home/jdoe/src/neovim"
    618 --- "$XDG_CONFIG_HOME/nvim/init.vim"          => "/Users/jdoe/.config/nvim/init.vim"
    619 --- "~/src/nvim/api/../tui/./tui.c"           => "/home/jdoe/src/nvim/tui/tui.c"
    620 --- "./foo/bar"                               => "foo/bar"
    621 --- "foo/../../../bar"                        => "../../bar"
    622 --- "/home/jdoe/../../../bar"                 => "/bar"
    623 --- "C:foo/../../baz"                         => "C:../baz"
    624 --- "C:/foo/../../baz"                        => "C:/baz"
    625 --- [[\\?\UNC\server\share\foo\..\..\..\bar]] => "//?/UNC/server/share/bar"
    626 --- ```
    627 ---
    628 ---@since 10
    629 ---@param path (string) Path to normalize
    630 ---@param opts? vim.fs.normalize.Opts
    631 ---@return (string) : Normalized path
    632 function M.normalize(path, opts)
    633  opts = opts or {}
    634 
    635  if not opts._fast then
    636    vim.validate('path', path, 'string')
    637    vim.validate('expand_env', opts.expand_env, 'boolean', true)
    638    vim.validate('win', opts.win, 'boolean', true)
    639  end
    640 
    641  local win = opts.win == nil and iswin or not not opts.win
    642  local os_sep_local = win and '\\' or '/'
    643 
    644  -- Empty path is already normalized
    645  if path == '' then
    646    return ''
    647  end
    648 
    649  -- Expand ~ to user's home directory
    650  path = expand_home(path, os_sep_local)
    651 
    652  -- Expand environment variables if `opts.expand_env` isn't `false`
    653  if opts.expand_env == nil or opts.expand_env then
    654    path = path:gsub('%$([%w_]+)', uv.os_getenv) --- @type string
    655  end
    656 
    657  if win then
    658    -- Convert path separator to `/`
    659    path = path:gsub(os_sep_local, '/')
    660  end
    661 
    662  -- Check for double slashes at the start of the path because they have special meaning
    663  local double_slash = false
    664  if not opts._fast then
    665    double_slash = vim.startswith(path, '//') and not vim.startswith(path, '///')
    666  end
    667 
    668  local prefix = ''
    669 
    670  if win then
    671    local is_valid --- @type boolean
    672    -- Split Windows paths into prefix and body to make processing easier
    673    prefix, path, is_valid = split_windows_path(path)
    674 
    675    -- If path is not valid, return it as-is
    676    if not is_valid then
    677      return prefix .. path
    678    end
    679 
    680    -- Ensure capital drive and remove extraneous slashes from the prefix
    681    prefix = prefix:gsub('^%a:', string.upper):gsub('/+', '/')
    682  end
    683 
    684  if not opts._fast then
    685    -- Resolve `.` and `..` components and remove extraneous slashes from path, then recombine prefix
    686    -- and path.
    687    path = path_resolve_dot(path)
    688  end
    689 
    690  -- Preserve leading double slashes as they indicate UNC paths and DOS device paths in
    691  -- Windows and have implementation-defined behavior in POSIX.
    692  path = (double_slash and '/' or '') .. prefix .. path
    693 
    694  -- Change empty path to `.`
    695  if path == '' then
    696    path = '.'
    697  end
    698 
    699  return path
    700 end
    701 
    702 --- @param path string Path to remove
    703 --- @param ty string type of path
    704 --- @param recursive? boolean
    705 --- @param force? boolean
    706 local function rm(path, ty, recursive, force)
    707  --- @diagnostic disable-next-line:no-unknown
    708  local rm_fn
    709 
    710  if ty == 'directory' then
    711    if recursive then
    712      for file, fty in vim.fs.dir(path) do
    713        rm(M.joinpath(path, file), fty, true, force)
    714      end
    715    elseif not force then
    716      error(string.format('%s is a directory', path))
    717    end
    718 
    719    rm_fn = uv.fs_rmdir
    720  else
    721    rm_fn = uv.fs_unlink
    722  end
    723 
    724  local ret, err, errnm = rm_fn(path)
    725  if ret == nil and (not force or errnm ~= 'ENOENT') then
    726    error(err)
    727  end
    728 end
    729 
    730 --- @class vim.fs.rm.Opts
    731 --- @inlinedoc
    732 ---
    733 --- Remove directory contents recursively.
    734 --- @field recursive? boolean
    735 ---
    736 --- Ignore nonexistent files and arguments.
    737 --- @field force? boolean
    738 
    739 --- Removes a file or directory.
    740 ---
    741 --- Removes symlinks without touching the origin. To remove the origin, resolve it explicitly
    742 --- with |uv.fs_realpath()|:
    743 --- ```lua
    744 --- vim.fs.rm(vim.uv.fs_realpath('symlink-dir'), { recursive = true })
    745 --- ```
    746 ---
    747 --- @since 13
    748 --- @param path string Path to remove (not expanded/resolved).
    749 --- @param opts? vim.fs.rm.Opts
    750 function M.rm(path, opts)
    751  opts = opts or {}
    752 
    753  local stat, err, errnm = uv.fs_lstat(path)
    754  if stat then
    755    rm(path, stat.type, opts.recursive, opts.force)
    756  elseif not opts.force or errnm ~= 'ENOENT' then
    757    error(err)
    758  end
    759 end
    760 
    761 --- Converts `path` to an absolute path. Expands tilde (~) at the beginning of the path
    762 --- to the user's home directory. Does not check if the path exists, normalize the path, resolve
    763 --- symlinks or hardlinks (including `.` and `..`), or expand environment variables. If the path is
    764 --- already absolute, it is returned unchanged. Also converts `\` path separators to `/`.
    765 ---
    766 --- @since 13
    767 --- @param path string Path
    768 --- @return string Absolute path
    769 function M.abspath(path)
    770  -- TODO(justinmk): mark f_fnamemodify as API_FAST and use it, ":p:h" should be safe...
    771 
    772  vim.validate('path', path, 'string')
    773 
    774  -- Expand ~ to user's home directory
    775  path = expand_home(path)
    776 
    777  -- Convert path separator to `/`
    778  path = path:gsub(os_sep, '/')
    779 
    780  local prefix = ''
    781 
    782  if iswin then
    783    prefix, path = split_windows_path(path)
    784  end
    785 
    786  if prefix == '//' or vim.startswith(path, '/') then
    787    -- Path is already absolute, do nothing
    788    return prefix .. path
    789  end
    790 
    791  -- Windows allows paths like C:foo/bar, these paths are relative to the current working directory
    792  -- of the drive specified in the path
    793  local cwd = (iswin and prefix:match('^%w:$')) and uv.fs_realpath(prefix) or uv.cwd()
    794  assert(cwd ~= nil)
    795  -- Convert cwd path separator to `/`
    796  cwd = cwd:gsub(os_sep, '/')
    797 
    798  if path == '.' then
    799    return cwd
    800  end
    801  -- Prefix is not needed for expanding relative paths, `cwd` already contains it.
    802  return M.joinpath(cwd, path)
    803 end
    804 
    805 --- Gets `target` path relative to `base`, or `nil` if `base` is not an ancestor.
    806 ---
    807 --- Example:
    808 ---
    809 --- ```lua
    810 --- vim.fs.relpath('/var', '/var/lib') -- 'lib'
    811 --- vim.fs.relpath('/var', '/usr/bin') -- nil
    812 --- ```
    813 ---
    814 --- @since 13
    815 --- @param base string
    816 --- @param target string
    817 --- @param opts table? Reserved for future use
    818 --- @return string|nil
    819 function M.relpath(base, target, opts)
    820  vim.validate('base', base, 'string')
    821  vim.validate('target', target, 'string')
    822  vim.validate('opts', opts, 'table', true)
    823 
    824  base = M.normalize(M.abspath(base))
    825  target = M.normalize(M.abspath(target))
    826  if base == target then
    827    return '.'
    828  end
    829 
    830  local prefix = ''
    831  if iswin then
    832    prefix, base = split_windows_path(base)
    833  end
    834  base = prefix .. base .. (base ~= '/' and '/' or '')
    835 
    836  return vim.startswith(target, base) and target:sub(#base + 1) or nil
    837 end
    838 
    839 return M