fs.lua 24 KB

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