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