From 8c23603740073c7d4687a63015c532ff188b1298 Mon Sep 17 00:00:00 2001 From: James Trew Date: Wed, 21 Aug 2024 23:07:55 -0400 Subject: [PATCH 01/43] initial Path:new --- lua/plenary/path2.lua | 508 +++++++++++++++++++ tests/plenary/path2_spec.lua | 923 +++++++++++++++++++++++++++++++++++ 2 files changed, 1431 insertions(+) create mode 100644 lua/plenary/path2.lua create mode 100644 tests/plenary/path2_spec.lua diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua new file mode 100644 index 00000000..fe776f59 --- /dev/null +++ b/lua/plenary/path2.lua @@ -0,0 +1,508 @@ +--[[ +- [x] path +- [x] path.home +- [x] path.sep +- [x] path.root +- [x] path.S_IF + - [ ] band + - [ ] concat_paths + - [ ] is_root + - [ ] _split_by_separator + - [ ] is_uri + - [ ] is_absolute + - [ ] _normalize_path + - [ ] clean +- [x] Path +- [ ] check_self +- [x] Path.__index +- [x] Path.__div +- [x] Path.__tostring +- [x] Path.__concat +- [x] Path.is_path +- [x] Path:new +- [ ] Path:_fs_filename +- [ ] Path:_stat +- [ ] Path:_st_mode +- [ ] Path:joinpath +- [ ] Path:absolute +- [ ] Path:exists +- [ ] Path:expand +- [ ] Path:make_relative +- [ ] Path:normalize +- [ ] shorten_len +- [ ] shorten +- [ ] Path:shorten +- [ ] Path:mkdir +- [ ] Path:rmdir +- [ ] Path:rename +- [ ] Path:copy +- [ ] Path:touch +- [ ] Path:rm +- [ ] Path:is_dir +- [ ] Path:is_absolute +- [ ] Path:_split + - [ ] _get_parent +- [ ] Path:parent +- [ ] Path:parents +- [ ] Path:is_file +- [ ] Path:open +- [ ] Path:close +- [ ] Path:write +- [ ] Path:_read +- [ ] Path:_read_async +- [ ] Path:read +- [ ] Path:head +- [ ] Path:tail +- [ ] Path:readlines +- [ ] Path:iter +- [ ] Path:readbyterange +-[ ] Path:find_upwards +]] + +local uv = vim.loop + +local iswin = uv.os_uname().sysname == "Windows_NT" +local hasshellslash = vim.fn.exists "+shellslash" == 1 + +local S_IF = { + -- S_IFDIR = 0o040000 # directory + DIR = 0x4000, + -- S_IFREG = 0o100000 # regular file + REG = 0x8000, +} + +---@class plenary.path +---@field home? string home directory path +---@field sep string OS path separator +---@field root fun():string root directory path +---@field S_IF { DIR: integer, REG: integer } stat filetype bitmask +local path = setmetatable({ + home = vim.loop.os_homedir(), + S_IF = S_IF, +}, { + __index = function(t, k) + local raw = rawget(t, k) + if raw then + return raw + end + + if not iswin then + t.sep = "/" + return t.sep + end + + return (hasshellslash and vim.o.shellslash) and "/" or "\\" + end, +}) + +path.root = (function() + if path.sep == "/" then + return function() + return "/" + end + else + return function(base) + base = base or vim.loop.cwd() + return base:sub(1, 1) .. ":\\" + end + end +end)() + +local function is_uri(filename) + local char = string.byte(filename, 1) or 0 + + -- is alpha? + if char < 65 or (char > 90 and char < 97) or char > 122 then + return false + end + + for i = 2, #filename do + char = string.byte(filename, i) + if char == 58 then -- `:` + return i < #filename and string.byte(filename, i + 1) ~= 92 -- `\` + elseif + not ( + (char >= 48 and char <= 57) -- 0-9 + or (char >= 65 and char <= 90) -- A-Z + or (char >= 97 and char <= 122) -- a-z + or char == 43 -- `+` + or char == 46 -- `.` + or char == 45 -- `-` + ) + then + return false + end + end + return false +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 p string Path to split. +--- @return string, string, boolean : prefix, body, whether path is invalid. +local function split_windows_p(p) + local prefix = "" + + --- Match pattern. If there is a match, move the matched pattern from the p 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 = p:match(pattern) + + if match then + prefix = prefix .. match --[[ @as string ]] + p = p:sub(#match + 1) + end + + return match + end + + local function process_unc_path() + return match_to_prefix "[^\\]+\\+[^\\]+\\+" + end + + if match_to_prefix "^\\\\[?.]\\" then + -- Device ps + local device = match_to_prefix "[^\\]+\\+" + + -- Return early if device pattern doesn't match, or if device is UNC and it's not a valid p + if not device or (device:match "^UNC\\+$" and not process_unc_path()) then + return prefix, p, false + end + elseif match_to_prefix "^\\\\" then + -- Process UNC p, return early if it's invalid + if not process_unc_path() then + return prefix, p, false + end + elseif p:match "^%w:" then + -- Drive ps + prefix, p = p:sub(1, 2), p: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 p. For ps like C:foo\\bar, there are no + -- slashes at the end of the prefix, so it will be treated as a relative p, as it should be. + local trailing_slash = prefix:match "\\+$" + + if trailing_slash then + prefix = prefix:sub(1, -1 - #trailing_slash) + p = trailing_slash .. p --[[ @as string ]] + end + + return prefix, p, 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 p string Path to resolve. +---@return string # Resolved path. +local function path_resolve_dot(p) + local is_path_absolute = vim.startswith(p, "/") + local new_path_components = {} + + for component in vim.gsplit(p, "/") 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 + +---@param p string path +---@return string +local function normalize_path(p) + if p == "" or is_uri(p) then + return p + end + + if iswin then + p = p:gsub("\\", "/") + end + + local double_slash = vim.startswith(p, "//") and not vim.startswith(p, "///") + local prefix = "" + + if iswin then + local valid + prefix, p, valid = split_windows_p(p) + if not valid then + return prefix .. p + end + prefix = prefix:gsub("/+", "/") + end + + p = path_resolve_dot(p) + p = (double_slash and "/" or "") .. prefix .. p + + if p == "" then + p = "." + end + + return p +end + +---@class plenary.Path +---@field path plenary.path +---@field filename string path as a string +---@field private _filename string +---@field private _sep string path separator +---@field private _absolute string absolute path +---@field private _cwd string cwd path +local Path = { + path = path, +} + +Path.__index = function(t, k) + local raw = rawget(Path, k) + if raw then + return raw + end + + if k == "_cwd" then + local cwd = uv.fs_realpath "." + t._cwd = cwd + return cwd + end + + if k == "_absolute" then + local absolute = uv.fs_realpath(t.filename) + t._absolute = absolute + return absolute + end +end + +Path.__newindex = function(t, k, value) + if k == "filename" then + error "'filename' field is immutable" + end + return rawset(t, k, value) +end + +Path.__div = function(self, other) + assert(Path.is_path(self)) + assert(Path.is_path(other) or type(other) == "string") + + return self:joinpath(other) +end + +Path.__tostring = function(self) + return clean(self.filename) +end + +-- TODO: See where we concat the table, and maybe we could make this work. +Path.__concat = function(self, other) + return self.filename .. other +end + +Path.is_path = function(a) + return getmetatable(a) == Path +end + +---@param parts string[] +---@param sep string +---@return string +local function unix_path_str(parts, sep) + -- any sep other than `/` is not a valid sep but allowing for backwards compat reasons + local flat_parts = {} + for _, part in ipairs(parts) do + vim.list_extend(flat_parts, vim.split(part, sep)) + end + + return (table.concat(flat_parts, sep):gsub(sep .. "+", sep)) +end + +---@param parts string[] +---@param sep string +---@return string +local function windows_path_str(parts, sep) + local disk = parts[1]:match "^[%a]:" + local is_disk_root = parts[1]:match "^[%a]:[\\/]" ~= nil + local is_unc = parts[1]:match "^\\\\" or parts[1]:match "^//" + + local flat_parts = {} + for _, part in ipairs(parts) do + vim.list_extend(flat_parts, vim.split(part, "[\\/]")) + end + + if not is_disk_root and flat_parts[1] == disk then + table.remove(flat_parts, 1) + local p = disk .. table.concat(flat_parts, sep) + return (p:gsub(sep .. "+", sep)) + end + if is_unc then + table.remove(flat_parts, 1) + table.remove(flat_parts, 1) + local body = (table.concat(flat_parts, sep):gsub(sep .. "+", sep)) + return sep .. sep .. body + end + return (table.concat(flat_parts, sep):gsub(sep .. "+", sep)) +end + +---@return plenary.Path +function Path:new(...) + local args = { ... } + + if type(self) == "string" then + table.insert(args, 1, self) + self = Path + end + + local path_input + if #args == 1 then + if Path.is_path(args[1]) then + local p = args[1] ---@cast p plenary.Path + return p + end + if type(args[1]) == "table" then + path_input = args[1] + else + assert(type(args[1]) == "string", "unexpected path input\n" .. vim.inspect(path_input)) + path_input = args + end + else + path_input = args + end + + assert(type(path_input) == "table", vim.inspect(path_input)) + ---@cast path_input {[integer]: (string)|plenary.Path, sep: string?} + + local sep = path.sep + sep = path_input.sep or path.sep + path_input.sep = nil + path_input = vim.tbl_map(function(part) + if Path.is_path(part) then + return part.filename + else + assert(type(part) == "string", vim.inspect(path_input)) + return vim.trim(part) + end + end, path_input) + + assert(#path_input > 0, "can't create Path out of nothing") + + local path_string + if iswin then + path_string = windows_path_str(path_input, sep) + else + path_string = unix_path_str(path_input, sep) + end + + -- if type(path_input) == "string" then + -- if iswin then + -- if path_input:match "^[%a]:[\\/].*$" then + -- end + -- path_input = vim.split(path_input, "[\\/]") + -- else + -- path_input = vim.split(path_input, sep) + -- end + -- end + + -- if type(path_input) == "table" then + -- local path_objs = {} + -- for _, v in ipairs(path_input) do + -- if Path.is_path(v) then + -- table.insert(path_objs, v.filename) + -- else + -- assert(type(v) == "string") + -- table.insert(path_objs, v) + -- end + -- end + + -- if iswin and path_objs[1]:match "^[%a]:$" then + -- local disk = path_objs[1] + -- table.remove(path_objs, 1) + -- path_string = disk .. table.concat(path_objs, sep) + -- else + -- path_string = table.concat(path_objs, sep) + -- end + -- else + -- error("unexpected path input\n" .. vim.inspect(path_input)) + -- end + + local obj = { + -- precompute normalized path using `/` as sep + _filename = normalize_path(path_string), + filename = path_string, + _sep = sep, + } + + setmetatable(obj, Path) + + return obj +end + +--- For POSIX path, anything starting with a `/` is considered a absolute path. +--- +--- +--- For Windows, it's a little more involved. +--- +--- Disk names are single letters. They MUST be followed by a separator to be +--- considered an absolute path. eg. +--- C:\Documents\Newsletters\Summer2018.pdf -> An absolute file path from the root of drive C:. + +--- UNC paths are also considered absolute. eg. \\Server2\Share\Test\Foo.txt +--- +--- Any other valid paths are relative. eg. +--- C:Projects\apilibrary\apilibrary.sln -> A relative path from the current directory of the C: drive. +--- 2018\January.xlsx -> A relative path to a file in a subdirectory of the current directory. +--- \Program Files\Custom Utilities\StringFinder.exe -> A relative path from the root of the current drive. +--- ..\Publications\TravelBrochure.pdf -> A relative path to a file in a directory starting from the current directory. +---@return boolean +function Path:is_absolute() + if not iswin then + return string.sub(self._filename, 1, 1) == "/" + end + + if string.match(self._filename, "^[%a]:/.*$") ~= nil then + return true + elseif string.match(self._filename, "^//") then + return true + end + + return false +end + +---@return string +function Path:absolute() + if self:is_absolute() then + return self.filename + end + return (normalize_path(self._cwd .. self._sep .. self._filename):gsub("/", self._sep)) +end + +vim.o.shellslash = false +-- -- local p = Path:new { "C:", "README.md" } +local p = Path:new { "C:\\Documents\\Newsletters\\Summer2018.pdf" } +print(p.filename, p:is_absolute(), p:absolute()) +vim.o.shellslash = true + +return Path diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua new file mode 100644 index 00000000..25a9f6a8 --- /dev/null +++ b/tests/plenary/path2_spec.lua @@ -0,0 +1,923 @@ +local Path = require "plenary.path2" +local path = Path.path +local compat = require "plenary.compat" +local iswin = vim.loop.os_uname().sysname == "Windows_NT" + +describe("absolute", function() + describe("unix", function() + if iswin then + return + end + end) + + describe("windows", function() + if not iswin then + return + end + + describe("shellslash", function() + vim.o.shellslash = true + end) + + describe("noshellslash", function() + vim.o.shellslash = false + end) + end) +end) + +describe("Path", function() + describe("filename", function() + local function get_paths() + local readme_path = vim.fn.fnamemodify("README.md", ":p") + + ---@type [string[]|string, string][] + local paths = { + { "README.md", "README.md" }, + { { "README.md" }, "README.md" }, + { { "lua", "..", "README.md" }, "lua/../README.md" }, + { { "lua/../README.md" }, "lua/../README.md" }, + { { "./lua/../README.md" }, "./lua/../README.md" }, + { "./lua//..//README.md", "./lua/../README.md" }, + { { readme_path }, readme_path }, + } + + return paths + end + + local function test_filename(test_cases) + for _, tc in ipairs(test_cases) do + local input, expect = tc[1], tc[2] + it(vim.inspect(input), function() + local p = Path:new(input) + assert.are.same(expect, p.filename) + end) + end + end + + describe("unix", function() + if iswin then + return + end + test_filename(get_paths()) + end) + + describe("windows", function() + if not iswin then + return + end + + local function get_windows_paths() + local nossl = vim.fn.exists "+shellslash" == 1 and not vim.o.shellslash + + ---@type [string[]|string, string][] + local paths = { + { [[C:\Documents\Newsletters\Summer2018.pdf]], [[C:/Documents/Newsletters/Summer2018.pdf]] }, + { [[C:\\Documents\\Newsletters\Summer2018.pdf]], [[C:/Documents/Newsletters/Summer2018.pdf]] }, + { { [[C:\Documents\Newsletters\Summer2018.pdf]] }, [[C:/Documents/Newsletters/Summer2018.pdf]] }, + { { [[C:/Documents/Newsletters/Summer2018.pdf]] }, [[C:/Documents/Newsletters/Summer2018.pdf]] }, + { { [[\\Server2\Share\Test\Foo.txt]] }, [[//Server2/Share/Test/Foo.txt]] }, + { { [[//Server2/Share/Test/Foo.txt]] }, [[//Server2/Share/Test/Foo.txt]] }, + { [[//Server2//Share//Test/Foo.txt]], [[//Server2/Share/Test/Foo.txt]] }, + { [[\\Server2\\Share\\Test\Foo.txt]], [[//Server2/Share/Test/Foo.txt]] }, + { { "C:", "lua", "..", "README.md" }, "C:lua/../README.md" }, + { [[foo/bar\baz]], [[foo/bar/baz]] }, + -- TODO: add mixed sep paths + -- whatever these things are + -- \\.\C:\Test\Foo.txt + -- \\?\C:\Test\Foo.txt + } + vim.list_extend(paths, get_paths()) + + if nossl then + paths = vim.tbl_map(function(tc) + return { tc[1], (tc[2]:gsub("/", "\\")) } + end, paths) + end + + return paths + end + + describe("noshellslash", function() + vim.o.shellslash = false + test_filename(get_windows_paths()) + end) + + describe("shellslash", function() + vim.o.shellslash = true + test_filename(get_windows_paths()) + end) + end) + end) + + describe("absolute", function() + local function get_paths() + local readme_path = vim.fn.fnamemodify("README.md", ":p") + + ---@type [string[], string, boolean][] + local paths = { + { { "README.md" }, readme_path, false }, + { { "lua", "..", "README.md" }, readme_path, false }, + { { readme_path }, readme_path, true }, + } + return paths + end + + local function test_absolute(test_cases) + for _, tc in ipairs(test_cases) do + local input, expect, is_absolute = tc[1], tc[2], tc[3] + it(vim.inspect(input), function() + local p = Path:new(input) + assert.are.same(expect, p:absolute()) + assert.are.same(is_absolute, p:is_absolute()) + end) + end + end + + describe("unix", function() + if iswin then + return + end + test_absolute(get_paths()) + end) + + describe("windows", function() + if not iswin then + return + end + + local function get_windows_paths() + local nossl = vim.fn.exists "+shellslash" == 1 and not vim.o.shellslash + + ---@type [string[], string, boolean][] + local paths = { + { { [[C:\Documents\Newsletters\Summer2018.pdf]] }, [[C:/Documents/Newsletters/Summer2018.pdf]], true }, + { { [[C:/Documents/Newsletters/Summer2018.pdf]] }, [[C:/Documents/Newsletters/Summer2018.pdf]], true }, + { { [[\\Server2\Share\Test\Foo.txt]] }, [[//Server2/Share/Test/Foo.txt]], true }, + { { [[//Server2/Share/Test/Foo.txt]] }, [[//Server2/Share/Test/Foo.txt]], true }, + } + vim.list_extend(paths, get_paths()) + + if nossl then + paths = vim.tbl_map(function(tc) + return { tc[1], (tc[2]:gsub("/", "\\")), tc[3] } + end, paths) + end + + return paths + end + + describe("shellslash", function() + vim.o.shellslash = true + test_absolute(get_windows_paths()) + end) + + describe("noshellslash", function() + vim.o.shellslash = false + test_absolute(get_windows_paths()) + end) + end) + end) + + -- describe("absolute", function() + -- local readme_path = vim.fn.fnamemodify("README.md", ":p") + -- local posix_paths = { + -- { { "README.md" }, readme_path }, + -- { { "lua", "..", "README.md" }, readme_path }, + -- { { "lua/../README.md" }, readme_path }, + -- { { "./lua/../README.md" }, readme_path }, + -- { { readme_path }, readme_path }, + -- } + + -- local windows_paths = { + -- { { [[C:\Documents\Newsletters\Summer2018.pdf]] }, [[C:\Documents\Newsletters\Summer2018.pdf]] }, + -- { { [[C:/Documents/Newsletters/Summer2018.pdf]] }, [[C:\Documents\Newsletters\Summer2018.pdf]] }, + -- { { [[\\Server2\Share\Test\Foo.txt]] }, [[\\Server2\Share\Test\Foo.txt]] }, + -- { { [[//Server2/Share/Test/Foo.txt]] }, [[\\Server2\Share\Test\Foo.txt]] }, + -- } + -- vim.list_extend(windows_paths, posix_paths) + + -- local function test_absolute(input, expect) + -- it(string.format(":absolute() %s", vim.inspect(input)), function() + -- local p = Path:new(input) + -- assert.are.same(p:absolute(), expect) + -- end) + -- end + + -- local test_path = iswin and windows_paths or posix_paths + + -- for _, tc in ipairs(test_path) do + -- test_absolute(tc[1], tc[2]) + -- end + + -- it(".absolute()", function() + -- local p = Path:new { "README.md", sep = "\\" } + -- assert.are.same(p:absolute(), vim.fn.fnamemodify("README.md", ":p")) + -- end) + + -- it("can determine absolute paths", function() + -- local p = Path:new { "/home/asdfasdf/", sep = "/" } + -- assert(p:is_absolute(), "Is absolute") + -- assert(p:absolute() == p.filename) + -- end) + + -- it("can determine non absolute paths", function() + -- local p = Path:new { "./home/tj/", sep = "/" } + -- assert(not p:is_absolute(), "Is absolute") + -- end) + + -- it("will normalize the path", function() + -- local p = Path:new { "lua", "..", "README.md", sep = "/" } + -- assert.are.same(p:absolute(), vim.fn.fnamemodify("README.md", ":p")) + -- end) + -- end) + + -- it("can join paths by constructor or join path", function() + -- assert.are.same(Path:new("lua", "plenary"), Path:new("lua"):joinpath "plenary") + -- end) + + -- it("can join paths with /", function() + -- assert.are.same(Path:new("lua", "plenary"), Path:new "lua" / "plenary") + -- end) + + it("can join paths with paths", function() + assert.are.same(Path:new("lua", "plenary"), Path:new("lua", Path:new "plenary")) + end) + + it("inserts slashes", function() + assert.are.same("lua" .. path.sep .. "plenary", Path:new("lua", "plenary").filename) + end) + + -- describe(".exists()", function() + -- it("finds files that exist", function() + -- assert.are.same(true, Path:new("README.md"):exists()) + -- end) + + -- it("returns false for files that do not exist", function() + -- assert.are.same(false, Path:new("asdf.md"):exists()) + -- end) + -- end) + + -- describe(".is_dir()", function() + -- it("should find directories that exist", function() + -- assert.are.same(true, Path:new("lua"):is_dir()) + -- end) + + -- it("should return false when the directory does not exist", function() + -- assert.are.same(false, Path:new("asdf"):is_dir()) + -- end) + + -- it("should not show files as directories", function() + -- assert.are.same(false, Path:new("README.md"):is_dir()) + -- end) + -- end) + + -- describe(".is_file()", function() + -- it("should not allow directories", function() + -- assert.are.same(true, not Path:new("lua"):is_file()) + -- end) + + -- it("should return false when the file does not exist", function() + -- assert.are.same(true, not Path:new("asdf"):is_file()) + -- end) + + -- it("should show files as file", function() + -- assert.are.same(true, Path:new("README.md"):is_file()) + -- end) + -- end) + + describe(":new", function() + it("can be called with or without colon", function() + -- This will work, cause we used a colon + local with_colon = Path:new "lua" + local no_colon = Path.new "lua" + + assert.are.same(with_colon, no_colon) + end) + end) + + -- describe(":make_relative", function() + -- it("can take absolute paths and make them relative to the cwd", function() + -- local p = Path:new { "lua", "plenary", "path.lua" } + -- local absolute = vim.loop.cwd() .. path.sep .. p.filename + -- local relative = Path:new(absolute):make_relative() + -- assert.are.same(relative, p.filename) + -- end) + + -- it("can take absolute paths and make them relative to a given path", function() + -- local root = path.sep == "\\" and "c:\\" or "/" + -- local r = Path:new { root, "home", "prime" } + -- local p = Path:new { "aoeu", "agen.lua" } + -- local absolute = r.filename .. path.sep .. p.filename + -- local relative = Path:new(absolute):make_relative(r.filename) + -- assert.are.same(relative, p.filename) + -- end) + + -- it("can take double separator absolute paths and make them relative to the cwd", function() + -- local p = Path:new { "lua", "plenary", "path.lua" } + -- local absolute = vim.loop.cwd() .. path.sep .. path.sep .. p.filename + -- local relative = Path:new(absolute):make_relative() + -- assert.are.same(relative, p.filename) + -- end) + + -- it("can take double separator absolute paths and make them relative to a given path", function() + -- local root = path.sep == "\\" and "c:\\" or "/" + -- local r = Path:new { root, "home", "prime" } + -- local p = Path:new { "aoeu", "agen.lua" } + -- local absolute = r.filename .. path.sep .. path.sep .. p.filename + -- local relative = Path:new(absolute):make_relative(r.filename) + -- assert.are.same(relative, p.filename) + -- end) + + -- it("can take absolute paths and make them relative to a given path with trailing separator", function() + -- local root = path.sep == "\\" and "c:\\" or "/" + -- local r = Path:new { root, "home", "prime" } + -- local p = Path:new { "aoeu", "agen.lua" } + -- local absolute = r.filename .. path.sep .. p.filename + -- local relative = Path:new(absolute):make_relative(r.filename .. path.sep) + -- assert.are.same(relative, p.filename) + -- end) + + -- it("can take absolute paths and make them relative to the root directory", function() + -- local root = path.sep == "\\" and "c:\\" or "/" + -- local p = Path:new { "home", "prime", "aoeu", "agen.lua" } + -- local absolute = root .. p.filename + -- local relative = Path:new(absolute):make_relative(root) + -- assert.are.same(relative, p.filename) + -- end) + + -- it("can take absolute paths and make them relative to themselves", function() + -- local root = path.sep == "\\" and "c:\\" or "/" + -- local p = Path:new { root, "home", "prime", "aoeu", "agen.lua" } + -- local relative = Path:new(p.filename):make_relative(p.filename) + -- assert.are.same(relative, ".") + -- end) + + -- it("should not truncate if path separator is not present after cwd", function() + -- local cwd = "tmp" .. path.sep .. "foo" + -- local p = Path:new { "tmp", "foo_bar", "fileb.lua" } + -- local relative = Path:new(p.filename):make_relative(cwd) + -- assert.are.same(p.filename, relative) + -- end) + + -- it("should not truncate if path separator is not present after cwd and cwd ends in path sep", function() + -- local cwd = "tmp" .. path.sep .. "foo" .. path.sep + -- local p = Path:new { "tmp", "foo_bar", "fileb.lua" } + -- local relative = Path:new(p.filename):make_relative(cwd) + -- assert.are.same(p.filename, relative) + -- end) + -- end) + + -- describe(":normalize", function() + -- it("can take path that has one character directories", function() + -- local orig = "/home/j/./p//path.lua" + -- local final = Path:new(orig):normalize() + -- assert.are.same(final, "/home/j/p/path.lua") + -- end) + + -- it("can take paths with double separators change them to single separators", function() + -- local orig = "/lua//plenary/path.lua" + -- local final = Path:new(orig):normalize() + -- assert.are.same(final, "/lua/plenary/path.lua") + -- end) + -- -- this may be redundant since normalize just calls make_relative which is tested above + -- it("can take absolute paths with double seps" .. "and make them relative with single seps", function() + -- local orig = "/lua//plenary/path.lua" + -- local final = Path:new(orig):normalize() + -- assert.are.same(final, "/lua/plenary/path.lua") + -- end) + + -- it("can remove the .. in paths", function() + -- local orig = "/lua//plenary/path.lua/foo/bar/../.." + -- local final = Path:new(orig):normalize() + -- assert.are.same(final, "/lua/plenary/path.lua") + -- end) + + -- it("can normalize relative paths", function() + -- assert.are.same(Path:new("lua/plenary/path.lua"):normalize(), "lua/plenary/path.lua") + -- end) + + -- it("can normalize relative paths containing ..", function() + -- assert.are.same(Path:new("lua/plenary/path.lua/../path.lua"):normalize(), "lua/plenary/path.lua") + -- end) + + -- it("can normalize relative paths with initial ..", function() + -- local p = Path:new "../lua/plenary/path.lua" + -- p._cwd = "/tmp/lua" + -- assert.are.same("lua/plenary/path.lua", p:normalize()) + -- end) + + -- it("can normalize relative paths to absolute when initial .. count matches cwd parts", function() + -- local p = Path:new "../../tmp/lua/plenary/path.lua" + -- p._cwd = "/tmp/lua" + -- assert.are.same("/tmp/lua/plenary/path.lua", p:normalize()) + -- end) + + -- it("can normalize ~ when file is within home directory (trailing slash)", function() + -- local home = "/home/test/" + -- local p = Path:new { home, "./test_file" } + -- p.path.home = home + -- p._cwd = "/tmp/lua" + -- assert.are.same("~/test_file", p:normalize()) + -- end) + + -- it("can normalize ~ when file is within home directory (no trailing slash)", function() + -- local home = "/home/test" + -- local p = Path:new { home, "./test_file" } + -- p.path.home = home + -- p._cwd = "/tmp/lua" + -- assert.are.same("~/test_file", p:normalize()) + -- end) + + -- it("handles usernames with a dash at the end", function() + -- local home = "/home/mattr-" + -- local p = Path:new { home, "test_file" } + -- p.path.home = home + -- p._cwd = "/tmp/lua" + -- assert.are.same("~/test_file", p:normalize()) + -- end) + + -- it("handles filenames with the same prefix as the home directory", function() + -- local p = Path:new "/home/test.old/test_file" + -- p.path.home = "/home/test" + -- assert.are.same("/home/test.old/test_file", p:normalize()) + -- end) + -- end) + + -- describe(":shorten", function() + -- it("can shorten a path", function() + -- local long_path = "/this/is/a/long/path" + -- local short_path = Path:new(long_path):shorten() + -- assert.are.same(short_path, "/t/i/a/l/path") + -- end) + + -- it("can shorten a path's components to a given length", function() + -- local long_path = "/this/is/a/long/path" + -- local short_path = Path:new(long_path):shorten(2) + -- assert.are.same(short_path, "/th/is/a/lo/path") + + -- -- without the leading / + -- long_path = "this/is/a/long/path" + -- short_path = Path:new(long_path):shorten(3) + -- assert.are.same(short_path, "thi/is/a/lon/path") + + -- -- where len is greater than the length of the final component + -- long_path = "this/is/an/extremely/long/path" + -- short_path = Path:new(long_path):shorten(5) + -- assert.are.same(short_path, "this/is/an/extre/long/path") + -- end) + + -- it("can shorten a path's components when excluding parts", function() + -- local long_path = "/this/is/a/long/path" + -- local short_path = Path:new(long_path):shorten(nil, { 1, -1 }) + -- assert.are.same(short_path, "/this/i/a/l/path") + + -- -- without the leading / + -- long_path = "this/is/a/long/path" + -- short_path = Path:new(long_path):shorten(nil, { 1, -1 }) + -- assert.are.same(short_path, "this/i/a/l/path") + + -- -- where excluding positions greater than the number of parts + -- long_path = "this/is/an/extremely/long/path" + -- short_path = Path:new(long_path):shorten(nil, { 2, 4, 6, 8 }) + -- assert.are.same(short_path, "t/is/a/extremely/l/path") + + -- -- where excluding positions less than the negation of the number of parts + -- long_path = "this/is/an/extremely/long/path" + -- short_path = Path:new(long_path):shorten(nil, { -2, -4, -6, -8 }) + -- assert.are.same(short_path, "this/i/an/e/long/p") + -- end) + + -- it("can shorten a path's components to a given length and exclude positions", function() + -- local long_path = "/this/is/a/long/path" + -- local short_path = Path:new(long_path):shorten(2, { 1, -1 }) + -- assert.are.same(short_path, "/this/is/a/lo/path") + + -- long_path = "this/is/a/long/path" + -- short_path = Path:new(long_path):shorten(3, { 2, -2 }) + -- assert.are.same(short_path, "thi/is/a/long/pat") + + -- long_path = "this/is/an/extremely/long/path" + -- short_path = Path:new(long_path):shorten(5, { 3, -3 }) + -- assert.are.same(short_path, "this/is/an/extremely/long/path") + -- end) + -- end) + + -- describe("mkdir / rmdir", function() + -- it("can create and delete directories", function() + -- local p = Path:new "_dir_not_exist" + + -- p:rmdir() + -- assert(not p:exists(), "After rmdir, it should not exist") + + -- p:mkdir() + -- assert(p:exists()) + + -- p:rmdir() + -- assert(not p:exists()) + -- end) + + -- it("fails when exists_ok is false", function() + -- local p = Path:new "lua" + -- assert(not pcall(p.mkdir, p, { exists_ok = false })) + -- end) + + -- it("fails when parents is not passed", function() + -- local p = Path:new("impossible", "dir") + -- assert(not pcall(p.mkdir, p, { parents = false })) + -- assert(not p:exists()) + -- end) + + -- it("can create nested directories", function() + -- local p = Path:new("impossible", "dir") + -- assert(pcall(p.mkdir, p, { parents = true })) + -- assert(p:exists()) + + -- p:rmdir() + -- Path:new("impossible"):rmdir() + -- assert(not p:exists()) + -- assert(not Path:new("impossible"):exists()) + -- end) + -- end) + + -- describe("touch", function() + -- it("can create and delete new files", function() + -- local p = Path:new "test_file.lua" + -- assert(pcall(p.touch, p)) + -- assert(p:exists()) + + -- p:rm() + -- assert(not p:exists()) + -- end) + + -- it("does not effect already created files but updates last access", function() + -- local p = Path:new "README.md" + -- local last_atime = p:_stat().atime.sec + -- local last_mtime = p:_stat().mtime.sec + + -- local lines = p:readlines() + + -- assert(pcall(p.touch, p)) + -- print(p:_stat().atime.sec > last_atime) + -- print(p:_stat().mtime.sec > last_mtime) + -- assert(p:exists()) + + -- assert.are.same(lines, p:readlines()) + -- end) + + -- it("does not create dirs if nested in none existing dirs and parents not set", function() + -- local p = Path:new { "nested", "nested2", "test_file.lua" } + -- assert(not pcall(p.touch, p, { parents = false })) + -- assert(not p:exists()) + -- end) + + -- it("does create dirs if nested in none existing dirs", function() + -- local p1 = Path:new { "nested", "nested2", "test_file.lua" } + -- local p2 = Path:new { "nested", "asdf", ".hidden" } + -- local d1 = Path:new { "nested", "dir", ".hidden" } + -- assert(pcall(p1.touch, p1, { parents = true })) + -- assert(pcall(p2.touch, p2, { parents = true })) + -- assert(pcall(d1.mkdir, d1, { parents = true })) + -- assert(p1:exists()) + -- assert(p2:exists()) + -- assert(d1:exists()) + + -- Path:new({ "nested" }):rm { recursive = true } + -- assert(not p1:exists()) + -- assert(not p2:exists()) + -- assert(not d1:exists()) + -- assert(not Path:new({ "nested" }):exists()) + -- end) + -- end) + + -- describe("rename", function() + -- it("can rename a file", function() + -- local p = Path:new "a_random_filename.lua" + -- assert(pcall(p.touch, p)) + -- assert(p:exists()) + + -- assert(pcall(p.rename, p, { new_name = "not_a_random_filename.lua" })) + -- assert.are.same("not_a_random_filename.lua", p.filename) + + -- p:rm() + -- end) + + -- it("can handle an invalid filename", function() + -- local p = Path:new "some_random_filename.lua" + -- assert(pcall(p.touch, p)) + -- assert(p:exists()) + + -- assert(not pcall(p.rename, p, { new_name = "" })) + -- assert(not pcall(p.rename, p)) + -- assert.are.same("some_random_filename.lua", p.filename) + + -- p:rm() + -- end) + + -- it("can move to parent dir", function() + -- local p = Path:new "some_random_filename.lua" + -- assert(pcall(p.touch, p)) + -- assert(p:exists()) + + -- assert(pcall(p.rename, p, { new_name = "../some_random_filename.lua" })) + -- assert.are.same(vim.loop.fs_realpath(Path:new("../some_random_filename.lua"):absolute()), p:absolute()) + + -- p:rm() + -- end) + + -- it("cannot rename to an existing filename", function() + -- local p1 = Path:new "a_random_filename.lua" + -- local p2 = Path:new "not_a_random_filename.lua" + -- assert(pcall(p1.touch, p1)) + -- assert(pcall(p2.touch, p2)) + -- assert(p1:exists()) + -- assert(p2:exists()) + + -- assert(not pcall(p1.rename, p1, { new_name = "not_a_random_filename.lua" })) + -- assert.are.same(p1.filename, "a_random_filename.lua") + + -- p1:rm() + -- p2:rm() + -- end) + -- end) + + -- describe("copy", function() + -- it("can copy a file", function() + -- local p1 = Path:new "a_random_filename.rs" + -- local p2 = Path:new "not_a_random_filename.rs" + -- assert(pcall(p1.touch, p1)) + -- assert(p1:exists()) + + -- assert(pcall(p1.copy, p1, { destination = "not_a_random_filename.rs" })) + -- assert.are.same(p1.filename, "a_random_filename.rs") + -- assert.are.same(p2.filename, "not_a_random_filename.rs") + + -- p1:rm() + -- p2:rm() + -- end) + + -- it("can copy to parent dir", function() + -- local p = Path:new "some_random_filename.lua" + -- assert(pcall(p.touch, p)) + -- assert(p:exists()) + + -- assert(pcall(p.copy, p, { destination = "../some_random_filename.lua" })) + -- assert(pcall(p.exists, p)) + + -- p:rm() + -- Path:new(vim.loop.fs_realpath "../some_random_filename.lua"):rm() + -- end) + + -- it("cannot copy an existing file if override false", function() + -- local p1 = Path:new "a_random_filename.rs" + -- local p2 = Path:new "not_a_random_filename.rs" + -- assert(pcall(p1.touch, p1)) + -- assert(pcall(p2.touch, p2)) + -- assert(p1:exists()) + -- assert(p2:exists()) + + -- assert(pcall(p1.copy, p1, { destination = "not_a_random_filename.rs", override = false })) + -- assert.are.same(p1.filename, "a_random_filename.rs") + -- assert.are.same(p2.filename, "not_a_random_filename.rs") + + -- p1:rm() + -- p2:rm() + -- end) + + -- it("fails when copying folders non-recursively", function() + -- local src_dir = Path:new "src" + -- src_dir:mkdir() + -- src_dir:joinpath("file1.lua"):touch() + + -- local trg_dir = Path:new "trg" + -- local status = xpcall(function() + -- src_dir:copy { destination = trg_dir, recursive = false } + -- end, function() end) + -- -- failed as intended + -- assert(status == false) + + -- src_dir:rm { recursive = true } + -- end) + + -- it("can copy directories recursively", function() + -- -- vim.tbl_flatten doesn't work here as copy doesn't return a list + -- local flatten + -- flatten = function(ret, t) + -- for _, v in pairs(t) do + -- if type(v) == "table" then + -- flatten(ret, v) + -- else + -- table.insert(ret, v) + -- end + -- end + -- end + + -- -- setup directories + -- local src_dir = Path:new "src" + -- local trg_dir = Path:new "trg" + -- src_dir:mkdir() + + -- -- set up sub directory paths for creation and testing + -- local sub_dirs = { "sub_dir1", "sub_dir1/sub_dir2" } + -- local src_dirs = { src_dir } + -- local trg_dirs = { trg_dir } + -- -- {src, trg}_dirs is a table with all directory levels by {src, trg} + -- for _, dir in ipairs(sub_dirs) do + -- table.insert(src_dirs, src_dir:joinpath(dir)) + -- table.insert(trg_dirs, trg_dir:joinpath(dir)) + -- end + + -- -- generate {file}_{level}.lua on every directory level in src + -- -- src + -- -- ├── file1_1.lua + -- -- ├── file2_1.lua + -- -- ├── .file3_1.lua + -- -- └── sub_dir1 + -- -- ├── file1_2.lua + -- -- ├── file2_2.lua + -- -- ├── .file3_2.lua + -- -- └── sub_dir2 + -- -- ├── file1_3.lua + -- -- ├── file2_3.lua + -- -- └── .file3_3.lua + -- local files = { "file1", "file2", ".file3" } + -- for _, file in ipairs(files) do + -- for level, dir in ipairs(src_dirs) do + -- local p = dir:joinpath(file .. "_" .. level .. ".lua") + -- assert(pcall(p.touch, p, { parents = true, exists_ok = true })) + -- assert(p:exists()) + -- end + -- end + + -- for _, hidden in ipairs { true, false } do + -- -- override = `false` should NOT copy as it was copied beforehand + -- for _, override in ipairs { true, false } do + -- local success = src_dir:copy { destination = trg_dir, recursive = true, override = override, hidden = hidden } + -- -- the files are already created because we iterate first with `override=true` + -- -- hence, we test here that no file ops have been committed: any value in tbl of tbls should be false + -- if not override then + -- local file_ops = {} + -- flatten(file_ops, success) + -- -- 3 layers with at at least 2 and at most 3 files (`hidden = true`) + -- local num_files = not hidden and 6 or 9 + -- assert(#file_ops == num_files) + -- for _, op in ipairs(file_ops) do + -- assert(op == false) + -- end + -- else + -- for _, file in ipairs(files) do + -- for level, dir in ipairs(trg_dirs) do + -- local p = dir:joinpath(file .. "_" .. level .. ".lua") + -- -- file 3 is hidden + -- if not (file == files[3]) then + -- assert(p:exists()) + -- else + -- assert(p:exists() == hidden) + -- end + -- end + -- end + -- end + -- -- only clean up once we tested that we dont want to copy + -- -- if `override=true` + -- if not override then + -- trg_dir:rm { recursive = true } + -- end + -- end + -- end + + -- src_dir:rm { recursive = true } + -- end) + -- end) + + -- describe("parents", function() + -- it("should extract the ancestors of the path", function() + -- local p = Path:new(vim.loop.cwd()) + -- local parents = p:parents() + -- assert(compat.islist(parents)) + -- for _, parent in pairs(parents) do + -- assert.are.same(type(parent), "string") + -- end + -- end) + -- it("should return itself if it corresponds to path.root", function() + -- local p = Path:new(Path.path.root(vim.loop.cwd())) + -- assert.are.same(p:parent(), p) + -- end) + -- end) + + -- describe("read parts", function() + -- it("should read head of file", function() + -- local p = Path:new "LICENSE" + -- local data = p:head() + -- local should = [[MIT License + + -- Copyright (c) 2020 TJ DeVries + + -- Permission is hereby granted, free of charge, to any person obtaining a copy + -- of this software and associated documentation files (the "Software"), to deal + -- in the Software without restriction, including without limitation the rights + -- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + -- copies of the Software, and to permit persons to whom the Software is + -- furnished to do so, subject to the following conditions:]] + -- assert.are.same(should, data) + -- end) + + -- it("should read the first line of file", function() + -- local p = Path:new "LICENSE" + -- local data = p:head(1) + -- local should = [[MIT License]] + -- assert.are.same(should, data) + -- end) + + -- it("head should max read whole file", function() + -- local p = Path:new "LICENSE" + -- local data = p:head(1000) + -- local should = [[MIT License + + -- Copyright (c) 2020 TJ DeVries + + -- Permission is hereby granted, free of charge, to any person obtaining a copy + -- of this software and associated documentation files (the "Software"), to deal + -- in the Software without restriction, including without limitation the rights + -- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + -- copies of the Software, and to permit persons to whom the Software is + -- furnished to do so, subject to the following conditions: + + -- The above copyright notice and this permission notice shall be included in all + -- copies or substantial portions of the Software. + + -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + -- SOFTWARE.]] + -- assert.are.same(should, data) + -- end) + + -- it("should read tail of file", function() + -- local p = Path:new "LICENSE" + -- local data = p:tail() + -- local should = [[The above copyright notice and this permission notice shall be included in all + -- copies or substantial portions of the Software. + + -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + -- SOFTWARE.]] + -- assert.are.same(should, data) + -- end) + + -- it("should read the last line of file", function() + -- local p = Path:new "LICENSE" + -- local data = p:tail(1) + -- local should = [[SOFTWARE.]] + -- assert.are.same(should, data) + -- end) + + -- it("tail should max read whole file", function() + -- local p = Path:new "LICENSE" + -- local data = p:tail(1000) + -- local should = [[MIT License + + -- Copyright (c) 2020 TJ DeVries + + -- Permission is hereby granted, free of charge, to any person obtaining a copy + -- of this software and associated documentation files (the "Software"), to deal + -- in the Software without restriction, including without limitation the rights + -- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + -- copies of the Software, and to permit persons to whom the Software is + -- furnished to do so, subject to the following conditions: + + -- The above copyright notice and this permission notice shall be included in all + -- copies or substantial portions of the Software. + + -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + -- SOFTWARE.]] + -- assert.are.same(should, data) + -- end) + -- end) + + -- describe("readbyterange", function() + -- it("should read bytes at given offset", function() + -- local p = Path:new "LICENSE" + -- local data = p:readbyterange(13, 10) + -- local should = "Copyright " + -- assert.are.same(should, data) + -- end) + + -- it("supports negative offset", function() + -- local p = Path:new "LICENSE" + -- local data = p:readbyterange(-10, 10) + -- local should = "SOFTWARE.\n" + -- assert.are.same(should, data) + -- end) + -- end) +end) From 815fb2c0a0acf27ecd38bd635c47a71db939a1b9 Mon Sep 17 00:00:00 2001 From: James Trew Date: Thu, 22 Aug 2024 21:56:04 -0400 Subject: [PATCH 02/43] make_relative done --- lua/plenary/path2.lua | 398 +++++++++++++++++------- tests/plenary/path2_spec.lua | 567 ++++++++++++++++++----------------- 2 files changed, 573 insertions(+), 392 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index fe776f59..77e53afa 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -20,14 +20,14 @@ - [x] Path.__concat - [x] Path.is_path - [x] Path:new -- [ ] Path:_fs_filename -- [ ] Path:_stat -- [ ] Path:_st_mode -- [ ] Path:joinpath -- [ ] Path:absolute -- [ ] Path:exists +- [x] Path:_fs_filename +- [x] Path:_stat +- [x] Path:_st_mode +- [x] Path:joinpath +- [x] Path:absolute +- [x] Path:exists - [ ] Path:expand -- [ ] Path:make_relative +- [x] Path:make_relative - [ ] Path:normalize - [ ] shorten_len - [ ] shorten @@ -39,7 +39,7 @@ - [ ] Path:touch - [ ] Path:rm - [ ] Path:is_dir -- [ ] Path:is_absolute +- [x] Path:is_absolute - [ ] Path:_split - [ ] _get_parent - [ ] Path:parent @@ -72,13 +72,23 @@ local S_IF = { } ---@class plenary.path ----@field home? string home directory path ----@field sep string OS path separator ----@field root fun():string root directory path +---@field home string home directory path +---@field sep string OS path separator respecting 'shellslash' +--- +--- OS separator for paths returned by libuv functions. +--- Note: libuv will happily take either path separator regardless of 'shellslash'. +---@field private _uv_sep string +--- +--- get the root directory path. +--- On Windows, this is determined from the current working directory in order +--- to capture the current disk name. But can be calculated from another path +--- using the optional `base` parameter. +---@field root fun(base: string?):string ---@field S_IF { DIR: integer, REG: integer } stat filetype bitmask local path = setmetatable({ - home = vim.loop.os_homedir(), + home = vim.fn.getcwd(), -- respects shellslash unlike vim.uv.cwd() S_IF = S_IF, + _uv_sep = iswin and "\\" or "/", }, { __index = function(t, k) local raw = rawget(t, k) @@ -86,54 +96,100 @@ local path = setmetatable({ return raw end - if not iswin then - t.sep = "/" - return t.sep - end + if k == "sep" then + if not iswin then + t.sep = "/" + return t.sep + end - return (hasshellslash and vim.o.shellslash) and "/" or "\\" + return (hasshellslash and vim.o.shellslash) and "/" or "\\" + end end, }) path.root = (function() - if path.sep == "/" then + if not iswin then return function() return "/" end else return function(base) - base = base or vim.loop.cwd() - return base:sub(1, 1) .. ":\\" + base = base or path.home + local disk = base:match "^[%a]:" + if disk then + return disk .. path.sep + end + return string.rep(path.sep, 2) -- UNC end end end)() +--- WARNING: Should really avoid using this. It's more like +--- `maybe_uri_maybe_not`. There are both false positives and false negative +--- edge cases. +--- +--- Approximates if a filename is a valid URI by checking if the filename +--- starts with a plausible scheme. +--- +--- A valid URI scheme begins with a letter, followed by any number of letters, +--- numbers and `+`, `.`, `-` and ends with a `:`. +--- +--- To disambiguate URI schemes from Windows path, we also check up to 2 +--- characters after the `:` to make sure it's followed by `//`. +--- +--- Two major caveats according to our checks: +--- - a "valid" URI is also a valid unix relative path so any relative unix +--- path that's in the shape of a URI according to our check will be flagged +--- as a URI. +--- - relative Windows paths like `C:Projects/apilibrary/apilibrary.sln` will +--- be caught as a URI. +--- +---@param filename string +---@return boolean local function is_uri(filename) - local char = string.byte(filename, 1) or 0 + local ch = filename:byte(1) or 0 - -- is alpha? - if char < 65 or (char > 90 and char < 97) or char > 122 then + -- is not alpha? + if not ((ch >= 97 and ch <= 122) or (ch >= 65 and ch <= 90)) then return false end + local scheme_end = 0 for i = 2, #filename do - char = string.byte(filename, i) - if char == 58 then -- `:` - return i < #filename and string.byte(filename, i + 1) ~= 92 -- `\` - elseif - not ( - (char >= 48 and char <= 57) -- 0-9 - or (char >= 65 and char <= 90) -- A-Z - or (char >= 97 and char <= 122) -- a-z - or char == 43 -- `+` - or char == 46 -- `.` - or char == 45 -- `-` - ) - then + ch = filename:byte(i) + if + (ch >= 97 and ch <= 122) -- a-z + or (ch >= 65 and ch <= 90) -- A-Z + or (ch >= 48 and ch <= 57) -- 0-9 + or ch == 43 -- `+` + or ch == 46 -- `.` + or ch == 45 -- `-` + then -- luacheck: ignore 542 + -- pass + elseif ch == 58 then + scheme_end = i + break + else return false end end - return false + + if scheme_end == 0 then + return false + end + + local next = filename:byte(scheme_end + 1) or 0 + if next == 0 then + -- nothing following the scheme + return false + elseif next == 92 then -- `\` + -- could be Windows absolute path but not a uri + return false + elseif next == 47 and (filename:byte(scheme_end + 2) or 0) ~= 47 then -- `/` + -- still could be Windows absolute path using `/` seps but not a uri + return false + end + return true end --- Split a Windows path into a prefix and a body, such that the body can be processed like a POSIX @@ -150,10 +206,10 @@ end --- --- @param p string Path to split. --- @return string, string, boolean : prefix, body, whether path is invalid. -local function split_windows_p(p) +local function split_windows_path(p) local prefix = "" - --- Match pattern. If there is a match, move the matched pattern from the p to the 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. @@ -170,31 +226,31 @@ local function split_windows_p(p) end local function process_unc_path() - return match_to_prefix "[^\\]+\\+[^\\]+\\+" + return match_to_prefix "[^/]+/+[^/]+/+" end - if match_to_prefix "^\\\\[?.]\\" then - -- Device ps - local device = match_to_prefix "[^\\]+\\+" + 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 p - if not device or (device:match "^UNC\\+$" and not process_unc_path()) then + -- 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, p, false end - elseif match_to_prefix "^\\\\" then - -- Process UNC p, return early if it's invalid + elseif match_to_prefix "^//" then + -- Process UNC path, return early if it's invalid if not process_unc_path() then return prefix, p, false end elseif p:match "^%w:" then - -- Drive ps + -- Drive paths prefix, p = p:sub(1, 2), p: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 p. For ps like C:foo\\bar, there are no - -- slashes at the end of the prefix, so it will be treated as a relative p, as it should be. - local trailing_slash = prefix:match "\\+$" + -- 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) @@ -216,14 +272,14 @@ local function path_resolve_dot(p) local new_path_components = {} for component in vim.gsplit(p, "/") do - if component == "." or component == "" then -- luacheck: ignore 542 + if component == "." or component == "" then -- 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 + elseif is_path_absolute then -- Reached the root directory in absolute path, do nothing else -- Reached current directory in relative path, add `..` to the path @@ -237,6 +293,11 @@ local function path_resolve_dot(p) return (is_path_absolute and "/" or "") .. table.concat(new_path_components, "/") end +--- Resolves '.' and '..' in the path, removes extra path separator. +--- +--- For Windows, converts separator `\` to `/` to simplify many operations. +--- +--- Credit to famiu. This is basically neovim core `vim.fs.normalize`. ---@param p string path ---@return string local function normalize_path(p) @@ -253,7 +314,7 @@ local function normalize_path(p) if iswin then local valid - prefix, p, valid = split_windows_p(p) + prefix, p, valid = split_windows_path(p) if not valid then return prefix .. p end @@ -273,10 +334,14 @@ end ---@class plenary.Path ---@field path plenary.path ---@field filename string path as a string ----@field private _filename string ----@field private _sep string path separator ----@field private _absolute string absolute path ----@field private _cwd string cwd path +--- +--- internal string representation of the path that's normalized and uses `/` +--- as path separator. makes many other operations much easier to work with. +---@field private _name string +---@field private _sep string path separator taking into account 'shellslash' on windows +---@field private _absolute string? absolute path +---@field private _cwd string? cwd path +---@field private _fs_stat table fs_stat local Path = { path = path, } @@ -289,24 +354,30 @@ Path.__index = function(t, k) if k == "_cwd" then local cwd = uv.fs_realpath "." + if cwd ~= nil then + cwd = (cwd:gsub(path._uv_sep, "/")) + end t._cwd = cwd - return cwd + return t._cwd end if k == "_absolute" then - local absolute = uv.fs_realpath(t.filename) + local absolute = uv.fs_realpath(t._name) + if absolute ~= nil then + absolute = (absolute:gsub(path._uv_sep, "/")) + end t._absolute = absolute return absolute end -end -Path.__newindex = function(t, k, value) - if k == "filename" then - error "'filename' field is immutable" + if k == "_fs_stat" then + t._fs_stat = uv.fs_stat(t._absolute or t._name) or {} + return t._fs_stat end - return rawset(t, k, value) end +---@param other plenary.Path|string +---@return plenary.Path Path.__div = function(self, other) assert(Path.is_path(self)) assert(Path.is_path(other) or type(other) == "string") @@ -314,8 +385,9 @@ Path.__div = function(self, other) return self:joinpath(other) end +---@return string Path.__tostring = function(self) - return clean(self.filename) + return self._name end -- TODO: See where we concat the table, and maybe we could make this work. @@ -416,56 +488,97 @@ function Path:new(...) path_string = unix_path_str(path_input, sep) end - -- if type(path_input) == "string" then - -- if iswin then - -- if path_input:match "^[%a]:[\\/].*$" then - -- end - -- path_input = vim.split(path_input, "[\\/]") - -- else - -- path_input = vim.split(path_input, sep) - -- end - -- end - - -- if type(path_input) == "table" then - -- local path_objs = {} - -- for _, v in ipairs(path_input) do - -- if Path.is_path(v) then - -- table.insert(path_objs, v.filename) - -- else - -- assert(type(v) == "string") - -- table.insert(path_objs, v) - -- end - -- end - - -- if iswin and path_objs[1]:match "^[%a]:$" then - -- local disk = path_objs[1] - -- table.remove(path_objs, 1) - -- path_string = disk .. table.concat(path_objs, sep) - -- else - -- path_string = table.concat(path_objs, sep) - -- end - -- else - -- error("unexpected path input\n" .. vim.inspect(path_input)) - -- end - - local obj = { + local proxy = { -- precompute normalized path using `/` as sep - _filename = normalize_path(path_string), + _name = normalize_path(path_string), filename = path_string, _sep = sep, } - setmetatable(obj, Path) + setmetatable(proxy, Path) + + local obj = { __inner = proxy } + setmetatable(obj, { + __index = function(_, k) + return proxy[k] + end, + __newindex = function(t, k, val) + if k == "filename" then + proxy.filename = val + proxy._name = normalize_path(val) + proxy._absolute = nil + proxy._fs_stat = nil + elseif k == "_name" then + proxy.filename = (val:gsub("/", t._sep)) + proxy._name = val + proxy._absolute = nil + proxy._fs_stat = nil + else + proxy[k] = val + end + end, + ---@return plenary.Path + __div = function(t, other) + return Path.__div(t, other) + end, + ---@return string + __concat = function(t, other) + return Path.__concat(t, other) + end, + ---@return string + __tostring = function(t) + return Path.__tostring(t) + end, + __metatable = Path, + }) return obj end +---@return string +function Path:absolute() + if self:is_absolute() then + return (self._name:gsub("/", self._sep)) + end + return (normalize_path(self._cwd .. self._sep .. self._name):gsub("/", self._sep)) +end + +---@return string +function Path:_fs_filename() + return self:absolute() or self.filename +end + +---@return table +function Path:_stat() + return self._fs_stat +end + +---@return number +function Path:_st_mode() + return self:_stat().mode or 0 +end + +---@return boolean +function Path:exists() + return not vim.tbl_isempty(self:_stat()) +end + +---@return boolean +function Path:is_dir() + return self:_stat().type == "directory" +end + +---@return boolean +function Path:is_file() + return self:_stat().type == "file" +end + --- For POSIX path, anything starting with a `/` is considered a absolute path. --- --- --- For Windows, it's a little more involved. --- ---- Disk names are single letters. They MUST be followed by a separator to be +--- Disk names are single letters. They MUST be followed by a `:` + separator to be --- considered an absolute path. eg. --- C:\Documents\Newsletters\Summer2018.pdf -> An absolute file path from the root of drive C:. @@ -479,30 +592,87 @@ end ---@return boolean function Path:is_absolute() if not iswin then - return string.sub(self._filename, 1, 1) == "/" + return string.sub(self._name, 1, 1) == "/" end - if string.match(self._filename, "^[%a]:/.*$") ~= nil then + if string.match(self._name, "^[%a]:/.*$") ~= nil then return true - elseif string.match(self._filename, "^//") then + elseif string.match(self._name, "^//") then return true end return false end ----@return string -function Path:absolute() - if self:is_absolute() then +---@return plenary.Path +function Path:joinpath(...) + return Path:new(self._name, ...) +end + +--- Make path relative to another. +--- +--- No-op if path is a URI. +---@param cwd string? path to make relative to (default: cwd) +---@return string # new filename +function Path:make_relative(cwd) + if is_uri(self._name) then + return self.filename + end + + cwd = Path:new(vim.F.if_nil(cwd, self._cwd))._name + + if self._name == cwd then + self._name = "." return self.filename end - return (normalize_path(self._cwd .. self._sep .. self._filename):gsub("/", self._sep)) + + if cwd:sub(#cwd, #cwd) ~= "/" then + cwd = cwd .. "/" + end + + if not self:is_absolute() then + self._name = normalize_path(cwd .. self._name) + end + + -- TODO: doesn't handle distant relative cwd well + -- eg. cwd = '/tmp/foo' and path = '/home/user/bar' + -- would be something like '/tmp/foo/../../home/user/bar'? + -- I'm not even sure, check later + if self._name:sub(1, #cwd) == cwd then + self._name = self._name:sub(#cwd + 1, -1) + else + self._name = normalize_path(self.filename) + end + return self.filename end -vim.o.shellslash = false --- -- local p = Path:new { "C:", "README.md" } -local p = Path:new { "C:\\Documents\\Newsletters\\Summer2018.pdf" } -print(p.filename, p:is_absolute(), p:absolute()) -vim.o.shellslash = true +--- Makes the path relative to cwd or provided path and resolves any internal +--- '.' and '..' in relative paths according. Substitutes home directory +--- with `~` if applicable. Deduplicates path separators and trims any trailing +--- separators. +--- +--- No-op if path is a URI. +---@param cwd string? path to make relative to (default: cwd) +---@return string +function Path:normalize(cwd) + if is_uri(self._name) then + return self.filename + end + + print(self.filename, self._name) + self:make_relative(cwd) + + local home = path.home + if home:sub(-1) ~= self._sep then + home = home .. self._sep + end + + local start, finish = self._name:find(home, 1, true) + if start == 1 then + self._name = "~/" .. self._name:sub(finish + 1, -1) + end + + return self.filename +end return Path diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 25a9f6a8..7ddeead7 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -1,8 +1,50 @@ local Path = require "plenary.path2" local path = Path.path -local compat = require "plenary.compat" +-- local compat = require "plenary.compat" local iswin = vim.loop.os_uname().sysname == "Windows_NT" +local hasshellslash = vim.fn.exists "+shellslash" == 1 + +---@param bool boolean +local function set_shellslash(bool) + if hasshellslash then + vim.o.shellslash = bool + end +end + +local function it_ssl(name, test_fn) + if not hasshellslash then + it(name, test_fn) + else + local orig = vim.o.shellslash + vim.o.shellslash = true + it(name .. " - shellslash", test_fn) + + vim.o.shellslash = false + it(name .. " - noshellslash", test_fn) + vim.o.shellslash = orig + end +end + +local function it_cross_plat(name, test_fn) + if not iswin then + it(name .. " - unix", test_fn) + else + it_ssl(name .. " - windows", test_fn) + end +end + +--- convert unix path into window paths +local function plat_path(p) + if not iswin then + return p + end + if hasshellslash and vim.o.shellslash then + return p + end + return p:gsub("/", "\\") +end + describe("absolute", function() describe("unix", function() if iswin then @@ -16,11 +58,11 @@ describe("absolute", function() end describe("shellslash", function() - vim.o.shellslash = true + set_shellslash(true) end) describe("noshellslash", function() - vim.o.shellslash = false + set_shellslash(false) end) end) end) @@ -67,7 +109,7 @@ describe("Path", function() end local function get_windows_paths() - local nossl = vim.fn.exists "+shellslash" == 1 and not vim.o.shellslash + local nossl = hasshellslash and not vim.o.shellslash ---@type [string[]|string, string][] local paths = { @@ -80,11 +122,12 @@ describe("Path", function() { [[//Server2//Share//Test/Foo.txt]], [[//Server2/Share/Test/Foo.txt]] }, { [[\\Server2\\Share\\Test\Foo.txt]], [[//Server2/Share/Test/Foo.txt]] }, { { "C:", "lua", "..", "README.md" }, "C:lua/../README.md" }, + { { "C:/", "lua", "..", "README.md" }, "C:/lua/../README.md" }, + { "C:lua/../README.md", "C:lua/../README.md" }, + { "C:/lua/../README.md", "C:/lua/../README.md" }, { [[foo/bar\baz]], [[foo/bar/baz]] }, - -- TODO: add mixed sep paths - -- whatever these things are - -- \\.\C:\Test\Foo.txt - -- \\?\C:\Test\Foo.txt + { [[\\.\C:\Test\Foo.txt]], [[//./C:/Test/Foo.txt]] }, + { [[\\?\C:\Test\Foo.txt]], [[//?/C:/Test/Foo.txt]] }, } vim.list_extend(paths, get_paths()) @@ -97,13 +140,18 @@ describe("Path", function() return paths end + it("custom sep", function() + local p = Path:new { "foo\\bar/baz", sep = "/" } + assert.are.same(p.filename, "foo/bar/baz") + end) + describe("noshellslash", function() - vim.o.shellslash = false + set_shellslash(false) test_filename(get_windows_paths()) end) describe("shellslash", function() - vim.o.shellslash = true + set_shellslash(true) test_filename(get_windows_paths()) end) end) @@ -113,9 +161,9 @@ describe("Path", function() local function get_paths() local readme_path = vim.fn.fnamemodify("README.md", ":p") - ---@type [string[], string, boolean][] + ---@type [string[]|string, string, boolean][] local paths = { - { { "README.md" }, readme_path, false }, + { "README.md", readme_path, false }, { { "lua", "..", "README.md" }, readme_path, false }, { { readme_path }, readme_path, true }, } @@ -146,14 +194,21 @@ describe("Path", function() end local function get_windows_paths() - local nossl = vim.fn.exists "+shellslash" == 1 and not vim.o.shellslash + local nossl = hasshellslash and not vim.o.shellslash + local disk = path.root():match "^[%a]:" + local readme_path = vim.fn.fnamemodify("README.md", ":p") - ---@type [string[], string, boolean][] + ---@type [string[]|string, string, boolean][] local paths = { - { { [[C:\Documents\Newsletters\Summer2018.pdf]] }, [[C:/Documents/Newsletters/Summer2018.pdf]], true }, - { { [[C:/Documents/Newsletters/Summer2018.pdf]] }, [[C:/Documents/Newsletters/Summer2018.pdf]], true }, - { { [[\\Server2\Share\Test\Foo.txt]] }, [[//Server2/Share/Test/Foo.txt]], true }, - { { [[//Server2/Share/Test/Foo.txt]] }, [[//Server2/Share/Test/Foo.txt]], true }, + { [[C:\Documents\Newsletters\Summer2018.pdf]], [[C:/Documents/Newsletters/Summer2018.pdf]], true }, + { [[C:/Documents/Newsletters/Summer2018.pdf]], [[C:/Documents/Newsletters/Summer2018.pdf]], true }, + { [[\\Server2\Share\Test\Foo.txt]], [[//Server2/Share/Test/Foo.txt]], true }, + { [[//Server2/Share/Test/Foo.txt]], [[//Server2/Share/Test/Foo.txt]], true }, + { [[\\.\C:\Test\Foo.txt]], [[//./C:/Test/Foo.txt]], true }, + { [[\\?\C:\Test\Foo.txt]], [[//?/C:/Test/Foo.txt]], true }, + { readme_path, readme_path, true }, + { disk .. [[lua/../README.md]], readme_path, false }, + { { disk, "lua", "..", "README.md" }, readme_path, false }, } vim.list_extend(paths, get_paths()) @@ -167,126 +222,73 @@ describe("Path", function() end describe("shellslash", function() - vim.o.shellslash = true + set_shellslash(true) test_absolute(get_windows_paths()) end) describe("noshellslash", function() - vim.o.shellslash = false + set_shellslash(false) test_absolute(get_windows_paths()) end) end) end) - -- describe("absolute", function() - -- local readme_path = vim.fn.fnamemodify("README.md", ":p") - -- local posix_paths = { - -- { { "README.md" }, readme_path }, - -- { { "lua", "..", "README.md" }, readme_path }, - -- { { "lua/../README.md" }, readme_path }, - -- { { "./lua/../README.md" }, readme_path }, - -- { { readme_path }, readme_path }, - -- } - - -- local windows_paths = { - -- { { [[C:\Documents\Newsletters\Summer2018.pdf]] }, [[C:\Documents\Newsletters\Summer2018.pdf]] }, - -- { { [[C:/Documents/Newsletters/Summer2018.pdf]] }, [[C:\Documents\Newsletters\Summer2018.pdf]] }, - -- { { [[\\Server2\Share\Test\Foo.txt]] }, [[\\Server2\Share\Test\Foo.txt]] }, - -- { { [[//Server2/Share/Test/Foo.txt]] }, [[\\Server2\Share\Test\Foo.txt]] }, - -- } - -- vim.list_extend(windows_paths, posix_paths) - - -- local function test_absolute(input, expect) - -- it(string.format(":absolute() %s", vim.inspect(input)), function() - -- local p = Path:new(input) - -- assert.are.same(p:absolute(), expect) - -- end) - -- end - - -- local test_path = iswin and windows_paths or posix_paths - - -- for _, tc in ipairs(test_path) do - -- test_absolute(tc[1], tc[2]) - -- end - - -- it(".absolute()", function() - -- local p = Path:new { "README.md", sep = "\\" } - -- assert.are.same(p:absolute(), vim.fn.fnamemodify("README.md", ":p")) - -- end) - - -- it("can determine absolute paths", function() - -- local p = Path:new { "/home/asdfasdf/", sep = "/" } - -- assert(p:is_absolute(), "Is absolute") - -- assert(p:absolute() == p.filename) - -- end) - - -- it("can determine non absolute paths", function() - -- local p = Path:new { "./home/tj/", sep = "/" } - -- assert(not p:is_absolute(), "Is absolute") - -- end) - - -- it("will normalize the path", function() - -- local p = Path:new { "lua", "..", "README.md", sep = "/" } - -- assert.are.same(p:absolute(), vim.fn.fnamemodify("README.md", ":p")) - -- end) - -- end) - - -- it("can join paths by constructor or join path", function() - -- assert.are.same(Path:new("lua", "plenary"), Path:new("lua"):joinpath "plenary") - -- end) + it_cross_plat("can join paths by constructor or join path", function() + assert.are.same(Path:new("lua", "plenary"), Path:new("lua"):joinpath "plenary") + end) - -- it("can join paths with /", function() - -- assert.are.same(Path:new("lua", "plenary"), Path:new "lua" / "plenary") - -- end) + it_cross_plat("can join paths with /", function() + assert.are.same(Path:new("lua", "plenary"), Path:new "lua" / "plenary") + end) - it("can join paths with paths", function() + it_cross_plat("can join paths with paths", function() assert.are.same(Path:new("lua", "plenary"), Path:new("lua", Path:new "plenary")) end) - it("inserts slashes", function() + it_cross_plat("inserts slashes", function() assert.are.same("lua" .. path.sep .. "plenary", Path:new("lua", "plenary").filename) end) - -- describe(".exists()", function() - -- it("finds files that exist", function() - -- assert.are.same(true, Path:new("README.md"):exists()) - -- end) + describe(".exists()", function() + it_cross_plat("finds files that exist", function() + assert.are.same(true, Path:new("README.md"):exists()) + end) - -- it("returns false for files that do not exist", function() - -- assert.are.same(false, Path:new("asdf.md"):exists()) - -- end) - -- end) + it_cross_plat("returns false for files that do not exist", function() + assert.are.same(false, Path:new("asdf.md"):exists()) + end) + end) - -- describe(".is_dir()", function() - -- it("should find directories that exist", function() - -- assert.are.same(true, Path:new("lua"):is_dir()) - -- end) + describe(".is_dir()", function() + it_cross_plat("should find directories that exist", function() + assert.are.same(true, Path:new("lua"):is_dir()) + end) - -- it("should return false when the directory does not exist", function() - -- assert.are.same(false, Path:new("asdf"):is_dir()) - -- end) + it_cross_plat("should return false when the directory does not exist", function() + assert.are.same(false, Path:new("asdf"):is_dir()) + end) - -- it("should not show files as directories", function() - -- assert.are.same(false, Path:new("README.md"):is_dir()) - -- end) - -- end) + it_cross_plat("should not show files as directories", function() + assert.are.same(false, Path:new("README.md"):is_dir()) + end) + end) - -- describe(".is_file()", function() - -- it("should not allow directories", function() - -- assert.are.same(true, not Path:new("lua"):is_file()) - -- end) + describe(".is_file()", function() + it_cross_plat("should not allow directories", function() + assert.are.same(true, not Path:new("lua"):is_file()) + end) - -- it("should return false when the file does not exist", function() - -- assert.are.same(true, not Path:new("asdf"):is_file()) - -- end) + it_cross_plat("should return false when the file does not exist", function() + assert.are.same(true, not Path:new("asdf"):is_file()) + end) - -- it("should show files as file", function() - -- assert.are.same(true, Path:new("README.md"):is_file()) - -- end) - -- end) + it_cross_plat("should show files as file", function() + assert.are.same(true, Path:new("README.md"):is_file()) + end) + end) describe(":new", function() - it("can be called with or without colon", function() + it_cross_plat("can be called with or without colon", function() -- This will work, cause we used a colon local with_colon = Path:new "lua" local no_colon = Path.new "lua" @@ -295,162 +297,171 @@ describe("Path", function() end) end) - -- describe(":make_relative", function() - -- it("can take absolute paths and make them relative to the cwd", function() - -- local p = Path:new { "lua", "plenary", "path.lua" } - -- local absolute = vim.loop.cwd() .. path.sep .. p.filename - -- local relative = Path:new(absolute):make_relative() - -- assert.are.same(relative, p.filename) - -- end) - - -- it("can take absolute paths and make them relative to a given path", function() - -- local root = path.sep == "\\" and "c:\\" or "/" - -- local r = Path:new { root, "home", "prime" } - -- local p = Path:new { "aoeu", "agen.lua" } - -- local absolute = r.filename .. path.sep .. p.filename - -- local relative = Path:new(absolute):make_relative(r.filename) - -- assert.are.same(relative, p.filename) - -- end) - - -- it("can take double separator absolute paths and make them relative to the cwd", function() - -- local p = Path:new { "lua", "plenary", "path.lua" } - -- local absolute = vim.loop.cwd() .. path.sep .. path.sep .. p.filename - -- local relative = Path:new(absolute):make_relative() - -- assert.are.same(relative, p.filename) - -- end) - - -- it("can take double separator absolute paths and make them relative to a given path", function() - -- local root = path.sep == "\\" and "c:\\" or "/" - -- local r = Path:new { root, "home", "prime" } - -- local p = Path:new { "aoeu", "agen.lua" } - -- local absolute = r.filename .. path.sep .. path.sep .. p.filename - -- local relative = Path:new(absolute):make_relative(r.filename) - -- assert.are.same(relative, p.filename) - -- end) - - -- it("can take absolute paths and make them relative to a given path with trailing separator", function() - -- local root = path.sep == "\\" and "c:\\" or "/" - -- local r = Path:new { root, "home", "prime" } - -- local p = Path:new { "aoeu", "agen.lua" } - -- local absolute = r.filename .. path.sep .. p.filename - -- local relative = Path:new(absolute):make_relative(r.filename .. path.sep) - -- assert.are.same(relative, p.filename) - -- end) - - -- it("can take absolute paths and make them relative to the root directory", function() - -- local root = path.sep == "\\" and "c:\\" or "/" - -- local p = Path:new { "home", "prime", "aoeu", "agen.lua" } - -- local absolute = root .. p.filename - -- local relative = Path:new(absolute):make_relative(root) - -- assert.are.same(relative, p.filename) - -- end) - - -- it("can take absolute paths and make them relative to themselves", function() - -- local root = path.sep == "\\" and "c:\\" or "/" - -- local p = Path:new { root, "home", "prime", "aoeu", "agen.lua" } - -- local relative = Path:new(p.filename):make_relative(p.filename) - -- assert.are.same(relative, ".") - -- end) - - -- it("should not truncate if path separator is not present after cwd", function() - -- local cwd = "tmp" .. path.sep .. "foo" - -- local p = Path:new { "tmp", "foo_bar", "fileb.lua" } - -- local relative = Path:new(p.filename):make_relative(cwd) - -- assert.are.same(p.filename, relative) - -- end) + describe(":make_relative", function() + local root = iswin and "c:\\" or "/" + it_cross_plat("can take absolute paths and make them relative to the cwd", function() + local p = Path:new { "lua", "plenary", "path.lua" } + local absolute = vim.loop.cwd() .. path.sep .. p.filename + local relative = Path:new(absolute):make_relative() + assert.are.same(p.filename, relative) + end) - -- it("should not truncate if path separator is not present after cwd and cwd ends in path sep", function() - -- local cwd = "tmp" .. path.sep .. "foo" .. path.sep - -- local p = Path:new { "tmp", "foo_bar", "fileb.lua" } - -- local relative = Path:new(p.filename):make_relative(cwd) - -- assert.are.same(p.filename, relative) - -- end) - -- end) + it_cross_plat("can take absolute paths and make them relative to a given path", function() + local r = Path:new { root, "home", "prime" } + local p = Path:new { "aoeu", "agen.lua" } + local absolute = r.filename .. path.sep .. p.filename + local relative = Path:new(absolute):make_relative(r.filename) + assert.are.same(relative, p.filename) + end) - -- describe(":normalize", function() - -- it("can take path that has one character directories", function() - -- local orig = "/home/j/./p//path.lua" - -- local final = Path:new(orig):normalize() - -- assert.are.same(final, "/home/j/p/path.lua") - -- end) + it_cross_plat("can take double separator absolute paths and make them relative to the cwd", function() + local p = Path:new { "lua", "plenary", "path.lua" } + local absolute = vim.loop.cwd() .. path.sep .. path.sep .. p.filename + local relative = Path:new(absolute):make_relative() + assert.are.same(relative, p.filename) + end) - -- it("can take paths with double separators change them to single separators", function() - -- local orig = "/lua//plenary/path.lua" - -- local final = Path:new(orig):normalize() - -- assert.are.same(final, "/lua/plenary/path.lua") - -- end) - -- -- this may be redundant since normalize just calls make_relative which is tested above - -- it("can take absolute paths with double seps" .. "and make them relative with single seps", function() - -- local orig = "/lua//plenary/path.lua" - -- local final = Path:new(orig):normalize() - -- assert.are.same(final, "/lua/plenary/path.lua") - -- end) + it_cross_plat("can take double separator absolute paths and make them relative to a given path", function() + local r = Path:new { root, "home", "prime" } + local p = Path:new { "aoeu", "agen.lua" } + local absolute = r.filename .. path.sep .. path.sep .. p.filename + local relative = Path:new(absolute):make_relative(r.filename) + assert.are.same(relative, p.filename) + end) - -- it("can remove the .. in paths", function() - -- local orig = "/lua//plenary/path.lua/foo/bar/../.." - -- local final = Path:new(orig):normalize() - -- assert.are.same(final, "/lua/plenary/path.lua") - -- end) + it_cross_plat("can take absolute paths and make them relative to a given path with trailing separator", function() + local r = Path:new { root, "home", "prime" } + local p = Path:new { "aoeu", "agen.lua" } + local absolute = r.filename .. path.sep .. p.filename + local relative = Path:new(absolute):make_relative(r.filename .. path.sep) + assert.are.same(relative, p.filename) + end) - -- it("can normalize relative paths", function() - -- assert.are.same(Path:new("lua/plenary/path.lua"):normalize(), "lua/plenary/path.lua") - -- end) + it_cross_plat("can take absolute paths and make them relative to the root directory", function() + local p = Path:new { "home", "prime", "aoeu", "agen.lua" } + local absolute = root .. p.filename + local relative = Path:new(absolute):make_relative(root) + assert.are.same(relative, p.filename) + end) - -- it("can normalize relative paths containing ..", function() - -- assert.are.same(Path:new("lua/plenary/path.lua/../path.lua"):normalize(), "lua/plenary/path.lua") - -- end) + it_cross_plat("can take absolute paths and make them relative to themselves", function() + local p = Path:new { root, "home", "prime", "aoeu", "agen.lua" } + local relative = Path:new(p.filename):make_relative(p.filename) + assert.are.same(relative, ".") + end) - -- it("can normalize relative paths with initial ..", function() - -- local p = Path:new "../lua/plenary/path.lua" - -- p._cwd = "/tmp/lua" - -- assert.are.same("lua/plenary/path.lua", p:normalize()) - -- end) + it_cross_plat("should not truncate if path separator is not present after cwd", function() + local cwd = "tmp" .. path.sep .. "foo" + local p = Path:new { "tmp", "foo_bar", "fileb.lua" } + local relative = Path:new(p.filename):make_relative(cwd) + assert.are.same(p.filename, relative) + end) - -- it("can normalize relative paths to absolute when initial .. count matches cwd parts", function() - -- local p = Path:new "../../tmp/lua/plenary/path.lua" - -- p._cwd = "/tmp/lua" - -- assert.are.same("/tmp/lua/plenary/path.lua", p:normalize()) - -- end) + it_cross_plat("should not truncate if path separator is not present after cwd and cwd ends in path sep", function() + local cwd = "tmp" .. path.sep .. "foo" .. path.sep + local p = Path:new { "tmp", "foo_bar", "fileb.lua" } + local relative = Path:new(p.filename):make_relative(cwd) + assert.are.same(p.filename, relative) + end) + end) - -- it("can normalize ~ when file is within home directory (trailing slash)", function() - -- local home = "/home/test/" - -- local p = Path:new { home, "./test_file" } - -- p.path.home = home - -- p._cwd = "/tmp/lua" - -- assert.are.same("~/test_file", p:normalize()) - -- end) + describe(":normalize", function() + local home = iswin and "C:/Users/test/" or "/home/test/" + local tmp_lua = iswin and "C:/Windows/Temp/lua" or "/tmp/lua" - -- it("can normalize ~ when file is within home directory (no trailing slash)", function() - -- local home = "/home/test" - -- local p = Path:new { home, "./test_file" } - -- p.path.home = home - -- p._cwd = "/tmp/lua" - -- assert.are.same("~/test_file", p:normalize()) - -- end) + it_cross_plat("can take path that has one character directories", function() + local orig = iswin and "C:/Users/j/./p//path.lua" or "/home/j/./p//path.lua" + local final = Path:new(orig):normalize() + local expect = plat_path(iswin and "C:/Users/j/p/path.lua" or "/home/j/p/path.lua") + assert.are.same(expect, final) + end) - -- it("handles usernames with a dash at the end", function() - -- local home = "/home/mattr-" - -- local p = Path:new { home, "test_file" } - -- p.path.home = home - -- p._cwd = "/tmp/lua" - -- assert.are.same("~/test_file", p:normalize()) - -- end) + it_cross_plat("can take paths with double separators change them to single separators", function() + local orig = "lua//plenary/path.lua" + local final = Path:new(orig):normalize() + local expect = plat_path("lua/plenary/path.lua") + assert.are.same(expect, final) + end) - -- it("handles filenames with the same prefix as the home directory", function() - -- local p = Path:new "/home/test.old/test_file" - -- p.path.home = "/home/test" - -- assert.are.same("/home/test.old/test_file", p:normalize()) - -- end) - -- end) + -- -- this may be redundant since normalize just calls make_relative which is tested above + -- it_cross_plat("can take absolute paths with double seps" .. "and make them relative with single seps", function() + -- local orig = "/lua//plenary/path.lua" + -- local final = Path:new(orig):normalize() + -- local expect = plat_path("/lua/plenary/path.lua") + -- assert.are.same(expect, final) + -- end) + + -- it_cross_plat("can remove the .. in paths", function() + -- local orig = "/lua//plenary/path.lua/foo/bar/../.." + -- local final = Path:new(orig):normalize() + -- local expect = plat_path("/lua/plenary/path.lua") + -- assert.are.same(expect, final) + -- end) + + -- it_cross_plat("can normalize relative paths", function() + -- local orig = "lua/plenary/path.lua" + -- local final = Path:new(orig):normalize() + -- local expect = plat_path(orig) + -- assert.are.same(expect, final) + -- end) + + -- it_cross_plat("can normalize relative paths containing ..", function() + -- local orig = "lua/plenary/path.lua/../path.lua" + -- local final = Path:new(orig):normalize() + -- local expect = plat_path("lua/plenary/path.lua") + -- assert.are.same(expect, final) + -- end) + + -- it_cross_plat("can normalize relative paths with initial ..", function() + -- local p = Path:new "../lua/plenary/path.lua" + -- p._cwd = tmp_lua + -- local expect = plat_path("lua/plenary/path.lua") + -- assert.are.same(expect, p:normalize()) + -- end) + + -- it_cross_plat("can normalize relative paths to absolute when initial .. count matches cwd parts", function() + -- local p = Path:new "../../tmp/lua/plenary/path.lua" + -- p._cwd = "/tmp/lua" + -- assert.are.same("/tmp/lua/plenary/path.lua", p:normalize()) + -- end) + + -- it_cross_plat("can normalize ~ when file is within home directory (trailing slash)", function() + -- local p = Path:new { home, "./test_file" } + -- p.path.home = home + -- p._cwd = tmp_lua + -- assert.are.same("~/test_file", p:normalize()) + -- end) + + -- it_cross_plat("can normalize ~ when file is within home directory (no trailing slash)", function() + -- local p = Path:new { home, "./test_file" } + -- p.path.home = home + -- p._cwd = tmp_lua + -- assert.are.same("~/test_file", p:normalize()) + -- end) + + -- it_cross_plat("handles usernames with a dash at the end", function() + -- local p = Path:new { home, "test_file" } + -- p.path.home = home + -- p._cwd = tmp_lua + -- assert.are.same("~/test_file", p:normalize()) + -- end) + + -- it_cross_plat("handles filenames with the same prefix as the home directory", function() + -- local pstr = iswin and "C:/Users/test.old/test_file" or "/home/test.old/test_file" + -- local p = Path:new(pstr) + -- p.path.home = home + -- assert.are.same(pstr, p:normalize()) + -- end) + end) -- describe(":shorten", function() - -- it("can shorten a path", function() + -- it_cross_plat("can shorten a path", function() -- local long_path = "/this/is/a/long/path" -- local short_path = Path:new(long_path):shorten() -- assert.are.same(short_path, "/t/i/a/l/path") -- end) - -- it("can shorten a path's components to a given length", function() + -- it_cross_plat("can shorten a path's components to a given length", function() -- local long_path = "/this/is/a/long/path" -- local short_path = Path:new(long_path):shorten(2) -- assert.are.same(short_path, "/th/is/a/lo/path") @@ -466,7 +477,7 @@ describe("Path", function() -- assert.are.same(short_path, "this/is/an/extre/long/path") -- end) - -- it("can shorten a path's components when excluding parts", function() + -- it_cross_plat("can shorten a path's components when excluding parts", function() -- local long_path = "/this/is/a/long/path" -- local short_path = Path:new(long_path):shorten(nil, { 1, -1 }) -- assert.are.same(short_path, "/this/i/a/l/path") @@ -487,7 +498,7 @@ describe("Path", function() -- assert.are.same(short_path, "this/i/an/e/long/p") -- end) - -- it("can shorten a path's components to a given length and exclude positions", function() + -- it_cross_plat("can shorten a path's components to a given length and exclude positions", function() -- local long_path = "/this/is/a/long/path" -- local short_path = Path:new(long_path):shorten(2, { 1, -1 }) -- assert.are.same(short_path, "/this/is/a/lo/path") @@ -503,7 +514,7 @@ describe("Path", function() -- end) -- describe("mkdir / rmdir", function() - -- it("can create and delete directories", function() + -- it_cross_plat("can create and delete directories", function() -- local p = Path:new "_dir_not_exist" -- p:rmdir() @@ -516,18 +527,18 @@ describe("Path", function() -- assert(not p:exists()) -- end) - -- it("fails when exists_ok is false", function() + -- it_cross_plat("fails when exists_ok is false", function() -- local p = Path:new "lua" -- assert(not pcall(p.mkdir, p, { exists_ok = false })) -- end) - -- it("fails when parents is not passed", function() + -- it_cross_plat("fails when parents is not passed", function() -- local p = Path:new("impossible", "dir") -- assert(not pcall(p.mkdir, p, { parents = false })) -- assert(not p:exists()) -- end) - -- it("can create nested directories", function() + -- it_cross_plat("can create nested directories", function() -- local p = Path:new("impossible", "dir") -- assert(pcall(p.mkdir, p, { parents = true })) -- assert(p:exists()) @@ -540,7 +551,7 @@ describe("Path", function() -- end) -- describe("touch", function() - -- it("can create and delete new files", function() + -- it_cross_plat("can create and delete new files", function() -- local p = Path:new "test_file.lua" -- assert(pcall(p.touch, p)) -- assert(p:exists()) @@ -549,7 +560,7 @@ describe("Path", function() -- assert(not p:exists()) -- end) - -- it("does not effect already created files but updates last access", function() + -- it_cross_plat("does not effect already created files but updates last access", function() -- local p = Path:new "README.md" -- local last_atime = p:_stat().atime.sec -- local last_mtime = p:_stat().mtime.sec @@ -564,13 +575,13 @@ describe("Path", function() -- assert.are.same(lines, p:readlines()) -- end) - -- it("does not create dirs if nested in none existing dirs and parents not set", function() + -- it_cross_plat("does not create dirs if nested in none existing dirs and parents not set", function() -- local p = Path:new { "nested", "nested2", "test_file.lua" } -- assert(not pcall(p.touch, p, { parents = false })) -- assert(not p:exists()) -- end) - -- it("does create dirs if nested in none existing dirs", function() + -- it_cross_plat("does create dirs if nested in none existing dirs", function() -- local p1 = Path:new { "nested", "nested2", "test_file.lua" } -- local p2 = Path:new { "nested", "asdf", ".hidden" } -- local d1 = Path:new { "nested", "dir", ".hidden" } @@ -590,7 +601,7 @@ describe("Path", function() -- end) -- describe("rename", function() - -- it("can rename a file", function() + -- it_cross_plat("can rename a file", function() -- local p = Path:new "a_random_filename.lua" -- assert(pcall(p.touch, p)) -- assert(p:exists()) @@ -601,7 +612,7 @@ describe("Path", function() -- p:rm() -- end) - -- it("can handle an invalid filename", function() + -- it_cross_plat("can handle an invalid filename", function() -- local p = Path:new "some_random_filename.lua" -- assert(pcall(p.touch, p)) -- assert(p:exists()) @@ -613,7 +624,7 @@ describe("Path", function() -- p:rm() -- end) - -- it("can move to parent dir", function() + -- it_cross_plat("can move to parent dir", function() -- local p = Path:new "some_random_filename.lua" -- assert(pcall(p.touch, p)) -- assert(p:exists()) @@ -624,7 +635,7 @@ describe("Path", function() -- p:rm() -- end) - -- it("cannot rename to an existing filename", function() + -- it_cross_plat("cannot rename to an existing filename", function() -- local p1 = Path:new "a_random_filename.lua" -- local p2 = Path:new "not_a_random_filename.lua" -- assert(pcall(p1.touch, p1)) @@ -641,7 +652,7 @@ describe("Path", function() -- end) -- describe("copy", function() - -- it("can copy a file", function() + -- it_cross_plat("can copy a file", function() -- local p1 = Path:new "a_random_filename.rs" -- local p2 = Path:new "not_a_random_filename.rs" -- assert(pcall(p1.touch, p1)) @@ -655,7 +666,7 @@ describe("Path", function() -- p2:rm() -- end) - -- it("can copy to parent dir", function() + -- it_cross_plat("can copy to parent dir", function() -- local p = Path:new "some_random_filename.lua" -- assert(pcall(p.touch, p)) -- assert(p:exists()) @@ -667,7 +678,7 @@ describe("Path", function() -- Path:new(vim.loop.fs_realpath "../some_random_filename.lua"):rm() -- end) - -- it("cannot copy an existing file if override false", function() + -- it_cross_plat("cannot copy an existing file if override false", function() -- local p1 = Path:new "a_random_filename.rs" -- local p2 = Path:new "not_a_random_filename.rs" -- assert(pcall(p1.touch, p1)) @@ -683,7 +694,7 @@ describe("Path", function() -- p2:rm() -- end) - -- it("fails when copying folders non-recursively", function() + -- it_cross_plat("fails when copying folders non-recursively", function() -- local src_dir = Path:new "src" -- src_dir:mkdir() -- src_dir:joinpath("file1.lua"):touch() @@ -698,7 +709,7 @@ describe("Path", function() -- src_dir:rm { recursive = true } -- end) - -- it("can copy directories recursively", function() + -- it_cross_plat("can copy directories recursively", function() -- -- vim.tbl_flatten doesn't work here as copy doesn't return a list -- local flatten -- flatten = function(ret, t) @@ -789,7 +800,7 @@ describe("Path", function() -- end) -- describe("parents", function() - -- it("should extract the ancestors of the path", function() + -- it_cross_plat("should extract the ancestors of the path", function() -- local p = Path:new(vim.loop.cwd()) -- local parents = p:parents() -- assert(compat.islist(parents)) @@ -797,14 +808,14 @@ describe("Path", function() -- assert.are.same(type(parent), "string") -- end -- end) - -- it("should return itself if it corresponds to path.root", function() + -- it_cross_plat("should return itself if it corresponds to path.root", function() -- local p = Path:new(Path.path.root(vim.loop.cwd())) -- assert.are.same(p:parent(), p) -- end) -- end) -- describe("read parts", function() - -- it("should read head of file", function() + -- it_cross_plat("should read head of file", function() -- local p = Path:new "LICENSE" -- local data = p:head() -- local should = [[MIT License @@ -820,14 +831,14 @@ describe("Path", function() -- assert.are.same(should, data) -- end) - -- it("should read the first line of file", function() + -- it_cross_plat("should read the first line of file", function() -- local p = Path:new "LICENSE" -- local data = p:head(1) -- local should = [[MIT License]] -- assert.are.same(should, data) -- end) - -- it("head should max read whole file", function() + -- it_cross_plat("head should max read whole file", function() -- local p = Path:new "LICENSE" -- local data = p:head(1000) -- local should = [[MIT License @@ -854,7 +865,7 @@ describe("Path", function() -- assert.are.same(should, data) -- end) - -- it("should read tail of file", function() + -- it_cross_plat("should read tail of file", function() -- local p = Path:new "LICENSE" -- local data = p:tail() -- local should = [[The above copyright notice and this permission notice shall be included in all @@ -870,14 +881,14 @@ describe("Path", function() -- assert.are.same(should, data) -- end) - -- it("should read the last line of file", function() + -- it_cross_plat("should read the last line of file", function() -- local p = Path:new "LICENSE" -- local data = p:tail(1) -- local should = [[SOFTWARE.]] -- assert.are.same(should, data) -- end) - -- it("tail should max read whole file", function() + -- it_cross_plat("tail should max read whole file", function() -- local p = Path:new "LICENSE" -- local data = p:tail(1000) -- local should = [[MIT License @@ -906,14 +917,14 @@ describe("Path", function() -- end) -- describe("readbyterange", function() - -- it("should read bytes at given offset", function() + -- it_cross_plat("should read bytes at given offset", function() -- local p = Path:new "LICENSE" -- local data = p:readbyterange(13, 10) -- local should = "Copyright " -- assert.are.same(should, data) -- end) - -- it("supports negative offset", function() + -- it_cross_plat("supports negative offset", function() -- local p = Path:new "LICENSE" -- local data = p:readbyterange(-10, 10) -- local should = "SOFTWARE.\n" From 977f05740cdbd166a0290a6869c11b90398158f5 Mon Sep 17 00:00:00 2001 From: James Trew Date: Sat, 24 Aug 2024 21:59:07 -0400 Subject: [PATCH 03/43] new new Path start --- lua/plenary/path2.lua | 827 +++++++++++-------------------- lua/plenary/path4.lua | 672 ++++++++++++++++++++++++++ tests/plenary/path2_spec.lua | 737 +--------------------------- tests/plenary/path4_spec.lua | 910 +++++++++++++++++++++++++++++++++++ 4 files changed, 1879 insertions(+), 1267 deletions(-) create mode 100644 lua/plenary/path4.lua create mode 100644 tests/plenary/path4_spec.lua diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 77e53afa..c882496e 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -1,69 +1,127 @@ ---[[ -- [x] path -- [x] path.home -- [x] path.sep -- [x] path.root -- [x] path.S_IF - - [ ] band - - [ ] concat_paths - - [ ] is_root - - [ ] _split_by_separator - - [ ] is_uri - - [ ] is_absolute - - [ ] _normalize_path - - [ ] clean -- [x] Path -- [ ] check_self -- [x] Path.__index -- [x] Path.__div -- [x] Path.__tostring -- [x] Path.__concat -- [x] Path.is_path -- [x] Path:new -- [x] Path:_fs_filename -- [x] Path:_stat -- [x] Path:_st_mode -- [x] Path:joinpath -- [x] Path:absolute -- [x] Path:exists -- [ ] Path:expand -- [x] Path:make_relative -- [ ] Path:normalize -- [ ] shorten_len -- [ ] shorten -- [ ] Path:shorten -- [ ] Path:mkdir -- [ ] Path:rmdir -- [ ] Path:rename -- [ ] Path:copy -- [ ] Path:touch -- [ ] Path:rm -- [ ] Path:is_dir -- [x] Path:is_absolute -- [ ] Path:_split - - [ ] _get_parent -- [ ] Path:parent -- [ ] Path:parents -- [ ] Path:is_file -- [ ] Path:open -- [ ] Path:close -- [ ] Path:write -- [ ] Path:_read -- [ ] Path:_read_async -- [ ] Path:read -- [ ] Path:head -- [ ] Path:tail -- [ ] Path:readlines -- [ ] Path:iter -- [ ] Path:readbyterange --[ ] Path:find_upwards -]] - local uv = vim.loop - local iswin = uv.os_uname().sysname == "Windows_NT" local hasshellslash = vim.fn.exists "+shellslash" == 1 +---@class plenary._Path +---@field sep string +---@field altsep string +---@field has_drv boolean +---@field convert_altsep fun(self: plenary._Path, p:string): string +---@field split_root fun(self: plenary._Path, part:string): string, string, string + +---@class plenary._WindowsPath : plenary._Path +local _WindowsPath = { + sep = "\\", + altsep = "/", + has_drv = true, +} + +setmetatable(_WindowsPath, { __index = _WindowsPath }) + +---@param p string +---@return string +function _WindowsPath:convert_altsep(p) + return (p:gsub(self.altsep, self.sep)) +end + +---@param part string path +---@return string drv +---@return string root +---@return string relpath +function _WindowsPath:split_root(part) + -- https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats + local prefix = "" + local first, second = part:sub(1, 1), part:sub(2, 2) + + if first == self.sep and second == self.sep then + prefix, part = self:_split_extended_path(part) + first, second = part:sub(1, 1), part:sub(2, 2) + end + + local third = part:sub(3, 3) + + if first == self.sep and second == self.sep and third ~= self.sep then + -- is a UNC path: + -- vvvvvvvvvvvvvvvvvvvvv root + -- \\machine\mountpoint\directory\etc\... + -- directory ^^^^^^^^^^^^^^ + + local index = part:find(self.sep, 3) + if index ~= nil then + local index2 = part:find(self.sep, index + 1) + if index2 ~= index + 1 then + if index2 == nil then + index2 = #part + end + + if prefix ~= "" then + return prefix + part:sub(2, index2 - 1), self.sep, part:sub(index2 + 1) + else + return part:sub(1, index2 - 1), self.sep, part:sub(index2 + 1) + end + end + end + end + + local drv, root = "", "" + if second == ":" and first:match "%a" then + drv, part = part:sub(1, 2), part:sub(3) + first = third + end + + if first == self.sep then + root = first + part = part:gsub("^" .. self.sep .. "+", "") + end + + return prefix .. drv, root, part +end + +---@param p string path +---@return string +---@return string +function _WindowsPath:_split_extended_path(p) + local ext_prefix = [[\\?\]] + local prefix = "" + + if p:sub(1, #ext_prefix) == ext_prefix then + prefix = p:sub(1, 4) + p = p:sub(5) + if p:sub(1, 3) == "UNC" .. self.sep then + prefix = prefix .. p:sub(1, 3) + p = self.sep .. p:sub(4) + end + end + + return prefix, p +end + +---@class plenary._PosixPath : plenary._Path +local _PosixPath = { + sep = "/", + altsep = "", + has_drv = false, +} +setmetatable(_PosixPath, { __index = _PosixPath }) + +---@param p string +---@return string +function _PosixPath:convert_altsep(p) + return p +end + +---@param part string path +---@return string drv +---@return string root +---@return string relpath +function _PosixPath:split_root(part) + if part:sub(1) == self.sep then + part = (part:gsub("^" .. self.sep, "")) + return "", self.sep, part:sub(2, #part) + end + return "", "", part +end + local S_IF = { -- S_IFDIR = 0o040000 # directory DIR = 0x4000, @@ -71,13 +129,10 @@ local S_IF = { REG = 0x8000, } ----@class plenary.path +---@class plenary.path2 ---@field home string home directory path ---@field sep string OS path separator respecting 'shellslash' ---- ---- OS separator for paths returned by libuv functions. ---- Note: libuv will happily take either path separator regardless of 'shellslash'. ----@field private _uv_sep string +---@field isshellslash boolean whether shellslash is on (always false on unix systems) --- --- get the root directory path. --- On Windows, this is determined from the current working directory in order @@ -88,7 +143,6 @@ local S_IF = { local path = setmetatable({ home = vim.fn.getcwd(), -- respects shellslash unlike vim.uv.cwd() S_IF = S_IF, - _uv_sep = iswin and "\\" or "/", }, { __index = function(t, k) local raw = rawget(t, k) @@ -96,13 +150,17 @@ local path = setmetatable({ return raw end + if k == "isshellslash" then + return (hasshellslash and vim.o.shellslash) + end + if k == "sep" then if not iswin then t.sep = "/" return t.sep end - return (hasshellslash and vim.o.shellslash) and "/" or "\\" + return t.isshellslash and "/" or "\\" end end, }) @@ -115,236 +173,78 @@ path.root = (function() else return function(base) base = base or path.home - local disk = base:match "^[%a]:" - if disk then - return disk .. path.sep - end - return string.rep(path.sep, 2) -- UNC + local _, root, _ = _WindowsPath:split_root(base) + return root end end end)() ---- WARNING: Should really avoid using this. It's more like ---- `maybe_uri_maybe_not`. There are both false positives and false negative ---- edge cases. ---- ---- Approximates if a filename is a valid URI by checking if the filename ---- starts with a plausible scheme. ---- ---- A valid URI scheme begins with a letter, followed by any number of letters, ---- numbers and `+`, `.`, `-` and ends with a `:`. ---- ---- To disambiguate URI schemes from Windows path, we also check up to 2 ---- characters after the `:` to make sure it's followed by `//`. ---- ---- Two major caveats according to our checks: ---- - a "valid" URI is also a valid unix relative path so any relative unix ---- path that's in the shape of a URI according to our check will be flagged ---- as a URI. ---- - relative Windows paths like `C:Projects/apilibrary/apilibrary.sln` will ---- be caught as a URI. ---- ----@param filename string ----@return boolean -local function is_uri(filename) - local ch = filename:byte(1) or 0 - - -- is not alpha? - if not ((ch >= 97 and ch <= 122) or (ch >= 65 and ch <= 90)) then - return false - end - - local scheme_end = 0 - for i = 2, #filename do - ch = filename:byte(i) - if - (ch >= 97 and ch <= 122) -- a-z - or (ch >= 65 and ch <= 90) -- A-Z - or (ch >= 48 and ch <= 57) -- 0-9 - or ch == 43 -- `+` - or ch == 46 -- `.` - or ch == 45 -- `-` - then -- luacheck: ignore 542 - -- pass - elseif ch == 58 then - scheme_end = i - break +---@param parts string[] +---@param _path plenary._Path +---@return string drv +---@return string root +---@return string[] +local function parse_parts(parts, _path) + local drv, root, rel, parsed = "", "", "", {} + + for i = #parts, 1, -1 do + local part = parts[i] + part = _path:convert_altsep(part) + + drv, root, rel = _path:split_root(part) + + if rel:match(_path.sep) then + local relparts = vim.split(rel, _path.sep) + for j = #relparts, 1, -1 do + local p = relparts[j] + if p ~= "" and p ~= "." then + table.insert(parsed, p) + end + end else - return false - end - end - - if scheme_end == 0 then - return false - end - - local next = filename:byte(scheme_end + 1) or 0 - if next == 0 then - -- nothing following the scheme - return false - elseif next == 92 then -- `\` - -- could be Windows absolute path but not a uri - return false - elseif next == 47 and (filename:byte(scheme_end + 2) or 0) ~= 47 then -- `/` - -- still could be Windows absolute path using `/` seps but not a uri - return false - end - return true -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 p string Path to split. ---- @return string, string, boolean : prefix, body, whether path is invalid. -local function split_windows_path(p) - 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 = p:match(pattern) - - if match then - prefix = prefix .. match --[[ @as string ]] - p = p: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, p, false - end - elseif match_to_prefix "^//" then - -- Process UNC path, return early if it's invalid - if not process_unc_path() then - return prefix, p, false + if rel ~= "" and rel ~= "." then + table.insert(parsed, rel) + end end - elseif p:match "^%w:" then - -- Drive paths - prefix, p = p:sub(1, 2), p: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) - p = trailing_slash .. p --[[ @as string ]] - end - return prefix, p, 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 p string Path to resolve. ----@return string # Resolved path. -local function path_resolve_dot(p) - local is_path_absolute = vim.startswith(p, "/") - local new_path_components = {} - - for component in vim.gsplit(p, "/") do - if component == "." or component == "" then - -- 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 - -- 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) + if drv or root then + if not drv then + for k = #parts, 1, -1 do + local p = parts[k] + p = _path:convert_altsep(p) + drv = _path:split_root(p) + if drv then + break + end + end + + break end - else - table.insert(new_path_components, component) end end - return (is_path_absolute and "/" or "") .. table.concat(new_path_components, "/") -end - ---- Resolves '.' and '..' in the path, removes extra path separator. ---- ---- For Windows, converts separator `\` to `/` to simplify many operations. ---- ---- Credit to famiu. This is basically neovim core `vim.fs.normalize`. ----@param p string path ----@return string -local function normalize_path(p) - if p == "" or is_uri(p) then - return p - end - - if iswin then - p = p:gsub("\\", "/") + if drv or root then + table.insert(parsed, drv .. root) end - local double_slash = vim.startswith(p, "//") and not vim.startswith(p, "///") - local prefix = "" - - if iswin then - local valid - prefix, p, valid = split_windows_path(p) - if not valid then - return prefix .. p - end - prefix = prefix:gsub("/+", "/") - end - - p = path_resolve_dot(p) - p = (double_slash and "/" or "") .. prefix .. p - - if p == "" then - p = "." + local n = #parsed + for i = 1, math.floor(n / 2) do + parsed[i], parsed[n - i + 1] = parsed[n - i + 1], parsed[i] end - return p + return drv, root, parsed end ----@class plenary.Path ----@field path plenary.path ----@field filename string path as a string +---@class plenary.Path2 +---@field path plenary.path2 +---@field private _path plenary._Path +---@field drv string drive name, eg. 'C:' (only for Windows) +---@field root string root path (excludes drive name) +---@field parts string[] path parts excluding separators --- ---- internal string representation of the path that's normalized and uses `/` ---- as path separator. makes many other operations much easier to work with. ----@field private _name string ----@field private _sep string path separator taking into account 'shellslash' on windows ----@field private _absolute string? absolute path ----@field private _cwd string? cwd path ----@field private _fs_stat table fs_stat -local Path = { - path = path, -} +---@field filename string +---@field private _absolute string? lazy eval'ed fully resolved absolute path +local Path = { path = path } Path.__index = function(t, k) local raw = rawget(Path, k) @@ -352,149 +252,50 @@ Path.__index = function(t, k) return raw end - if k == "_cwd" then - local cwd = uv.fs_realpath "." - if cwd ~= nil then - cwd = (cwd:gsub(path._uv_sep, "/")) - end - t._cwd = cwd - return t._cwd + if k == "filename" then + t.filename = t:_filename() + return t.filename end - - if k == "_absolute" then - local absolute = uv.fs_realpath(t._name) - if absolute ~= nil then - absolute = (absolute:gsub(path._uv_sep, "/")) - end - t._absolute = absolute - return absolute - end - - if k == "_fs_stat" then - t._fs_stat = uv.fs_stat(t._absolute or t._name) or {} - return t._fs_stat - end -end - ----@param other plenary.Path|string ----@return plenary.Path -Path.__div = function(self, other) - assert(Path.is_path(self)) - assert(Path.is_path(other) or type(other) == "string") - - return self:joinpath(other) -end - ----@return string -Path.__tostring = function(self) - return self._name -end - --- TODO: See where we concat the table, and maybe we could make this work. -Path.__concat = function(self, other) - return self.filename .. other end -Path.is_path = function(a) - return getmetatable(a) == Path -end +---@alias plenary.Path2Args string|plenary.Path2|(string|plenary.Path2)[] ----@param parts string[] ----@param sep string ----@return string -local function unix_path_str(parts, sep) - -- any sep other than `/` is not a valid sep but allowing for backwards compat reasons - local flat_parts = {} - for _, part in ipairs(parts) do - vim.list_extend(flat_parts, vim.split(part, sep)) - end - - return (table.concat(flat_parts, sep):gsub(sep .. "+", sep)) -end - ----@param parts string[] ----@param sep string ----@return string -local function windows_path_str(parts, sep) - local disk = parts[1]:match "^[%a]:" - local is_disk_root = parts[1]:match "^[%a]:[\\/]" ~= nil - local is_unc = parts[1]:match "^\\\\" or parts[1]:match "^//" - - local flat_parts = {} - for _, part in ipairs(parts) do - vim.list_extend(flat_parts, vim.split(part, "[\\/]")) - end - - if not is_disk_root and flat_parts[1] == disk then - table.remove(flat_parts, 1) - local p = disk .. table.concat(flat_parts, sep) - return (p:gsub(sep .. "+", sep)) - end - if is_unc then - table.remove(flat_parts, 1) - table.remove(flat_parts, 1) - local body = (table.concat(flat_parts, sep):gsub(sep .. "+", sep)) - return sep .. sep .. body - end - return (table.concat(flat_parts, sep):gsub(sep .. "+", sep)) -end - ----@return plenary.Path +---@param ... plenary.Path2Args +---@return plenary.Path2 function Path:new(...) local args = { ... } - if type(self) == "string" then - table.insert(args, 1, self) - self = Path - end - - local path_input if #args == 1 then - if Path.is_path(args[1]) then - local p = args[1] ---@cast p plenary.Path - return p + local arg = args[1] + if type(arg) == "table" and not self.is_path(arg) then + args = arg + elseif type(arg) ~= "string" and not self.is_path(arg) then + error( + string.format( + "Invalid type passed to 'Path:new'. Expects any number of 'string' or 'Path' objects. Got type '%s', shape '%s'", + type(arg), + vim.inspect(arg) + ) + ) end - if type(args[1]) == "table" then - path_input = args[1] - else - assert(type(args[1]) == "string", "unexpected path input\n" .. vim.inspect(path_input)) - path_input = args - end - else - path_input = args end - assert(type(path_input) == "table", vim.inspect(path_input)) - ---@cast path_input {[integer]: (string)|plenary.Path, sep: string?} - - local sep = path.sep - sep = path_input.sep or path.sep - path_input.sep = nil - path_input = vim.tbl_map(function(part) - if Path.is_path(part) then - return part.filename + local parts = {} + for _, a in ipairs(args) do + if self.is_path(a) then + vim.list_extend(parts, a.parts) else - assert(type(part) == "string", vim.inspect(path_input)) - return vim.trim(part) + if a ~= "" then + table.insert(parts, a) + end end - end, path_input) - - assert(#path_input > 0, "can't create Path out of nothing") - - local path_string - if iswin then - path_string = windows_path_str(path_input, sep) - else - path_string = unix_path_str(path_input, sep) end - local proxy = { - -- precompute normalized path using `/` as sep - _name = normalize_path(path_string), - filename = path_string, - _sep = sep, - } + local _path = iswin and _WindowsPath or _PosixPath + local drv, root + drv, root, parts = parse_parts(parts, _path) + local proxy = { _path = _path, drv = drv, root = root, parts = parts } setmetatable(proxy, Path) local obj = { __inner = proxy } @@ -502,32 +303,12 @@ function Path:new(...) __index = function(_, k) return proxy[k] end, - __newindex = function(t, k, val) - if k == "filename" then - proxy.filename = val - proxy._name = normalize_path(val) - proxy._absolute = nil - proxy._fs_stat = nil - elseif k == "_name" then - proxy.filename = (val:gsub("/", t._sep)) - proxy._name = val - proxy._absolute = nil - proxy._fs_stat = nil - else + __newindex = function(_, k, val) + if k == "_absolute" then proxy[k] = val + return end - end, - ---@return plenary.Path - __div = function(t, other) - return Path.__div(t, other) - end, - ---@return string - __concat = function(t, other) - return Path.__concat(t, other) - end, - ---@return string - __tostring = function(t) - return Path.__tostring(t) + error "'Path' object is read-only" end, __metatable = Path, }) @@ -535,144 +316,96 @@ function Path:new(...) return obj end ----@return string -function Path:absolute() - if self:is_absolute() then - return (self._name:gsub("/", self._sep)) - end - return (normalize_path(self._cwd .. self._sep .. self._name):gsub("/", self._sep)) +---@param x any +---@return boolean +function Path.is_path(x) + return getmetatable(x) == Path end +---@private +---@param drv string? +---@param root string? +---@param parts string[]? ---@return string -function Path:_fs_filename() - return self:absolute() or self.filename -end +function Path:_filename(drv, root, parts) + drv = vim.F.if_nil(drv, self.drv) + drv = self.drv ~= "" and self.drv:gsub(self._path.sep, path.sep) or "" ----@return table -function Path:_stat() - return self._fs_stat -end + if self._path.has_drv and drv == "" then + root = "" + else + root = vim.F.if_nil(root, self.root) + root = self.root ~= "" and path.sep:rep(#self.root) or "" + end ----@return number -function Path:_st_mode() - return self:_stat().mode or 0 -end + parts = vim.F.if_nil(parts, self.parts) + local relparts = table.concat(vim.list_slice(parts, 2), path.sep) ----@return boolean -function Path:exists() - return not vim.tbl_isempty(self:_stat()) + return drv .. root .. relparts end ----@return boolean -function Path:is_dir() - return self:_stat().type == "directory" -end - ----@return boolean -function Path:is_file() - return self:_stat().type == "file" -end - ---- For POSIX path, anything starting with a `/` is considered a absolute path. ---- ---- ---- For Windows, it's a little more involved. ---- ---- Disk names are single letters. They MUST be followed by a `:` + separator to be ---- considered an absolute path. eg. ---- C:\Documents\Newsletters\Summer2018.pdf -> An absolute file path from the root of drive C:. - ---- UNC paths are also considered absolute. eg. \\Server2\Share\Test\Foo.txt ---- ---- Any other valid paths are relative. eg. ---- C:Projects\apilibrary\apilibrary.sln -> A relative path from the current directory of the C: drive. ---- 2018\January.xlsx -> A relative path to a file in a subdirectory of the current directory. ---- \Program Files\Custom Utilities\StringFinder.exe -> A relative path from the root of the current drive. ---- ..\Publications\TravelBrochure.pdf -> A relative path to a file in a directory starting from the current directory. ---@return boolean function Path:is_absolute() - if not iswin then - return string.sub(self._name, 1, 1) == "/" - end - - if string.match(self._name, "^[%a]:/.*$") ~= nil then - return true - elseif string.match(self._name, "^//") then - return true + if self.root == "" then + return false end - return false -end - ----@return plenary.Path -function Path:joinpath(...) - return Path:new(self._name, ...) + return self._path.has_drv and self.drv ~= "" end ---- Make path relative to another. ---- ---- No-op if path is a URI. ----@param cwd string? path to make relative to (default: cwd) ----@return string # new filename -function Path:make_relative(cwd) - if is_uri(self._name) then - return self.filename - end - - cwd = Path:new(vim.F.if_nil(cwd, self._cwd))._name - - if self._name == cwd then - self._name = "." - return self.filename - end - - if cwd:sub(#cwd, #cwd) ~= "/" then - cwd = cwd .. "/" - end - - if not self:is_absolute() then - self._name = normalize_path(cwd .. self._name) +---@param parts string[] path parts +---@return string[] +local function resolve_dots(parts) + local new_parts = {} + for _, part in ipairs(parts) do + if part == ".." then + if #new_parts > 0 and new_parts[#new_parts] ~= ".." then + table.remove(new_parts) + elseif #new_parts == 0 then + table.insert(new_parts, part) + end + else + table.insert(new_parts, part) + end end - -- TODO: doesn't handle distant relative cwd well - -- eg. cwd = '/tmp/foo' and path = '/home/user/bar' - -- would be something like '/tmp/foo/../../home/user/bar'? - -- I'm not even sure, check later - if self._name:sub(1, #cwd) == cwd then - self._name = self._name:sub(#cwd + 1, -1) - else - self._name = normalize_path(self.filename) - end - return self.filename + return new_parts end ---- Makes the path relative to cwd or provided path and resolves any internal ---- '.' and '..' in relative paths according. Substitutes home directory ---- with `~` if applicable. Deduplicates path separators and trims any trailing ---- separators. ---- ---- No-op if path is a URI. ----@param cwd string? path to make relative to (default: cwd) +--- normalized and resolved absolute path +--- respects 'shellslash' on Windows ---@return string -function Path:normalize(cwd) - if is_uri(self._name) then - return self.filename - end - - print(self.filename, self._name) - self:make_relative(cwd) - - local home = path.home - if home:sub(-1) ~= self._sep then - home = home .. self._sep +function Path:absolute() + if self._absolute then + return self._absolute end - local start, finish = self._name:find(home, 1, true) - if start == 1 then - self._name = "~/" .. self._name:sub(finish + 1, -1) + local parts = resolve_dots(self.parts) + local filename = self:_filename(self.drv, self.root, parts) + if self:is_absolute() then + self._absolute = filename + else + -- using fs_realpath over fnamemodify + -- fs_realpath resolves symlinks whereas fnamemodify doesn't but we're + -- resolving/normalizing the path anyways for reasons of compat with old Path + self._absolute = uv.fs_realpath(self:_filename()) + if self.path.isshellslash then + self._absolute = self._absolute:gsub("\\", path.sep) + end end + return self._absolute +end - return self.filename +---@param ... plenary.Path2Args +---@return plenary.Path2 +function Path:joinpath(...) + return Path:new { self, ... } end +-- vim.o.shellslash = false +local p = Path:new("lua"):joinpath "plenary" +-- vim.print(p) +-- print(p.filename, p:is_absolute(), p:absolute()) +-- vim.o.shellslash = true + return Path diff --git a/lua/plenary/path4.lua b/lua/plenary/path4.lua new file mode 100644 index 00000000..81b7bcd1 --- /dev/null +++ b/lua/plenary/path4.lua @@ -0,0 +1,672 @@ +--[[ +- [x] path +- [x] path.home +- [x] path.sep +- [x] path.root +- [x] path.S_IF + - [ ] band + - [ ] concat_paths + - [ ] is_root + - [ ] _split_by_separator + - [ ] is_uri + - [ ] is_absolute + - [ ] _normalize_path + - [ ] clean +- [x] Path +- [ ] check_self +- [x] Path.__index +- [x] Path.__div +- [x] Path.__tostring +- [x] Path.__concat +- [x] Path.is_path +- [x] Path:new +- [x] Path:_fs_filename +- [x] Path:_stat +- [x] Path:_st_mode +- [x] Path:joinpath +- [x] Path:absolute +- [x] Path:exists +- [ ] Path:expand +- [x] Path:make_relative +- [ ] Path:normalize +- [ ] shorten_len +- [ ] shorten +- [ ] Path:shorten +- [ ] Path:mkdir +- [ ] Path:rmdir +- [ ] Path:rename +- [ ] Path:copy +- [ ] Path:touch +- [ ] Path:rm +- [ ] Path:is_dir +- [x] Path:is_absolute +- [ ] Path:_split + - [ ] _get_parent +- [ ] Path:parent +- [ ] Path:parents +- [ ] Path:is_file +- [ ] Path:open +- [ ] Path:close +- [ ] Path:write +- [ ] Path:_read +- [ ] Path:_read_async +- [ ] Path:read +- [ ] Path:head +- [ ] Path:tail +- [ ] Path:readlines +- [ ] Path:iter +- [ ] Path:readbyterange +-[ ] Path:find_upwards +]] + +local uv = vim.loop + +local iswin = uv.os_uname().sysname == "Windows_NT" +local hasshellslash = vim.fn.exists "+shellslash" == 1 + +local S_IF = { + -- S_IFDIR = 0o040000 # directory + DIR = 0x4000, + -- S_IFREG = 0o100000 # regular file + REG = 0x8000, +} + +---@class plenary.path +---@field home string home directory path +---@field sep string OS path separator respecting 'shellslash' +--- +--- OS separator for paths returned by libuv functions. +--- Note: libuv will happily take either path separator regardless of 'shellslash'. +---@field private _uv_sep string +--- +--- get the root directory path. +--- On Windows, this is determined from the current working directory in order +--- to capture the current disk name. But can be calculated from another path +--- using the optional `base` parameter. +---@field root fun(base: string?):string +---@field S_IF { DIR: integer, REG: integer } stat filetype bitmask +local path = setmetatable({ + home = vim.fn.getcwd(), -- respects shellslash unlike vim.uv.cwd() + S_IF = S_IF, + _uv_sep = iswin and "\\" or "/", +}, { + __index = function(t, k) + local raw = rawget(t, k) + if raw then + return raw + end + + if k == "sep" then + if not iswin then + t.sep = "/" + return t.sep + end + + return (hasshellslash and vim.o.shellslash) and "/" or "\\" + end + end, +}) + +path.root = (function() + if not iswin then + return function() + return "/" + end + else + return function(base) + base = base or path.home + local disk = base:match "^[%a]:" + if disk then + return disk .. path.sep + end + return string.rep(path.sep, 2) -- UNC + end + end +end)() + +--- WARNING: Should really avoid using this. It's more like +--- `maybe_uri_maybe_not`. There are both false positives and false negative +--- edge cases. +--- +--- Approximates if a filename is a valid URI by checking if the filename +--- starts with a plausible scheme. +--- +--- A valid URI scheme begins with a letter, followed by any number of letters, +--- numbers and `+`, `.`, `-` and ends with a `:`. +--- +--- To disambiguate URI schemes from Windows path, we also check up to 2 +--- characters after the `:` to make sure it's followed by `//`. +--- +--- Two major caveats according to our checks: +--- - a "valid" URI is also a valid unix relative path so any relative unix +--- path that's in the shape of a URI according to our check will be flagged +--- as a URI. +--- - relative Windows paths like `C:Projects/apilibrary/apilibrary.sln` will +--- be caught as a URI. +--- +---@param filename string +---@return boolean +local function is_uri(filename) + local ch = filename:byte(1) or 0 + + -- is not alpha? + if not ((ch >= 97 and ch <= 122) or (ch >= 65 and ch <= 90)) then + return false + end + + local scheme_end = 0 + for i = 2, #filename do + ch = filename:byte(i) + if + (ch >= 97 and ch <= 122) -- a-z + or (ch >= 65 and ch <= 90) -- A-Z + or (ch >= 48 and ch <= 57) -- 0-9 + or ch == 43 -- `+` + or ch == 46 -- `.` + or ch == 45 -- `-` + then -- luacheck: ignore 542 + -- pass + elseif ch == 58 then + scheme_end = i + break + else + return false + end + end + + if scheme_end == 0 then + return false + end + + local next = filename:byte(scheme_end + 1) or 0 + if next == 0 then + -- nothing following the scheme + return false + elseif next == 92 then -- `\` + -- could be Windows absolute path but not a uri + return false + elseif next == 47 and (filename:byte(scheme_end + 2) or 0) ~= 47 then -- `/` + -- still could be Windows absolute path using `/` seps but not a uri + return false + end + return true +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 p string Path to split. +--- @return string, string, boolean : prefix, body, whether path is invalid. +local function split_windows_path(p) + 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 = p:match(pattern) + + if match then + prefix = prefix .. match --[[ @as string ]] + p = p: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, p, false + end + elseif match_to_prefix "^//" then + -- Process UNC path, return early if it's invalid + if not process_unc_path() then + return prefix, p, false + end + elseif p:match "^%w:" then + -- Drive paths + prefix, p = p:sub(1, 2), p: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) + p = trailing_slash .. p --[[ @as string ]] + end + + return prefix, p, 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 p string Path to resolve. +---@return string # Resolved path. +local function path_resolve_dot(p) + local is_path_absolute = vim.startswith(p, "/") + local new_path_components = {} + + for component in vim.gsplit(p, "/") do + if component == "." or component == "" then + -- 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 + -- 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 + +--- Resolves '.' and '..' in the path, removes extra path separator. +--- +--- For Windows, converts separator `\` to `/` to simplify many operations. +--- +--- Credit to famiu. This is basically neovim core `vim.fs.normalize`. +---@param p string path +---@return string +local function normalize_path(p) + if p == "" or is_uri(p) then + return p + end + + if iswin then + p = p:gsub("\\", "/") + end + + local double_slash = vim.startswith(p, "//") and not vim.startswith(p, "///") + local prefix = "" + + if iswin then + local valid + prefix, p, valid = split_windows_path(p) + if not valid then + return prefix .. p + end + prefix = prefix:gsub("/+", "/") + end + + p = path_resolve_dot(p) + p = (double_slash and "/" or "") .. prefix .. p + + if p == "" then + p = "." + end + + return p +end + +---@class plenary.Path +---@field path plenary.path +---@field filename string path as a string +--- +--- internal string representation of the path that's normalized and uses `/` +--- as path separator. makes many other operations much easier to work with. +---@field private _name string +---@field private _sep string path separator taking into account 'shellslash' on windows +---@field private _absolute string? absolute path +---@field private _cwd string? cwd path +---@field private _fs_stat table fs_stat +local Path = { + path = path, +} + +Path.__index = function(t, k) + local raw = rawget(Path, k) + if raw then + return raw + end + + if k == "_cwd" then + local cwd = uv.fs_realpath "." + if cwd ~= nil then + cwd = (cwd:gsub(path._uv_sep, "/")) + end + t._cwd = cwd + return t._cwd + end + + if k == "_absolute" then + local absolute = uv.fs_realpath(t._name) + if absolute ~= nil then + absolute = (absolute:gsub(path._uv_sep, "/")) + end + t._absolute = absolute + return absolute + end + + if k == "_fs_stat" then + t._fs_stat = uv.fs_stat(t._absolute or t._name) or {} + return t._fs_stat + end +end + +---@param other plenary.Path|string +---@return plenary.Path +Path.__div = function(self, other) + assert(Path.is_path(self)) + assert(Path.is_path(other) or type(other) == "string") + + return self:joinpath(other) +end + +---@return string +Path.__tostring = function(self) + return self._name +end + +-- TODO: See where we concat the table, and maybe we could make this work. +Path.__concat = function(self, other) + return self.filename .. other +end + +Path.is_path = function(a) + return getmetatable(a) == Path +end + +---@param parts string[] +---@param sep string +---@return string +local function unix_path_str(parts, sep) + -- any sep other than `/` is not a valid sep but allowing for backwards compat reasons + local flat_parts = {} + for _, part in ipairs(parts) do + vim.list_extend(flat_parts, vim.split(part, sep)) + end + + return (table.concat(flat_parts, sep):gsub(sep .. "+", sep)) +end + +---@param parts string[] +---@param sep string +---@return string +local function windows_path_str(parts, sep) + local disk = parts[1]:match "^[%a]:" + local is_disk_root = parts[1]:match "^[%a]:[\\/]" ~= nil + local is_unc = parts[1]:match "^\\\\" or parts[1]:match "^//" + + local flat_parts = {} + for _, part in ipairs(parts) do + vim.list_extend(flat_parts, vim.split(part, "[\\/]")) + end + + if not is_disk_root and flat_parts[1] == disk then + table.remove(flat_parts, 1) + local p = disk .. table.concat(flat_parts, sep) + return (p:gsub(sep .. "+", sep)) + end + if is_unc then + table.remove(flat_parts, 1) + table.remove(flat_parts, 1) + local body = (table.concat(flat_parts, sep):gsub(sep .. "+", sep)) + return sep .. sep .. body + end + return (table.concat(flat_parts, sep):gsub(sep .. "+", sep)) +end + +---@return plenary.Path +function Path:new(...) + local args = { ... } + + if type(self) == "string" then + table.insert(args, 1, self) + self = Path + end + + local path_input + if #args == 1 then + if Path.is_path(args[1]) then + local p = args[1] ---@cast p plenary.Path + return p + end + if type(args[1]) == "table" then + path_input = args[1] + else + assert(type(args[1]) == "string", "unexpected path input\n" .. vim.inspect(path_input)) + path_input = args + end + else + path_input = args + end + + assert(type(path_input) == "table", vim.inspect(path_input)) + ---@cast path_input {[integer]: (string)|plenary.Path, sep: string?} + + local sep = path.sep + sep = path_input.sep or path.sep + path_input.sep = nil + path_input = vim.tbl_map(function(part) + if Path.is_path(part) then + return part.filename + else + assert(type(part) == "string", vim.inspect(path_input)) + return vim.trim(part) + end + end, path_input) + + assert(#path_input > 0, "can't create Path out of nothing") + + local path_string + if iswin then + path_string = windows_path_str(path_input, sep) + else + path_string = unix_path_str(path_input, sep) + end + + local proxy = { + -- precompute normalized path using `/` as sep + _name = normalize_path(path_string), + filename = path_string, + _sep = sep, + } + + setmetatable(proxy, Path) + + local obj = { __inner = proxy } + setmetatable(obj, { + __index = function(_, k) + return proxy[k] + end, + __newindex = function(t, k, val) + if k == "filename" then + proxy.filename, proxy._name = val, normalize_path(val) + proxy._absolute, proxy._fs_stat = nil, nil + elseif k == "_name" then + proxy.filename, proxy._name = (val:gsub("/", t._sep)), val + proxy._absolute, proxy._fs_stat = nil, nil + else + proxy[k] = val + end + end, + -- stylua: ignore start + __div = function(t, other) return Path.__div(t, other) end, + __concat = function(t, other) return Path.__concat(t, other) end, + __tostring = function(t) return Path.__tostring(t) end, + __metatable = Path, + -- stylua: ignore end + }) + + return obj +end + +---@return string +function Path:absolute() + if self:is_absolute() then + return (self._name:gsub("/", self._sep)) + end + return (normalize_path(self._cwd .. self._sep .. self._name):gsub("/", self._sep)) +end + +---@return string +function Path:_fs_filename() + return self:absolute() or self.filename +end + +---@return table +function Path:_stat() + return self._fs_stat +end + +---@return number +function Path:_st_mode() + return self:_stat().mode or 0 +end + +---@return boolean +function Path:exists() + return not vim.tbl_isempty(self:_stat()) +end + +---@return boolean +function Path:is_dir() + return self:_stat().type == "directory" +end + +---@return boolean +function Path:is_file() + return self:_stat().type == "file" +end + +--- For POSIX path, anything starting with a `/` is considered a absolute path. +--- +--- +--- For Windows, it's a little more involved. +--- +--- Disk names are single letters. They MUST be followed by a `:` + separator to be +--- considered an absolute path. eg. +--- C:\Documents\Newsletters\Summer2018.pdf -> An absolute file path from the root of drive C:. + +--- UNC paths are also considered absolute. eg. \\Server2\Share\Test\Foo.txt +--- +--- Any other valid paths are relative. eg. +--- C:Projects\apilibrary\apilibrary.sln -> A relative path from the current directory of the C: drive. +--- 2018\January.xlsx -> A relative path to a file in a subdirectory of the current directory. +--- \Program Files\Custom Utilities\StringFinder.exe -> A relative path from the root of the current drive. +--- ..\Publications\TravelBrochure.pdf -> A relative path to a file in a directory starting from the current directory. +---@return boolean +function Path:is_absolute() + if not iswin then + return string.sub(self._name, 1, 1) == "/" + end + + if string.match(self._name, "^[%a]:/.*$") ~= nil then + return true + elseif string.match(self._name, "^//") then + return true + end + + return false +end + +---@return plenary.Path +function Path:joinpath(...) + return Path:new(self._name, ...) +end + +--- Make an absolute path relative to another path. +--- +--- No-op if path is a URI. +---@param cwd string? path to make relative to (default: cwd) +---@return string # new filename +function Path:make_relative(cwd) + if is_uri(self._name) then + return self.filename + end + + cwd = Path:new(vim.F.if_nil(cwd, self._cwd))._name + if self._name == cwd then + self._name = "." + return self.filename + end + + if cwd:sub(#cwd, #cwd) ~= "/" then + cwd = cwd .. "/" + end + + if not self:is_absolute() then + self._name = normalize_path(cwd .. self._name) + end + + if self._name:sub(1, #cwd) == cwd then + self._name = self._name:sub(#cwd + 1, -1) + return self.filename + end + + -- if self._name:sub(1, #cwd) == cwd then + -- self._name = self._name:sub(#cwd + 1, -1) + -- else + -- error(string.format("'%s' is not a subpath of '%s'", self.filename, cwd)) + -- end + return self.filename +end + +--- Makes the path relative to cwd or provided path and resolves any internal +--- '.' and '..' in relative paths according. Substitutes home directory +--- with `~` if applicable. Deduplicates path separators and trims any trailing +--- separators. +--- +--- No-op if path is a URI. +---@param cwd string? path to make relative to (default: cwd) +---@return string +function Path:normalize(cwd) + if is_uri(self._name) then + return self.filename + end + + self:make_relative(cwd) + + local home = path.home + if home:sub(-1) ~= self._sep then + home = home .. self._sep + end + + local start, finish = self._name:find(home, 1, true) + if start == 1 then + self._name = "~/" .. self._name:sub(finish + 1, -1) + end + + return (self._name:gsub("/", self._sep)) +end + +-- local p = Path:new "C:/Windows/temp/lua/plenary/path.lua" +-- print(p.filename, p:normalize "C:/Windows/temp/lua") + +-- local p = Path:new "../lua/plenary/path.lua" +-- print(p:normalize("C:/Windows/temp/lua")) + +return Path diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 7ddeead7..dc1c087d 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -45,29 +45,7 @@ local function plat_path(p) return p:gsub("/", "\\") end -describe("absolute", function() - describe("unix", function() - if iswin then - return - end - end) - - describe("windows", function() - if not iswin then - return - end - - describe("shellslash", function() - set_shellslash(true) - end) - - describe("noshellslash", function() - set_shellslash(false) - end) - end) -end) - -describe("Path", function() +describe("Path2", function() describe("filename", function() local function get_paths() local readme_path = vim.fn.fnamemodify("README.md", ":p") @@ -78,8 +56,9 @@ describe("Path", function() { { "README.md" }, "README.md" }, { { "lua", "..", "README.md" }, "lua/../README.md" }, { { "lua/../README.md" }, "lua/../README.md" }, - { { "./lua/../README.md" }, "./lua/../README.md" }, - { "./lua//..//README.md", "./lua/../README.md" }, + { { "./lua/../README.md" }, "lua/../README.md" }, + { "./lua//..//README.md", "lua/../README.md" }, + { "foo/bar/", "foo/bar" }, { { readme_path }, readme_path }, } @@ -91,7 +70,7 @@ describe("Path", function() local input, expect = tc[1], tc[2] it(vim.inspect(input), function() local p = Path:new(input) - assert.are.same(expect, p.filename) + assert.are.same(expect, p.filename, p.parts) end) end end @@ -119,8 +98,8 @@ describe("Path", function() { { [[C:/Documents/Newsletters/Summer2018.pdf]] }, [[C:/Documents/Newsletters/Summer2018.pdf]] }, { { [[\\Server2\Share\Test\Foo.txt]] }, [[//Server2/Share/Test/Foo.txt]] }, { { [[//Server2/Share/Test/Foo.txt]] }, [[//Server2/Share/Test/Foo.txt]] }, - { [[//Server2//Share//Test/Foo.txt]], [[//Server2/Share/Test/Foo.txt]] }, - { [[\\Server2\\Share\\Test\Foo.txt]], [[//Server2/Share/Test/Foo.txt]] }, + { [[//Server2/Share/Test/Foo.txt]], [[//Server2/Share/Test/Foo.txt]] }, + { [[\\Server2\Share\Test\Foo.txt]], [[//Server2/Share/Test/Foo.txt]] }, { { "C:", "lua", "..", "README.md" }, "C:lua/../README.md" }, { { "C:/", "lua", "..", "README.md" }, "C:/lua/../README.md" }, { "C:lua/../README.md", "C:lua/../README.md" }, @@ -128,6 +107,7 @@ describe("Path", function() { [[foo/bar\baz]], [[foo/bar/baz]] }, { [[\\.\C:\Test\Foo.txt]], [[//./C:/Test/Foo.txt]] }, { [[\\?\C:\Test\Foo.txt]], [[//?/C:/Test/Foo.txt]] }, + { "/foo/bar/baz", "foo/bar/baz" }, } vim.list_extend(paths, get_paths()) @@ -195,7 +175,7 @@ describe("Path", function() local function get_windows_paths() local nossl = hasshellslash and not vim.o.shellslash - local disk = path.root():match "^[%a]:" + local drive = Path:new(vim.loop.cwd()).drv local readme_path = vim.fn.fnamemodify("README.md", ":p") ---@type [string[]|string, string, boolean][] @@ -207,8 +187,8 @@ describe("Path", function() { [[\\.\C:\Test\Foo.txt]], [[//./C:/Test/Foo.txt]], true }, { [[\\?\C:\Test\Foo.txt]], [[//?/C:/Test/Foo.txt]], true }, { readme_path, readme_path, true }, - { disk .. [[lua/../README.md]], readme_path, false }, - { { disk, "lua", "..", "README.md" }, readme_path, false }, + { drive .. [[lua/../README.md]], readme_path, false }, + { { drive, "lua", "..", "README.md" }, readme_path, false }, } vim.list_extend(paths, get_paths()) @@ -237,698 +217,15 @@ describe("Path", function() assert.are.same(Path:new("lua", "plenary"), Path:new("lua"):joinpath "plenary") end) - it_cross_plat("can join paths with /", function() - assert.are.same(Path:new("lua", "plenary"), Path:new "lua" / "plenary") - end) - - it_cross_plat("can join paths with paths", function() - assert.are.same(Path:new("lua", "plenary"), Path:new("lua", Path:new "plenary")) - end) - - it_cross_plat("inserts slashes", function() - assert.are.same("lua" .. path.sep .. "plenary", Path:new("lua", "plenary").filename) - end) - - describe(".exists()", function() - it_cross_plat("finds files that exist", function() - assert.are.same(true, Path:new("README.md"):exists()) - end) - - it_cross_plat("returns false for files that do not exist", function() - assert.are.same(false, Path:new("asdf.md"):exists()) - end) - end) - - describe(".is_dir()", function() - it_cross_plat("should find directories that exist", function() - assert.are.same(true, Path:new("lua"):is_dir()) - end) - - it_cross_plat("should return false when the directory does not exist", function() - assert.are.same(false, Path:new("asdf"):is_dir()) - end) - - it_cross_plat("should not show files as directories", function() - assert.are.same(false, Path:new("README.md"):is_dir()) - end) - end) - - describe(".is_file()", function() - it_cross_plat("should not allow directories", function() - assert.are.same(true, not Path:new("lua"):is_file()) - end) - - it_cross_plat("should return false when the file does not exist", function() - assert.are.same(true, not Path:new("asdf"):is_file()) - end) - - it_cross_plat("should show files as file", function() - assert.are.same(true, Path:new("README.md"):is_file()) - end) - end) - - describe(":new", function() - it_cross_plat("can be called with or without colon", function() - -- This will work, cause we used a colon - local with_colon = Path:new "lua" - local no_colon = Path.new "lua" - - assert.are.same(with_colon, no_colon) - end) - end) - - describe(":make_relative", function() - local root = iswin and "c:\\" or "/" - it_cross_plat("can take absolute paths and make them relative to the cwd", function() - local p = Path:new { "lua", "plenary", "path.lua" } - local absolute = vim.loop.cwd() .. path.sep .. p.filename - local relative = Path:new(absolute):make_relative() - assert.are.same(p.filename, relative) - end) - - it_cross_plat("can take absolute paths and make them relative to a given path", function() - local r = Path:new { root, "home", "prime" } - local p = Path:new { "aoeu", "agen.lua" } - local absolute = r.filename .. path.sep .. p.filename - local relative = Path:new(absolute):make_relative(r.filename) - assert.are.same(relative, p.filename) - end) - - it_cross_plat("can take double separator absolute paths and make them relative to the cwd", function() - local p = Path:new { "lua", "plenary", "path.lua" } - local absolute = vim.loop.cwd() .. path.sep .. path.sep .. p.filename - local relative = Path:new(absolute):make_relative() - assert.are.same(relative, p.filename) - end) - - it_cross_plat("can take double separator absolute paths and make them relative to a given path", function() - local r = Path:new { root, "home", "prime" } - local p = Path:new { "aoeu", "agen.lua" } - local absolute = r.filename .. path.sep .. path.sep .. p.filename - local relative = Path:new(absolute):make_relative(r.filename) - assert.are.same(relative, p.filename) - end) - - it_cross_plat("can take absolute paths and make them relative to a given path with trailing separator", function() - local r = Path:new { root, "home", "prime" } - local p = Path:new { "aoeu", "agen.lua" } - local absolute = r.filename .. path.sep .. p.filename - local relative = Path:new(absolute):make_relative(r.filename .. path.sep) - assert.are.same(relative, p.filename) - end) - - it_cross_plat("can take absolute paths and make them relative to the root directory", function() - local p = Path:new { "home", "prime", "aoeu", "agen.lua" } - local absolute = root .. p.filename - local relative = Path:new(absolute):make_relative(root) - assert.are.same(relative, p.filename) - end) - - it_cross_plat("can take absolute paths and make them relative to themselves", function() - local p = Path:new { root, "home", "prime", "aoeu", "agen.lua" } - local relative = Path:new(p.filename):make_relative(p.filename) - assert.are.same(relative, ".") - end) - - it_cross_plat("should not truncate if path separator is not present after cwd", function() - local cwd = "tmp" .. path.sep .. "foo" - local p = Path:new { "tmp", "foo_bar", "fileb.lua" } - local relative = Path:new(p.filename):make_relative(cwd) - assert.are.same(p.filename, relative) - end) - - it_cross_plat("should not truncate if path separator is not present after cwd and cwd ends in path sep", function() - local cwd = "tmp" .. path.sep .. "foo" .. path.sep - local p = Path:new { "tmp", "foo_bar", "fileb.lua" } - local relative = Path:new(p.filename):make_relative(cwd) - assert.are.same(p.filename, relative) - end) - end) - - describe(":normalize", function() - local home = iswin and "C:/Users/test/" or "/home/test/" - local tmp_lua = iswin and "C:/Windows/Temp/lua" or "/tmp/lua" - - it_cross_plat("can take path that has one character directories", function() - local orig = iswin and "C:/Users/j/./p//path.lua" or "/home/j/./p//path.lua" - local final = Path:new(orig):normalize() - local expect = plat_path(iswin and "C:/Users/j/p/path.lua" or "/home/j/p/path.lua") - assert.are.same(expect, final) - end) - - it_cross_plat("can take paths with double separators change them to single separators", function() - local orig = "lua//plenary/path.lua" - local final = Path:new(orig):normalize() - local expect = plat_path("lua/plenary/path.lua") - assert.are.same(expect, final) - end) - - -- -- this may be redundant since normalize just calls make_relative which is tested above - -- it_cross_plat("can take absolute paths with double seps" .. "and make them relative with single seps", function() - -- local orig = "/lua//plenary/path.lua" - -- local final = Path:new(orig):normalize() - -- local expect = plat_path("/lua/plenary/path.lua") - -- assert.are.same(expect, final) - -- end) - - -- it_cross_plat("can remove the .. in paths", function() - -- local orig = "/lua//plenary/path.lua/foo/bar/../.." - -- local final = Path:new(orig):normalize() - -- local expect = plat_path("/lua/plenary/path.lua") - -- assert.are.same(expect, final) - -- end) - - -- it_cross_plat("can normalize relative paths", function() - -- local orig = "lua/plenary/path.lua" - -- local final = Path:new(orig):normalize() - -- local expect = plat_path(orig) - -- assert.are.same(expect, final) - -- end) - - -- it_cross_plat("can normalize relative paths containing ..", function() - -- local orig = "lua/plenary/path.lua/../path.lua" - -- local final = Path:new(orig):normalize() - -- local expect = plat_path("lua/plenary/path.lua") - -- assert.are.same(expect, final) - -- end) - - -- it_cross_plat("can normalize relative paths with initial ..", function() - -- local p = Path:new "../lua/plenary/path.lua" - -- p._cwd = tmp_lua - -- local expect = plat_path("lua/plenary/path.lua") - -- assert.are.same(expect, p:normalize()) - -- end) - - -- it_cross_plat("can normalize relative paths to absolute when initial .. count matches cwd parts", function() - -- local p = Path:new "../../tmp/lua/plenary/path.lua" - -- p._cwd = "/tmp/lua" - -- assert.are.same("/tmp/lua/plenary/path.lua", p:normalize()) - -- end) - - -- it_cross_plat("can normalize ~ when file is within home directory (trailing slash)", function() - -- local p = Path:new { home, "./test_file" } - -- p.path.home = home - -- p._cwd = tmp_lua - -- assert.are.same("~/test_file", p:normalize()) - -- end) - - -- it_cross_plat("can normalize ~ when file is within home directory (no trailing slash)", function() - -- local p = Path:new { home, "./test_file" } - -- p.path.home = home - -- p._cwd = tmp_lua - -- assert.are.same("~/test_file", p:normalize()) - -- end) - - -- it_cross_plat("handles usernames with a dash at the end", function() - -- local p = Path:new { home, "test_file" } - -- p.path.home = home - -- p._cwd = tmp_lua - -- assert.are.same("~/test_file", p:normalize()) - -- end) - - -- it_cross_plat("handles filenames with the same prefix as the home directory", function() - -- local pstr = iswin and "C:/Users/test.old/test_file" or "/home/test.old/test_file" - -- local p = Path:new(pstr) - -- p.path.home = home - -- assert.are.same(pstr, p:normalize()) - -- end) - end) - - -- describe(":shorten", function() - -- it_cross_plat("can shorten a path", function() - -- local long_path = "/this/is/a/long/path" - -- local short_path = Path:new(long_path):shorten() - -- assert.are.same(short_path, "/t/i/a/l/path") - -- end) - - -- it_cross_plat("can shorten a path's components to a given length", function() - -- local long_path = "/this/is/a/long/path" - -- local short_path = Path:new(long_path):shorten(2) - -- assert.are.same(short_path, "/th/is/a/lo/path") - - -- -- without the leading / - -- long_path = "this/is/a/long/path" - -- short_path = Path:new(long_path):shorten(3) - -- assert.are.same(short_path, "thi/is/a/lon/path") - - -- -- where len is greater than the length of the final component - -- long_path = "this/is/an/extremely/long/path" - -- short_path = Path:new(long_path):shorten(5) - -- assert.are.same(short_path, "this/is/an/extre/long/path") - -- end) - - -- it_cross_plat("can shorten a path's components when excluding parts", function() - -- local long_path = "/this/is/a/long/path" - -- local short_path = Path:new(long_path):shorten(nil, { 1, -1 }) - -- assert.are.same(short_path, "/this/i/a/l/path") - - -- -- without the leading / - -- long_path = "this/is/a/long/path" - -- short_path = Path:new(long_path):shorten(nil, { 1, -1 }) - -- assert.are.same(short_path, "this/i/a/l/path") - - -- -- where excluding positions greater than the number of parts - -- long_path = "this/is/an/extremely/long/path" - -- short_path = Path:new(long_path):shorten(nil, { 2, 4, 6, 8 }) - -- assert.are.same(short_path, "t/is/a/extremely/l/path") - - -- -- where excluding positions less than the negation of the number of parts - -- long_path = "this/is/an/extremely/long/path" - -- short_path = Path:new(long_path):shorten(nil, { -2, -4, -6, -8 }) - -- assert.are.same(short_path, "this/i/an/e/long/p") - -- end) - - -- it_cross_plat("can shorten a path's components to a given length and exclude positions", function() - -- local long_path = "/this/is/a/long/path" - -- local short_path = Path:new(long_path):shorten(2, { 1, -1 }) - -- assert.are.same(short_path, "/this/is/a/lo/path") - - -- long_path = "this/is/a/long/path" - -- short_path = Path:new(long_path):shorten(3, { 2, -2 }) - -- assert.are.same(short_path, "thi/is/a/long/pat") - - -- long_path = "this/is/an/extremely/long/path" - -- short_path = Path:new(long_path):shorten(5, { 3, -3 }) - -- assert.are.same(short_path, "this/is/an/extremely/long/path") - -- end) - -- end) - - -- describe("mkdir / rmdir", function() - -- it_cross_plat("can create and delete directories", function() - -- local p = Path:new "_dir_not_exist" - - -- p:rmdir() - -- assert(not p:exists(), "After rmdir, it should not exist") - - -- p:mkdir() - -- assert(p:exists()) - - -- p:rmdir() - -- assert(not p:exists()) - -- end) - - -- it_cross_plat("fails when exists_ok is false", function() - -- local p = Path:new "lua" - -- assert(not pcall(p.mkdir, p, { exists_ok = false })) - -- end) - - -- it_cross_plat("fails when parents is not passed", function() - -- local p = Path:new("impossible", "dir") - -- assert(not pcall(p.mkdir, p, { parents = false })) - -- assert(not p:exists()) - -- end) - - -- it_cross_plat("can create nested directories", function() - -- local p = Path:new("impossible", "dir") - -- assert(pcall(p.mkdir, p, { parents = true })) - -- assert(p:exists()) - - -- p:rmdir() - -- Path:new("impossible"):rmdir() - -- assert(not p:exists()) - -- assert(not Path:new("impossible"):exists()) - -- end) - -- end) - - -- describe("touch", function() - -- it_cross_plat("can create and delete new files", function() - -- local p = Path:new "test_file.lua" - -- assert(pcall(p.touch, p)) - -- assert(p:exists()) - - -- p:rm() - -- assert(not p:exists()) - -- end) - - -- it_cross_plat("does not effect already created files but updates last access", function() - -- local p = Path:new "README.md" - -- local last_atime = p:_stat().atime.sec - -- local last_mtime = p:_stat().mtime.sec - - -- local lines = p:readlines() - - -- assert(pcall(p.touch, p)) - -- print(p:_stat().atime.sec > last_atime) - -- print(p:_stat().mtime.sec > last_mtime) - -- assert(p:exists()) - - -- assert.are.same(lines, p:readlines()) - -- end) - - -- it_cross_plat("does not create dirs if nested in none existing dirs and parents not set", function() - -- local p = Path:new { "nested", "nested2", "test_file.lua" } - -- assert(not pcall(p.touch, p, { parents = false })) - -- assert(not p:exists()) - -- end) - - -- it_cross_plat("does create dirs if nested in none existing dirs", function() - -- local p1 = Path:new { "nested", "nested2", "test_file.lua" } - -- local p2 = Path:new { "nested", "asdf", ".hidden" } - -- local d1 = Path:new { "nested", "dir", ".hidden" } - -- assert(pcall(p1.touch, p1, { parents = true })) - -- assert(pcall(p2.touch, p2, { parents = true })) - -- assert(pcall(d1.mkdir, d1, { parents = true })) - -- assert(p1:exists()) - -- assert(p2:exists()) - -- assert(d1:exists()) - - -- Path:new({ "nested" }):rm { recursive = true } - -- assert(not p1:exists()) - -- assert(not p2:exists()) - -- assert(not d1:exists()) - -- assert(not Path:new({ "nested" }):exists()) - -- end) + -- it_cross_plat("can join paths with /", function() + -- assert.are.same(Path:new("lua", "plenary"), Path:new "lua" / "plenary") -- end) - -- describe("rename", function() - -- it_cross_plat("can rename a file", function() - -- local p = Path:new "a_random_filename.lua" - -- assert(pcall(p.touch, p)) - -- assert(p:exists()) - - -- assert(pcall(p.rename, p, { new_name = "not_a_random_filename.lua" })) - -- assert.are.same("not_a_random_filename.lua", p.filename) - - -- p:rm() - -- end) - - -- it_cross_plat("can handle an invalid filename", function() - -- local p = Path:new "some_random_filename.lua" - -- assert(pcall(p.touch, p)) - -- assert(p:exists()) - - -- assert(not pcall(p.rename, p, { new_name = "" })) - -- assert(not pcall(p.rename, p)) - -- assert.are.same("some_random_filename.lua", p.filename) - - -- p:rm() - -- end) - - -- it_cross_plat("can move to parent dir", function() - -- local p = Path:new "some_random_filename.lua" - -- assert(pcall(p.touch, p)) - -- assert(p:exists()) - - -- assert(pcall(p.rename, p, { new_name = "../some_random_filename.lua" })) - -- assert.are.same(vim.loop.fs_realpath(Path:new("../some_random_filename.lua"):absolute()), p:absolute()) - - -- p:rm() - -- end) - - -- it_cross_plat("cannot rename to an existing filename", function() - -- local p1 = Path:new "a_random_filename.lua" - -- local p2 = Path:new "not_a_random_filename.lua" - -- assert(pcall(p1.touch, p1)) - -- assert(pcall(p2.touch, p2)) - -- assert(p1:exists()) - -- assert(p2:exists()) - - -- assert(not pcall(p1.rename, p1, { new_name = "not_a_random_filename.lua" })) - -- assert.are.same(p1.filename, "a_random_filename.lua") - - -- p1:rm() - -- p2:rm() - -- end) + -- it_cross_plat("can join paths with paths", function() + -- assert.are.same(Path:new("lua", "plenary"), Path:new("lua", Path:new "plenary")) -- end) - -- describe("copy", function() - -- it_cross_plat("can copy a file", function() - -- local p1 = Path:new "a_random_filename.rs" - -- local p2 = Path:new "not_a_random_filename.rs" - -- assert(pcall(p1.touch, p1)) - -- assert(p1:exists()) - - -- assert(pcall(p1.copy, p1, { destination = "not_a_random_filename.rs" })) - -- assert.are.same(p1.filename, "a_random_filename.rs") - -- assert.are.same(p2.filename, "not_a_random_filename.rs") - - -- p1:rm() - -- p2:rm() - -- end) - - -- it_cross_plat("can copy to parent dir", function() - -- local p = Path:new "some_random_filename.lua" - -- assert(pcall(p.touch, p)) - -- assert(p:exists()) - - -- assert(pcall(p.copy, p, { destination = "../some_random_filename.lua" })) - -- assert(pcall(p.exists, p)) - - -- p:rm() - -- Path:new(vim.loop.fs_realpath "../some_random_filename.lua"):rm() - -- end) - - -- it_cross_plat("cannot copy an existing file if override false", function() - -- local p1 = Path:new "a_random_filename.rs" - -- local p2 = Path:new "not_a_random_filename.rs" - -- assert(pcall(p1.touch, p1)) - -- assert(pcall(p2.touch, p2)) - -- assert(p1:exists()) - -- assert(p2:exists()) - - -- assert(pcall(p1.copy, p1, { destination = "not_a_random_filename.rs", override = false })) - -- assert.are.same(p1.filename, "a_random_filename.rs") - -- assert.are.same(p2.filename, "not_a_random_filename.rs") - - -- p1:rm() - -- p2:rm() - -- end) - - -- it_cross_plat("fails when copying folders non-recursively", function() - -- local src_dir = Path:new "src" - -- src_dir:mkdir() - -- src_dir:joinpath("file1.lua"):touch() - - -- local trg_dir = Path:new "trg" - -- local status = xpcall(function() - -- src_dir:copy { destination = trg_dir, recursive = false } - -- end, function() end) - -- -- failed as intended - -- assert(status == false) - - -- src_dir:rm { recursive = true } - -- end) - - -- it_cross_plat("can copy directories recursively", function() - -- -- vim.tbl_flatten doesn't work here as copy doesn't return a list - -- local flatten - -- flatten = function(ret, t) - -- for _, v in pairs(t) do - -- if type(v) == "table" then - -- flatten(ret, v) - -- else - -- table.insert(ret, v) - -- end - -- end - -- end - - -- -- setup directories - -- local src_dir = Path:new "src" - -- local trg_dir = Path:new "trg" - -- src_dir:mkdir() - - -- -- set up sub directory paths for creation and testing - -- local sub_dirs = { "sub_dir1", "sub_dir1/sub_dir2" } - -- local src_dirs = { src_dir } - -- local trg_dirs = { trg_dir } - -- -- {src, trg}_dirs is a table with all directory levels by {src, trg} - -- for _, dir in ipairs(sub_dirs) do - -- table.insert(src_dirs, src_dir:joinpath(dir)) - -- table.insert(trg_dirs, trg_dir:joinpath(dir)) - -- end - - -- -- generate {file}_{level}.lua on every directory level in src - -- -- src - -- -- ├── file1_1.lua - -- -- ├── file2_1.lua - -- -- ├── .file3_1.lua - -- -- └── sub_dir1 - -- -- ├── file1_2.lua - -- -- ├── file2_2.lua - -- -- ├── .file3_2.lua - -- -- └── sub_dir2 - -- -- ├── file1_3.lua - -- -- ├── file2_3.lua - -- -- └── .file3_3.lua - -- local files = { "file1", "file2", ".file3" } - -- for _, file in ipairs(files) do - -- for level, dir in ipairs(src_dirs) do - -- local p = dir:joinpath(file .. "_" .. level .. ".lua") - -- assert(pcall(p.touch, p, { parents = true, exists_ok = true })) - -- assert(p:exists()) - -- end - -- end - - -- for _, hidden in ipairs { true, false } do - -- -- override = `false` should NOT copy as it was copied beforehand - -- for _, override in ipairs { true, false } do - -- local success = src_dir:copy { destination = trg_dir, recursive = true, override = override, hidden = hidden } - -- -- the files are already created because we iterate first with `override=true` - -- -- hence, we test here that no file ops have been committed: any value in tbl of tbls should be false - -- if not override then - -- local file_ops = {} - -- flatten(file_ops, success) - -- -- 3 layers with at at least 2 and at most 3 files (`hidden = true`) - -- local num_files = not hidden and 6 or 9 - -- assert(#file_ops == num_files) - -- for _, op in ipairs(file_ops) do - -- assert(op == false) - -- end - -- else - -- for _, file in ipairs(files) do - -- for level, dir in ipairs(trg_dirs) do - -- local p = dir:joinpath(file .. "_" .. level .. ".lua") - -- -- file 3 is hidden - -- if not (file == files[3]) then - -- assert(p:exists()) - -- else - -- assert(p:exists() == hidden) - -- end - -- end - -- end - -- end - -- -- only clean up once we tested that we dont want to copy - -- -- if `override=true` - -- if not override then - -- trg_dir:rm { recursive = true } - -- end - -- end - -- end - - -- src_dir:rm { recursive = true } - -- end) + -- it_cross_plat("inserts slashes", function() + -- assert.are.same("lua" .. path.sep .. "plenary", Path:new("lua", "plenary").filename) -- end) - - -- describe("parents", function() - -- it_cross_plat("should extract the ancestors of the path", function() - -- local p = Path:new(vim.loop.cwd()) - -- local parents = p:parents() - -- assert(compat.islist(parents)) - -- for _, parent in pairs(parents) do - -- assert.are.same(type(parent), "string") - -- end - -- end) - -- it_cross_plat("should return itself if it corresponds to path.root", function() - -- local p = Path:new(Path.path.root(vim.loop.cwd())) - -- assert.are.same(p:parent(), p) - -- end) - -- end) - - -- describe("read parts", function() - -- it_cross_plat("should read head of file", function() - -- local p = Path:new "LICENSE" - -- local data = p:head() - -- local should = [[MIT License - - -- Copyright (c) 2020 TJ DeVries - - -- Permission is hereby granted, free of charge, to any person obtaining a copy - -- of this software and associated documentation files (the "Software"), to deal - -- in the Software without restriction, including without limitation the rights - -- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - -- copies of the Software, and to permit persons to whom the Software is - -- furnished to do so, subject to the following conditions:]] - -- assert.are.same(should, data) - -- end) - - -- it_cross_plat("should read the first line of file", function() - -- local p = Path:new "LICENSE" - -- local data = p:head(1) - -- local should = [[MIT License]] - -- assert.are.same(should, data) - -- end) - - -- it_cross_plat("head should max read whole file", function() - -- local p = Path:new "LICENSE" - -- local data = p:head(1000) - -- local should = [[MIT License - - -- Copyright (c) 2020 TJ DeVries - - -- Permission is hereby granted, free of charge, to any person obtaining a copy - -- of this software and associated documentation files (the "Software"), to deal - -- in the Software without restriction, including without limitation the rights - -- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - -- copies of the Software, and to permit persons to whom the Software is - -- furnished to do so, subject to the following conditions: - - -- The above copyright notice and this permission notice shall be included in all - -- copies or substantial portions of the Software. - - -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - -- SOFTWARE.]] - -- assert.are.same(should, data) - -- end) - - -- it_cross_plat("should read tail of file", function() - -- local p = Path:new "LICENSE" - -- local data = p:tail() - -- local should = [[The above copyright notice and this permission notice shall be included in all - -- copies or substantial portions of the Software. - - -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - -- SOFTWARE.]] - -- assert.are.same(should, data) - -- end) - - -- it_cross_plat("should read the last line of file", function() - -- local p = Path:new "LICENSE" - -- local data = p:tail(1) - -- local should = [[SOFTWARE.]] - -- assert.are.same(should, data) - -- end) - - -- it_cross_plat("tail should max read whole file", function() - -- local p = Path:new "LICENSE" - -- local data = p:tail(1000) - -- local should = [[MIT License - - -- Copyright (c) 2020 TJ DeVries - - -- Permission is hereby granted, free of charge, to any person obtaining a copy - -- of this software and associated documentation files (the "Software"), to deal - -- in the Software without restriction, including without limitation the rights - -- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - -- copies of the Software, and to permit persons to whom the Software is - -- furnished to do so, subject to the following conditions: - - -- The above copyright notice and this permission notice shall be included in all - -- copies or substantial portions of the Software. - - -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - -- SOFTWARE.]] - -- assert.are.same(should, data) - -- end) - -- end) - - -- describe("readbyterange", function() - -- it_cross_plat("should read bytes at given offset", function() - -- local p = Path:new "LICENSE" - -- local data = p:readbyterange(13, 10) - -- local should = "Copyright " - -- assert.are.same(should, data) - -- end) - - -- it_cross_plat("supports negative offset", function() - -- local p = Path:new "LICENSE" - -- local data = p:readbyterange(-10, 10) - -- local should = "SOFTWARE.\n" - -- assert.are.same(should, data) - -- end) - -- end) end) diff --git a/tests/plenary/path4_spec.lua b/tests/plenary/path4_spec.lua new file mode 100644 index 00000000..d5e86452 --- /dev/null +++ b/tests/plenary/path4_spec.lua @@ -0,0 +1,910 @@ +local Path = require "plenary.path4" +local path = Path.path +-- local compat = require "plenary.compat" +local iswin = vim.loop.os_uname().sysname == "Windows_NT" + +local hasshellslash = vim.fn.exists "+shellslash" == 1 + +---@param bool boolean +local function set_shellslash(bool) + if hasshellslash then + vim.o.shellslash = bool + end +end + +local function it_ssl(name, test_fn) + if not hasshellslash then + it(name, test_fn) + else + local orig = vim.o.shellslash + vim.o.shellslash = true + it(name .. " - shellslash", test_fn) + + vim.o.shellslash = false + it(name .. " - noshellslash", test_fn) + vim.o.shellslash = orig + end +end + +local function it_cross_plat(name, test_fn) + if not iswin then + it(name .. " - unix", test_fn) + else + it_ssl(name .. " - windows", test_fn) + end +end + +--- convert unix path into window paths +local function plat_path(p) + if not iswin then + return p + end + if hasshellslash and vim.o.shellslash then + return p + end + return p:gsub("/", "\\") +end + +describe("absolute", function() + describe("unix", function() + if iswin then + return + end + end) + + describe("windows", function() + if not iswin then + return + end + + describe("shellslash", function() + set_shellslash(true) + end) + + describe("noshellslash", function() + set_shellslash(false) + end) + end) +end) + +describe("Path", function() + describe("filename", function() + local function get_paths() + local readme_path = vim.fn.fnamemodify("README.md", ":p") + + ---@type [string[]|string, string][] + local paths = { + { "README.md", "README.md" }, + { { "README.md" }, "README.md" }, + { { "lua", "..", "README.md" }, "lua/../README.md" }, + { { "lua/../README.md" }, "lua/../README.md" }, + { { "./lua/../README.md" }, "./lua/../README.md" }, + { "./lua//..//README.md", "./lua/../README.md" }, + { { readme_path }, readme_path }, + } + + return paths + end + + local function test_filename(test_cases) + for _, tc in ipairs(test_cases) do + local input, expect = tc[1], tc[2] + it(vim.inspect(input), function() + local p = Path:new(input) + assert.are.same(expect, p.filename) + end) + end + end + + describe("unix", function() + if iswin then + return + end + test_filename(get_paths()) + end) + + describe("windows", function() + if not iswin then + return + end + + local function get_windows_paths() + local nossl = hasshellslash and not vim.o.shellslash + + ---@type [string[]|string, string][] + local paths = { + { [[C:\Documents\Newsletters\Summer2018.pdf]], [[C:/Documents/Newsletters/Summer2018.pdf]] }, + { [[C:\\Documents\\Newsletters\Summer2018.pdf]], [[C:/Documents/Newsletters/Summer2018.pdf]] }, + { { [[C:\Documents\Newsletters\Summer2018.pdf]] }, [[C:/Documents/Newsletters/Summer2018.pdf]] }, + { { [[C:/Documents/Newsletters/Summer2018.pdf]] }, [[C:/Documents/Newsletters/Summer2018.pdf]] }, + { { [[\\Server2\Share\Test\Foo.txt]] }, [[//Server2/Share/Test/Foo.txt]] }, + { { [[//Server2/Share/Test/Foo.txt]] }, [[//Server2/Share/Test/Foo.txt]] }, + { [[//Server2//Share//Test/Foo.txt]], [[//Server2/Share/Test/Foo.txt]] }, + { [[\\Server2\\Share\\Test\Foo.txt]], [[//Server2/Share/Test/Foo.txt]] }, + { { "C:", "lua", "..", "README.md" }, "C:lua/../README.md" }, + { { "C:/", "lua", "..", "README.md" }, "C:/lua/../README.md" }, + { "C:lua/../README.md", "C:lua/../README.md" }, + { "C:/lua/../README.md", "C:/lua/../README.md" }, + { [[foo/bar\baz]], [[foo/bar/baz]] }, + { [[\\.\C:\Test\Foo.txt]], [[//./C:/Test/Foo.txt]] }, + { [[\\?\C:\Test\Foo.txt]], [[//?/C:/Test/Foo.txt]] }, + } + vim.list_extend(paths, get_paths()) + + if nossl then + paths = vim.tbl_map(function(tc) + return { tc[1], (tc[2]:gsub("/", "\\")) } + end, paths) + end + + return paths + end + + it("custom sep", function() + local p = Path:new { "foo\\bar/baz", sep = "/" } + assert.are.same(p.filename, "foo/bar/baz") + end) + + describe("noshellslash", function() + set_shellslash(false) + test_filename(get_windows_paths()) + end) + + describe("shellslash", function() + set_shellslash(true) + test_filename(get_windows_paths()) + end) + end) + end) + + describe("absolute", function() + local function get_paths() + local readme_path = vim.fn.fnamemodify("README.md", ":p") + + ---@type [string[]|string, string, boolean][] + local paths = { + { "README.md", readme_path, false }, + { { "lua", "..", "README.md" }, readme_path, false }, + { { readme_path }, readme_path, true }, + } + return paths + end + + local function test_absolute(test_cases) + for _, tc in ipairs(test_cases) do + local input, expect, is_absolute = tc[1], tc[2], tc[3] + it(vim.inspect(input), function() + local p = Path:new(input) + assert.are.same(expect, p:absolute()) + assert.are.same(is_absolute, p:is_absolute()) + end) + end + end + + describe("unix", function() + if iswin then + return + end + test_absolute(get_paths()) + end) + + describe("windows", function() + if not iswin then + return + end + + local function get_windows_paths() + local nossl = hasshellslash and not vim.o.shellslash + local disk = path.root():match "^[%a]:" + local readme_path = vim.fn.fnamemodify("README.md", ":p") + + ---@type [string[]|string, string, boolean][] + local paths = { + { [[C:\Documents\Newsletters\Summer2018.pdf]], [[C:/Documents/Newsletters/Summer2018.pdf]], true }, + { [[C:/Documents/Newsletters/Summer2018.pdf]], [[C:/Documents/Newsletters/Summer2018.pdf]], true }, + { [[\\Server2\Share\Test\Foo.txt]], [[//Server2/Share/Test/Foo.txt]], true }, + { [[//Server2/Share/Test/Foo.txt]], [[//Server2/Share/Test/Foo.txt]], true }, + { [[\\.\C:\Test\Foo.txt]], [[//./C:/Test/Foo.txt]], true }, + { [[\\?\C:\Test\Foo.txt]], [[//?/C:/Test/Foo.txt]], true }, + { readme_path, readme_path, true }, + { disk .. [[lua/../README.md]], readme_path, false }, + { { disk, "lua", "..", "README.md" }, readme_path, false }, + } + vim.list_extend(paths, get_paths()) + + if nossl then + paths = vim.tbl_map(function(tc) + return { tc[1], (tc[2]:gsub("/", "\\")), tc[3] } + end, paths) + end + + return paths + end + + describe("shellslash", function() + set_shellslash(true) + test_absolute(get_windows_paths()) + end) + + describe("noshellslash", function() + set_shellslash(false) + test_absolute(get_windows_paths()) + end) + end) + end) + + it_cross_plat("can join paths by constructor or join path", function() + assert.are.same(Path:new("lua", "plenary"), Path:new("lua"):joinpath "plenary") + end) + + it_cross_plat("can join paths with /", function() + assert.are.same(Path:new("lua", "plenary"), Path:new "lua" / "plenary") + end) + + it_cross_plat("can join paths with paths", function() + assert.are.same(Path:new("lua", "plenary"), Path:new("lua", Path:new "plenary")) + end) + + it_cross_plat("inserts slashes", function() + assert.are.same("lua" .. path.sep .. "plenary", Path:new("lua", "plenary").filename) + end) + + describe(".exists()", function() + it_cross_plat("finds files that exist", function() + assert.are.same(true, Path:new("README.md"):exists()) + end) + + it_cross_plat("returns false for files that do not exist", function() + assert.are.same(false, Path:new("asdf.md"):exists()) + end) + end) + + describe(".is_dir()", function() + it_cross_plat("should find directories that exist", function() + assert.are.same(true, Path:new("lua"):is_dir()) + end) + + it_cross_plat("should return false when the directory does not exist", function() + assert.are.same(false, Path:new("asdf"):is_dir()) + end) + + it_cross_plat("should not show files as directories", function() + assert.are.same(false, Path:new("README.md"):is_dir()) + end) + end) + + describe(".is_file()", function() + it_cross_plat("should not allow directories", function() + assert.are.same(true, not Path:new("lua"):is_file()) + end) + + it_cross_plat("should return false when the file does not exist", function() + assert.are.same(true, not Path:new("asdf"):is_file()) + end) + + it_cross_plat("should show files as file", function() + assert.are.same(true, Path:new("README.md"):is_file()) + end) + end) + + describe(":new", function() + it_cross_plat("can be called with or without colon", function() + -- This will work, cause we used a colon + local with_colon = Path:new "lua" + local no_colon = Path.new "lua" + + assert.are.same(with_colon, no_colon) + end) + end) + + describe(":make_relative", function() + local root = iswin and "c:\\" or "/" + it_cross_plat("can take absolute paths and make them relative to the cwd", function() + local p = Path:new { "lua", "plenary", "path.lua" } + local absolute = vim.loop.cwd() .. path.sep .. p.filename + local relative = Path:new(absolute):make_relative() + assert.are.same(p.filename, relative) + end) + + it_cross_plat("can take absolute paths and make them relative to a given path", function() + local r = Path:new { root, "home", "prime" } + local p = Path:new { "aoeu", "agen.lua" } + local absolute = r.filename .. path.sep .. p.filename + local relative = Path:new(absolute):make_relative(r.filename) + assert.are.same(relative, p.filename) + end) + + it_cross_plat("can take double separator absolute paths and make them relative to the cwd", function() + local p = Path:new { "lua", "plenary", "path.lua" } + local absolute = vim.loop.cwd() .. path.sep .. path.sep .. p.filename + local relative = Path:new(absolute):make_relative() + assert.are.same(relative, p.filename) + end) + + it_cross_plat("can take double separator absolute paths and make them relative to a given path", function() + local r = Path:new { root, "home", "prime" } + local p = Path:new { "aoeu", "agen.lua" } + local absolute = r.filename .. path.sep .. path.sep .. p.filename + local relative = Path:new(absolute):make_relative(r.filename) + assert.are.same(relative, p.filename) + end) + + it_cross_plat("can take absolute paths and make them relative to a given path with trailing separator", function() + local r = Path:new { root, "home", "prime" } + local p = Path:new { "aoeu", "agen.lua" } + local absolute = r.filename .. path.sep .. p.filename + local relative = Path:new(absolute):make_relative(r.filename .. path.sep) + assert.are.same(relative, p.filename) + end) + + it_cross_plat("can take absolute paths and make them relative to the root directory", function() + local p = Path:new { "home", "prime", "aoeu", "agen.lua" } + local absolute = root .. p.filename + local relative = Path:new(absolute):make_relative(root) + assert.are.same(relative, p.filename) + end) + + it_cross_plat("can take absolute paths and make them relative to themselves", function() + local p = Path:new { root, "home", "prime", "aoeu", "agen.lua" } + local relative = Path:new(p.filename):make_relative(p.filename) + assert.are.same(relative, ".") + end) + + it_cross_plat("should not truncate if path separator is not present after cwd", function() + local cwd = "tmp" .. path.sep .. "foo" + local p = Path:new { "tmp", "foo_bar", "fileb.lua" } + local relative = Path:new(p.filename):make_relative(cwd) + assert.are.same(p.filename, relative) + end) + + it_cross_plat("should not truncate if path separator is not present after cwd and cwd ends in path sep", function() + local cwd = "tmp" .. path.sep .. "foo" .. path.sep + local p = Path:new { "tmp", "foo_bar", "fileb.lua" } + local relative = Path:new(p.filename):make_relative(cwd) + assert.are.same(p.filename, relative) + end) + end) + + describe(":normalize", function() + local home = iswin and "C:/Users/test/" or "/home/test/" + local tmp_lua = iswin and "C:/Windows/Temp/lua" or "/tmp/lua" + + it_cross_plat("handles absolute paths with '.' and double separators", function() + local orig = iswin and "C:/Users/test/./p//path.lua" or "/home/test/./p//path.lua" + local final = Path:new(orig):normalize(home) + local expect = plat_path "p/path.lua" + assert.are.same(expect, final) + end) + + it_cross_plat("handles relative paths with '.' and double separators", function() + local orig = "lua//plenary/./path.lua" + local final = Path:new(orig):normalize() + local expect = plat_path "lua/plenary/path.lua" + assert.are.same(expect, final) + end) + + it_cross_plat("can normalize relative paths containing ..", function() + local orig = "lua/plenary/path.lua/../path.lua" + local final = Path:new(orig):normalize() + local expect = plat_path "lua/plenary/path.lua" + assert.are.same(expect, final) + end) + + it_cross_plat("can normalize relative paths with initial ..", function() + local p = Path:new "../lua/plenary/path.lua" + local expect = plat_path "lua/plenary/path.lua" + assert.are.same(expect, p:normalize(tmp_lua)) + end) + + -- it_cross_plat("can normalize relative paths to absolute when initial .. count matches cwd parts", function() + -- local p = Path:new "../../tmp/lua/plenary/path.lua" + -- assert.are.same("/tmp/lua/plenary/path.lua", p:normalize(tmp_lua)) + -- end) + + -- it_cross_plat("can normalize ~ when file is within home directory (trailing slash)", function() + -- local p = Path:new { home, "./test_file" } + -- p.path.home = home + -- p._cwd = tmp_lua + -- assert.are.same("~/test_file", p:normalize()) + -- end) + + -- it_cross_plat("can normalize ~ when file is within home directory (no trailing slash)", function() + -- local p = Path:new { home, "./test_file" } + -- p.path.home = home + -- p._cwd = tmp_lua + -- assert.are.same("~/test_file", p:normalize()) + -- end) + + -- it_cross_plat("handles usernames with a dash at the end", function() + -- local p = Path:new { home, "test_file" } + -- p.path.home = home + -- p._cwd = tmp_lua + -- assert.are.same("~/test_file", p:normalize()) + -- end) + + -- it_cross_plat("handles filenames with the same prefix as the home directory", function() + -- local pstr = iswin and "C:/Users/test.old/test_file" or "/home/test.old/test_file" + -- local p = Path:new(pstr) + -- p.path.home = home + -- assert.are.same(pstr, p:normalize()) + -- end) + end) + + -- describe(":shorten", function() + -- it_cross_plat("can shorten a path", function() + -- local long_path = "/this/is/a/long/path" + -- local short_path = Path:new(long_path):shorten() + -- assert.are.same(short_path, "/t/i/a/l/path") + -- end) + + -- it_cross_plat("can shorten a path's components to a given length", function() + -- local long_path = "/this/is/a/long/path" + -- local short_path = Path:new(long_path):shorten(2) + -- assert.are.same(short_path, "/th/is/a/lo/path") + + -- -- without the leading / + -- long_path = "this/is/a/long/path" + -- short_path = Path:new(long_path):shorten(3) + -- assert.are.same(short_path, "thi/is/a/lon/path") + + -- -- where len is greater than the length of the final component + -- long_path = "this/is/an/extremely/long/path" + -- short_path = Path:new(long_path):shorten(5) + -- assert.are.same(short_path, "this/is/an/extre/long/path") + -- end) + + -- it_cross_plat("can shorten a path's components when excluding parts", function() + -- local long_path = "/this/is/a/long/path" + -- local short_path = Path:new(long_path):shorten(nil, { 1, -1 }) + -- assert.are.same(short_path, "/this/i/a/l/path") + + -- -- without the leading / + -- long_path = "this/is/a/long/path" + -- short_path = Path:new(long_path):shorten(nil, { 1, -1 }) + -- assert.are.same(short_path, "this/i/a/l/path") + + -- -- where excluding positions greater than the number of parts + -- long_path = "this/is/an/extremely/long/path" + -- short_path = Path:new(long_path):shorten(nil, { 2, 4, 6, 8 }) + -- assert.are.same(short_path, "t/is/a/extremely/l/path") + + -- -- where excluding positions less than the negation of the number of parts + -- long_path = "this/is/an/extremely/long/path" + -- short_path = Path:new(long_path):shorten(nil, { -2, -4, -6, -8 }) + -- assert.are.same(short_path, "this/i/an/e/long/p") + -- end) + + -- it_cross_plat("can shorten a path's components to a given length and exclude positions", function() + -- local long_path = "/this/is/a/long/path" + -- local short_path = Path:new(long_path):shorten(2, { 1, -1 }) + -- assert.are.same(short_path, "/this/is/a/lo/path") + + -- long_path = "this/is/a/long/path" + -- short_path = Path:new(long_path):shorten(3, { 2, -2 }) + -- assert.are.same(short_path, "thi/is/a/long/pat") + + -- long_path = "this/is/an/extremely/long/path" + -- short_path = Path:new(long_path):shorten(5, { 3, -3 }) + -- assert.are.same(short_path, "this/is/an/extremely/long/path") + -- end) + -- end) + + -- describe("mkdir / rmdir", function() + -- it_cross_plat("can create and delete directories", function() + -- local p = Path:new "_dir_not_exist" + + -- p:rmdir() + -- assert(not p:exists(), "After rmdir, it should not exist") + + -- p:mkdir() + -- assert(p:exists()) + + -- p:rmdir() + -- assert(not p:exists()) + -- end) + + -- it_cross_plat("fails when exists_ok is false", function() + -- local p = Path:new "lua" + -- assert(not pcall(p.mkdir, p, { exists_ok = false })) + -- end) + + -- it_cross_plat("fails when parents is not passed", function() + -- local p = Path:new("impossible", "dir") + -- assert(not pcall(p.mkdir, p, { parents = false })) + -- assert(not p:exists()) + -- end) + + -- it_cross_plat("can create nested directories", function() + -- local p = Path:new("impossible", "dir") + -- assert(pcall(p.mkdir, p, { parents = true })) + -- assert(p:exists()) + + -- p:rmdir() + -- Path:new("impossible"):rmdir() + -- assert(not p:exists()) + -- assert(not Path:new("impossible"):exists()) + -- end) + -- end) + + -- describe("touch", function() + -- it_cross_plat("can create and delete new files", function() + -- local p = Path:new "test_file.lua" + -- assert(pcall(p.touch, p)) + -- assert(p:exists()) + + -- p:rm() + -- assert(not p:exists()) + -- end) + + -- it_cross_plat("does not effect already created files but updates last access", function() + -- local p = Path:new "README.md" + -- local last_atime = p:_stat().atime.sec + -- local last_mtime = p:_stat().mtime.sec + + -- local lines = p:readlines() + + -- assert(pcall(p.touch, p)) + -- print(p:_stat().atime.sec > last_atime) + -- print(p:_stat().mtime.sec > last_mtime) + -- assert(p:exists()) + + -- assert.are.same(lines, p:readlines()) + -- end) + + -- it_cross_plat("does not create dirs if nested in none existing dirs and parents not set", function() + -- local p = Path:new { "nested", "nested2", "test_file.lua" } + -- assert(not pcall(p.touch, p, { parents = false })) + -- assert(not p:exists()) + -- end) + + -- it_cross_plat("does create dirs if nested in none existing dirs", function() + -- local p1 = Path:new { "nested", "nested2", "test_file.lua" } + -- local p2 = Path:new { "nested", "asdf", ".hidden" } + -- local d1 = Path:new { "nested", "dir", ".hidden" } + -- assert(pcall(p1.touch, p1, { parents = true })) + -- assert(pcall(p2.touch, p2, { parents = true })) + -- assert(pcall(d1.mkdir, d1, { parents = true })) + -- assert(p1:exists()) + -- assert(p2:exists()) + -- assert(d1:exists()) + + -- Path:new({ "nested" }):rm { recursive = true } + -- assert(not p1:exists()) + -- assert(not p2:exists()) + -- assert(not d1:exists()) + -- assert(not Path:new({ "nested" }):exists()) + -- end) + -- end) + + -- describe("rename", function() + -- it_cross_plat("can rename a file", function() + -- local p = Path:new "a_random_filename.lua" + -- assert(pcall(p.touch, p)) + -- assert(p:exists()) + + -- assert(pcall(p.rename, p, { new_name = "not_a_random_filename.lua" })) + -- assert.are.same("not_a_random_filename.lua", p.filename) + + -- p:rm() + -- end) + + -- it_cross_plat("can handle an invalid filename", function() + -- local p = Path:new "some_random_filename.lua" + -- assert(pcall(p.touch, p)) + -- assert(p:exists()) + + -- assert(not pcall(p.rename, p, { new_name = "" })) + -- assert(not pcall(p.rename, p)) + -- assert.are.same("some_random_filename.lua", p.filename) + + -- p:rm() + -- end) + + -- it_cross_plat("can move to parent dir", function() + -- local p = Path:new "some_random_filename.lua" + -- assert(pcall(p.touch, p)) + -- assert(p:exists()) + + -- assert(pcall(p.rename, p, { new_name = "../some_random_filename.lua" })) + -- assert.are.same(vim.loop.fs_realpath(Path:new("../some_random_filename.lua"):absolute()), p:absolute()) + + -- p:rm() + -- end) + + -- it_cross_plat("cannot rename to an existing filename", function() + -- local p1 = Path:new "a_random_filename.lua" + -- local p2 = Path:new "not_a_random_filename.lua" + -- assert(pcall(p1.touch, p1)) + -- assert(pcall(p2.touch, p2)) + -- assert(p1:exists()) + -- assert(p2:exists()) + + -- assert(not pcall(p1.rename, p1, { new_name = "not_a_random_filename.lua" })) + -- assert.are.same(p1.filename, "a_random_filename.lua") + + -- p1:rm() + -- p2:rm() + -- end) + -- end) + + -- describe("copy", function() + -- it_cross_plat("can copy a file", function() + -- local p1 = Path:new "a_random_filename.rs" + -- local p2 = Path:new "not_a_random_filename.rs" + -- assert(pcall(p1.touch, p1)) + -- assert(p1:exists()) + + -- assert(pcall(p1.copy, p1, { destination = "not_a_random_filename.rs" })) + -- assert.are.same(p1.filename, "a_random_filename.rs") + -- assert.are.same(p2.filename, "not_a_random_filename.rs") + + -- p1:rm() + -- p2:rm() + -- end) + + -- it_cross_plat("can copy to parent dir", function() + -- local p = Path:new "some_random_filename.lua" + -- assert(pcall(p.touch, p)) + -- assert(p:exists()) + + -- assert(pcall(p.copy, p, { destination = "../some_random_filename.lua" })) + -- assert(pcall(p.exists, p)) + + -- p:rm() + -- Path:new(vim.loop.fs_realpath "../some_random_filename.lua"):rm() + -- end) + + -- it_cross_plat("cannot copy an existing file if override false", function() + -- local p1 = Path:new "a_random_filename.rs" + -- local p2 = Path:new "not_a_random_filename.rs" + -- assert(pcall(p1.touch, p1)) + -- assert(pcall(p2.touch, p2)) + -- assert(p1:exists()) + -- assert(p2:exists()) + + -- assert(pcall(p1.copy, p1, { destination = "not_a_random_filename.rs", override = false })) + -- assert.are.same(p1.filename, "a_random_filename.rs") + -- assert.are.same(p2.filename, "not_a_random_filename.rs") + + -- p1:rm() + -- p2:rm() + -- end) + + -- it_cross_plat("fails when copying folders non-recursively", function() + -- local src_dir = Path:new "src" + -- src_dir:mkdir() + -- src_dir:joinpath("file1.lua"):touch() + + -- local trg_dir = Path:new "trg" + -- local status = xpcall(function() + -- src_dir:copy { destination = trg_dir, recursive = false } + -- end, function() end) + -- -- failed as intended + -- assert(status == false) + + -- src_dir:rm { recursive = true } + -- end) + + -- it_cross_plat("can copy directories recursively", function() + -- -- vim.tbl_flatten doesn't work here as copy doesn't return a list + -- local flatten + -- flatten = function(ret, t) + -- for _, v in pairs(t) do + -- if type(v) == "table" then + -- flatten(ret, v) + -- else + -- table.insert(ret, v) + -- end + -- end + -- end + + -- -- setup directories + -- local src_dir = Path:new "src" + -- local trg_dir = Path:new "trg" + -- src_dir:mkdir() + + -- -- set up sub directory paths for creation and testing + -- local sub_dirs = { "sub_dir1", "sub_dir1/sub_dir2" } + -- local src_dirs = { src_dir } + -- local trg_dirs = { trg_dir } + -- -- {src, trg}_dirs is a table with all directory levels by {src, trg} + -- for _, dir in ipairs(sub_dirs) do + -- table.insert(src_dirs, src_dir:joinpath(dir)) + -- table.insert(trg_dirs, trg_dir:joinpath(dir)) + -- end + + -- -- generate {file}_{level}.lua on every directory level in src + -- -- src + -- -- ├── file1_1.lua + -- -- ├── file2_1.lua + -- -- ├── .file3_1.lua + -- -- └── sub_dir1 + -- -- ├── file1_2.lua + -- -- ├── file2_2.lua + -- -- ├── .file3_2.lua + -- -- └── sub_dir2 + -- -- ├── file1_3.lua + -- -- ├── file2_3.lua + -- -- └── .file3_3.lua + -- local files = { "file1", "file2", ".file3" } + -- for _, file in ipairs(files) do + -- for level, dir in ipairs(src_dirs) do + -- local p = dir:joinpath(file .. "_" .. level .. ".lua") + -- assert(pcall(p.touch, p, { parents = true, exists_ok = true })) + -- assert(p:exists()) + -- end + -- end + + -- for _, hidden in ipairs { true, false } do + -- -- override = `false` should NOT copy as it was copied beforehand + -- for _, override in ipairs { true, false } do + -- local success = src_dir:copy { destination = trg_dir, recursive = true, override = override, hidden = hidden } + -- -- the files are already created because we iterate first with `override=true` + -- -- hence, we test here that no file ops have been committed: any value in tbl of tbls should be false + -- if not override then + -- local file_ops = {} + -- flatten(file_ops, success) + -- -- 3 layers with at at least 2 and at most 3 files (`hidden = true`) + -- local num_files = not hidden and 6 or 9 + -- assert(#file_ops == num_files) + -- for _, op in ipairs(file_ops) do + -- assert(op == false) + -- end + -- else + -- for _, file in ipairs(files) do + -- for level, dir in ipairs(trg_dirs) do + -- local p = dir:joinpath(file .. "_" .. level .. ".lua") + -- -- file 3 is hidden + -- if not (file == files[3]) then + -- assert(p:exists()) + -- else + -- assert(p:exists() == hidden) + -- end + -- end + -- end + -- end + -- -- only clean up once we tested that we dont want to copy + -- -- if `override=true` + -- if not override then + -- trg_dir:rm { recursive = true } + -- end + -- end + -- end + + -- src_dir:rm { recursive = true } + -- end) + -- end) + + -- describe("parents", function() + -- it_cross_plat("should extract the ancestors of the path", function() + -- local p = Path:new(vim.loop.cwd()) + -- local parents = p:parents() + -- assert(compat.islist(parents)) + -- for _, parent in pairs(parents) do + -- assert.are.same(type(parent), "string") + -- end + -- end) + -- it_cross_plat("should return itself if it corresponds to path.root", function() + -- local p = Path:new(Path.path.root(vim.loop.cwd())) + -- assert.are.same(p:parent(), p) + -- end) + -- end) + + -- describe("read parts", function() + -- it_cross_plat("should read head of file", function() + -- local p = Path:new "LICENSE" + -- local data = p:head() + -- local should = [[MIT License + + -- Copyright (c) 2020 TJ DeVries + + -- Permission is hereby granted, free of charge, to any person obtaining a copy + -- of this software and associated documentation files (the "Software"), to deal + -- in the Software without restriction, including without limitation the rights + -- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + -- copies of the Software, and to permit persons to whom the Software is + -- furnished to do so, subject to the following conditions:]] + -- assert.are.same(should, data) + -- end) + + -- it_cross_plat("should read the first line of file", function() + -- local p = Path:new "LICENSE" + -- local data = p:head(1) + -- local should = [[MIT License]] + -- assert.are.same(should, data) + -- end) + + -- it_cross_plat("head should max read whole file", function() + -- local p = Path:new "LICENSE" + -- local data = p:head(1000) + -- local should = [[MIT License + + -- Copyright (c) 2020 TJ DeVries + + -- Permission is hereby granted, free of charge, to any person obtaining a copy + -- of this software and associated documentation files (the "Software"), to deal + -- in the Software without restriction, including without limitation the rights + -- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + -- copies of the Software, and to permit persons to whom the Software is + -- furnished to do so, subject to the following conditions: + + -- The above copyright notice and this permission notice shall be included in all + -- copies or substantial portions of the Software. + + -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + -- SOFTWARE.]] + -- assert.are.same(should, data) + -- end) + + -- it_cross_plat("should read tail of file", function() + -- local p = Path:new "LICENSE" + -- local data = p:tail() + -- local should = [[The above copyright notice and this permission notice shall be included in all + -- copies or substantial portions of the Software. + + -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + -- SOFTWARE.]] + -- assert.are.same(should, data) + -- end) + + -- it_cross_plat("should read the last line of file", function() + -- local p = Path:new "LICENSE" + -- local data = p:tail(1) + -- local should = [[SOFTWARE.]] + -- assert.are.same(should, data) + -- end) + + -- it_cross_plat("tail should max read whole file", function() + -- local p = Path:new "LICENSE" + -- local data = p:tail(1000) + -- local should = [[MIT License + + -- Copyright (c) 2020 TJ DeVries + + -- Permission is hereby granted, free of charge, to any person obtaining a copy + -- of this software and associated documentation files (the "Software"), to deal + -- in the Software without restriction, including without limitation the rights + -- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + -- copies of the Software, and to permit persons to whom the Software is + -- furnished to do so, subject to the following conditions: + + -- The above copyright notice and this permission notice shall be included in all + -- copies or substantial portions of the Software. + + -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + -- SOFTWARE.]] + -- assert.are.same(should, data) + -- end) + -- end) + + -- describe("readbyterange", function() + -- it_cross_plat("should read bytes at given offset", function() + -- local p = Path:new "LICENSE" + -- local data = p:readbyterange(13, 10) + -- local should = "Copyright " + -- assert.are.same(should, data) + -- end) + + -- it_cross_plat("supports negative offset", function() + -- local p = Path:new "LICENSE" + -- local data = p:readbyterange(-10, 10) + -- local should = "SOFTWARE.\n" + -- assert.are.same(should, data) + -- end) + -- end) +end) From 9895af2557619a733e05aee4e4e1696b109c07dc Mon Sep 17 00:00:00 2001 From: James Trew Date: Sat, 24 Aug 2024 22:28:05 -0400 Subject: [PATCH 04/43] add path joining and is_dir --- lua/plenary/path2.lua | 61 ++++++++++++++++++++++++++++++------ tests/plenary/path2_spec.lua | 32 +++++++++++++------ 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index c882496e..ecc28a5f 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -243,6 +243,7 @@ end ---@field parts string[] path parts excluding separators --- ---@field filename string +---@field cwd string ---@field private _absolute string? lazy eval'ed fully resolved absolute path local Path = { path = path } @@ -256,6 +257,26 @@ Path.__index = function(t, k) t.filename = t:_filename() return t.filename end + + if k == "cwd" then + t.cwd = vim.fn.getcwd() + return t.cwd + end +end + +Path.__div = function(self, other) + assert(Path.is_path(self)) + assert(Path.is_path(other) or type(other) == "string") + + return self:joinpath(other) +end + +Path.__tostring = function(self) + return self.filename +end + +Path.__concat = function(self, other) + return self.filename .. other end ---@alias plenary.Path2Args string|plenary.Path2|(string|plenary.Path2)[] @@ -310,18 +331,17 @@ function Path:new(...) end error "'Path' object is read-only" end, + -- stylua: ignore start + __div = function(t, other) return Path.__div(t, other) end, + __concat = function(t, other) return Path.__concat(t, other) end, + __tostring = function(t) return Path.__tostring(t) end, __metatable = Path, + -- stylua: ignore end }) return obj end ----@param x any ----@return boolean -function Path.is_path(x) - return getmetatable(x) == Path -end - ---@private ---@param drv string? ---@param root string? @@ -344,6 +364,12 @@ function Path:_filename(drv, root, parts) return drv .. root .. relparts end +---@param x any +---@return boolean +function Path.is_path(x) + return getmetatable(x) == Path +end + ---@return boolean function Path:is_absolute() if self.root == "" then @@ -353,6 +379,16 @@ function Path:is_absolute() return self._path.has_drv and self.drv ~= "" end +--- if path doesn't exists, returns false +---@return boolean +function Path:is_dir() + local stat = uv.fs_stat(self:absolute()) + if stat then + return stat.type == "directory" + end + return false +end + ---@param parts string[] path parts ---@return string[] local function resolve_dots(parts) @@ -373,6 +409,10 @@ local function resolve_dots(parts) end --- normalized and resolved absolute path +--- +--- if given path doesn't exists and isn't already an absolute path, creates +--- one using the cwd +--- --- respects 'shellslash' on Windows ---@return string function Path:absolute() @@ -381,16 +421,17 @@ function Path:absolute() end local parts = resolve_dots(self.parts) - local filename = self:_filename(self.drv, self.root, parts) if self:is_absolute() then - self._absolute = filename + self._absolute = self:_filename(nil, nil, parts) else -- using fs_realpath over fnamemodify -- fs_realpath resolves symlinks whereas fnamemodify doesn't but we're -- resolving/normalizing the path anyways for reasons of compat with old Path - self._absolute = uv.fs_realpath(self:_filename()) + local p = uv.fs_realpath(self:_filename()) or Path:new({ self.cwd, self }):absolute() if self.path.isshellslash then - self._absolute = self._absolute:gsub("\\", path.sep) + self._absolute = p:gsub("\\", path.sep) + else + self._absolute = p end end return self._absolute diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index dc1c087d..c3d9d33d 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -217,15 +217,29 @@ describe("Path2", function() assert.are.same(Path:new("lua", "plenary"), Path:new("lua"):joinpath "plenary") end) - -- it_cross_plat("can join paths with /", function() - -- assert.are.same(Path:new("lua", "plenary"), Path:new "lua" / "plenary") - -- end) + it_cross_plat("can join paths with /", function() + assert.are.same(Path:new("lua", "plenary"), Path:new "lua" / "plenary") + end) + + it_cross_plat("can join paths with paths", function() + assert.are.same(Path:new("lua", "plenary"), Path:new("lua", Path:new "plenary")) + end) + + it_cross_plat("inserts slashes", function() + assert.are.same("lua" .. path.sep .. "plenary", Path:new("lua", "plenary").filename) + end) - -- it_cross_plat("can join paths with paths", function() - -- assert.are.same(Path:new("lua", "plenary"), Path:new("lua", Path:new "plenary")) - -- end) + describe(".is_dir()", function() + it_cross_plat("should find directories that exist", function() + assert.are.same(true, Path:new("lua"):is_dir()) + end) + + it_cross_plat("should return false when the directory does not exist", function() + assert.are.same(false, Path:new("asdf"):is_dir()) + end) - -- it_cross_plat("inserts slashes", function() - -- assert.are.same("lua" .. path.sep .. "plenary", Path:new("lua", "plenary").filename) - -- end) + it_cross_plat("should not show files as directories", function() + assert.are.same(false, Path:new("README.md"):is_dir()) + end) + end) end) From 049335c61ad43bc8b5eafc8c8abc418b6cb54b41 Mon Sep 17 00:00:00 2001 From: James Trew Date: Sun, 25 Aug 2024 14:18:20 -0400 Subject: [PATCH 05/43] exist and is_file --- lua/plenary/path2.lua | 96 ++++++++++++++++++++++++++++++++++-- tests/plenary/path2_spec.lua | 92 ++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 3 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index ecc28a5f..70a11839 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -1,3 +1,25 @@ +--- NOTES: +--- Rework on plenary.Path with a focus on better cross-platform support +--- including 'shellslash' support. +--- Effort to improve performance made (notably `:absolue` ~2x faster). +--- +--- Some finiky behaviors ironed out +--- eg. `:normalize` +--- TODO: demonstrate +--- +--- BREAKING CHANGES: +--- - `Path.new` no longer supported (think it's more confusing that helpful +--- and not really used as far as I can tell) +--- +--- - drop `__concat` metamethod? it was untested, not sure how functional it is +--- +--- - `Path` objects are now "read-only", I don't think people were ever doing +--- things like `path.filename = 'foo'` but now explicitly adding some barrier +--- to this. Allows us to compute `filename` from "metadata" parsed once on +--- instantiation. + +--- TODO: rework `split_root` logic according to python 3.12 + local uv = vim.loop local iswin = uv.os_uname().sysname == "Windows_NT" local hasshellslash = vim.fn.exists "+shellslash" == 1 @@ -264,6 +286,9 @@ Path.__index = function(t, k) end end +---@param self plenary.Path2 +---@param other string|plenary.Path2 +---@return plenary.Path2 Path.__div = function(self, other) assert(Path.is_path(self)) assert(Path.is_path(other) or type(other) == "string") @@ -271,12 +296,22 @@ Path.__div = function(self, other) return self:joinpath(other) end +---@param self plenary.Path2 +---@return string Path.__tostring = function(self) return self.filename end -Path.__concat = function(self, other) - return self.filename .. other +---@param self plenary.Path2 +---@param other string|plenary.Path2 +---@return boolean +Path.__eq = function(self, other) + assert(Path.is_path(self)) + assert(Path.is_path(other) or type(other) == "string") + -- TODO + if true then + error "not yet implemented" + end end ---@alias plenary.Path2Args string|plenary.Path2|(string|plenary.Path2)[] @@ -335,6 +370,7 @@ function Path:new(...) __div = function(t, other) return Path.__div(t, other) end, __concat = function(t, other) return Path.__concat(t, other) end, __tostring = function(t) return Path.__tostring(t) end, + __eq = function(t, other) return Path.__eq(t, other) end, __metatable = Path, -- stylua: ignore end }) @@ -379,6 +415,12 @@ function Path:is_absolute() return self._path.has_drv and self.drv ~= "" end +---@return boolean +function Path:exists() + local stat = uv.fs_stat(self:absolute()) + return stat ~= nil and not vim.tbl_isempty(stat) +end + --- if path doesn't exists, returns false ---@return boolean function Path:is_dir() @@ -389,6 +431,16 @@ function Path:is_dir() return false end +--- if path doesn't exists, returns false +---@return boolean +function Path:is_file() + local stat = uv.fs_stat(self:absolute()) + if stat then + return stat.type == "file" + end + return false +end + ---@param parts string[] path parts ---@return string[] local function resolve_dots(parts) @@ -443,8 +495,46 @@ function Path:joinpath(...) return Path:new { self, ... } end +--- makes a path relative to another (by default the cwd). +--- if path is already a relative path +---@param to string|plenary.Path2? absolute path to make relative to (default: cwd) +---@return string +function Path:make_relative(to) + to = vim.F.if_nil(to, self.cwd) + if type(to) == "string" then + to = Path:new(to) + end + + if self:is_absolute() then + local to_abs = to:absolute() + + if to_abs == self:absolute() then + return "." + else + -- TODO + end + else + end + + -- SEE: `Path.relative_to` implementation (3.12) specifically `walk_up` param + + local matches = true + for i = 1, #to.parts do + if to.parts[i] ~= self.parts[i] then + matches = false + break + end + end + + if matches then + return "." + end + + -- /home/jt/foo/bar/baz + -- /home/jt +end + -- vim.o.shellslash = false -local p = Path:new("lua"):joinpath "plenary" -- vim.print(p) -- print(p.filename, p:is_absolute(), p:absolute()) -- vim.o.shellslash = true diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index c3d9d33d..0df36cf8 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -229,6 +229,16 @@ describe("Path2", function() assert.are.same("lua" .. path.sep .. "plenary", Path:new("lua", "plenary").filename) end) + describe(".exists()", function() + it_cross_plat("finds files that exist", function() + assert.are.same(true, Path:new("README.md"):exists()) + end) + + it_cross_plat("returns false for files that do not exist", function() + assert.are.same(false, Path:new("asdf.md"):exists()) + end) + end) + describe(".is_dir()", function() it_cross_plat("should find directories that exist", function() assert.are.same(true, Path:new("lua"):is_dir()) @@ -242,4 +252,86 @@ describe("Path2", function() assert.are.same(false, Path:new("README.md"):is_dir()) end) end) + + describe(".is_file()", function() + it_cross_plat("should not allow directories", function() + assert.are.same(true, not Path:new("lua"):is_file()) + end) + + it_cross_plat("should return false when the file does not exist", function() + assert.are.same(true, not Path:new("asdf"):is_file()) + end) + + it_cross_plat("should show files as file", function() + assert.are.same(true, Path:new("README.md"):is_file()) + end) + end) + + -- describe(":make_relative", function() + -- local root = iswin and "c:\\" or "/" + -- it_cross_plat("can take absolute paths and make them relative to the cwd", function() + -- local p = Path:new { "lua", "plenary", "path.lua" } + -- local absolute = vim.loop.cwd() .. path.sep .. p.filename + -- local relative = Path:new(absolute):make_relative() + -- assert.are.same(p.filename, relative) + -- end) + + -- it_cross_plat("can take absolute paths and make them relative to a given path", function() + -- local r = Path:new { root, "home", "prime" } + -- local p = Path:new { "aoeu", "agen.lua" } + -- local absolute = r.filename .. path.sep .. p.filename + -- local relative = Path:new(absolute):make_relative(r.filename) + -- assert.are.same(relative, p.filename) + -- end) + + -- it_cross_plat("can take double separator absolute paths and make them relative to the cwd", function() + -- local p = Path:new { "lua", "plenary", "path.lua" } + -- local absolute = vim.loop.cwd() .. path.sep .. path.sep .. p.filename + -- local relative = Path:new(absolute):make_relative() + -- assert.are.same(relative, p.filename) + -- end) + + -- it_cross_plat("can take double separator absolute paths and make them relative to a given path", function() + -- local r = Path:new { root, "home", "prime" } + -- local p = Path:new { "aoeu", "agen.lua" } + -- local absolute = r.filename .. path.sep .. path.sep .. p.filename + -- local relative = Path:new(absolute):make_relative(r.filename) + -- assert.are.same(relative, p.filename) + -- end) + + -- it_cross_plat("can take absolute paths and make them relative to a given path with trailing separator", function() + -- local r = Path:new { root, "home", "prime" } + -- local p = Path:new { "aoeu", "agen.lua" } + -- local absolute = r.filename .. path.sep .. p.filename + -- local relative = Path:new(absolute):make_relative(r.filename .. path.sep) + -- assert.are.same(relative, p.filename) + -- end) + + -- it_cross_plat("can take absolute paths and make them relative to the root directory", function() + -- local p = Path:new { "home", "prime", "aoeu", "agen.lua" } + -- local absolute = root .. p.filename + -- local relative = Path:new(absolute):make_relative(root) + -- assert.are.same(relative, p.filename) + -- end) + + -- it_cross_plat("can take absolute paths and make them relative to themselves", function() + -- local p = Path:new { root, "home", "prime", "aoeu", "agen.lua" } + -- local relative = Path:new(p.filename):make_relative(p.filename) + -- assert.are.same(relative, ".") + -- end) + + -- it_cross_plat("should not truncate if path separator is not present after cwd", function() + -- local cwd = "tmp" .. path.sep .. "foo" + -- local p = Path:new { "tmp", "foo_bar", "fileb.lua" } + -- local relative = Path:new(p.filename):make_relative(cwd) + -- assert.are.same(p.filename, relative) + -- end) + + -- it_cross_plat("should not truncate if path separator is not present after cwd and cwd ends in path sep", function() + -- local cwd = "tmp" .. path.sep .. "foo" .. path.sep + -- local p = Path:new { "tmp", "foo_bar", "fileb.lua" } + -- local relative = Path:new(p.filename):make_relative(cwd) + -- assert.are.same(p.filename, relative) + -- end) + -- end) end) From f4072c446a1b330edc12b767ccf42ba2ba37bc82 Mon Sep 17 00:00:00 2001 From: James Trew Date: Sun, 25 Aug 2024 14:31:03 -0400 Subject: [PATCH 06/43] change parts to relparts simpler --- lua/plenary/path2.lua | 52 +++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 70a11839..0b589825 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -19,6 +19,7 @@ --- instantiation. --- TODO: rework `split_root` logic according to python 3.12 +--- TODO: rework `_filename` according to `_format_parsed_parts` local uv = vim.loop local iswin = uv.os_uname().sysname == "Windows_NT" @@ -28,6 +29,7 @@ local hasshellslash = vim.fn.exists "+shellslash" == 1 ---@field sep string ---@field altsep string ---@field has_drv boolean +---@field case_sensitive boolean ---@field convert_altsep fun(self: plenary._Path, p:string): string ---@field split_root fun(self: plenary._Path, part:string): string, string, string @@ -36,6 +38,7 @@ local _WindowsPath = { sep = "\\", altsep = "/", has_drv = true, + case_sensitive = true, } setmetatable(_WindowsPath, { __index = _WindowsPath }) @@ -123,6 +126,7 @@ local _PosixPath = { sep = "/", altsep = "", has_drv = false, + case_sensitive = true, } setmetatable(_PosixPath, { __index = _PosixPath }) @@ -245,10 +249,6 @@ local function parse_parts(parts, _path) end end - if drv or root then - table.insert(parsed, drv .. root) - end - local n = #parsed for i = 1, math.floor(n / 2) do parsed[i], parsed[n - i + 1] = parsed[n - i + 1], parsed[i] @@ -262,7 +262,7 @@ end ---@field private _path plenary._Path ---@field drv string drive name, eg. 'C:' (only for Windows) ---@field root string root path (excludes drive name) ----@field parts string[] path parts excluding separators +---@field relparts string[] relative path parts excluding separators --- ---@field filename string ---@field cwd string @@ -309,9 +309,10 @@ Path.__eq = function(self, other) assert(Path.is_path(self)) assert(Path.is_path(other) or type(other) == "string") -- TODO - if true then - error "not yet implemented" - end + -- if true then + -- error "not yet implemented" + -- end + return self.filename == other.filename end ---@alias plenary.Path2Args string|plenary.Path2|(string|plenary.Path2)[] @@ -336,22 +337,22 @@ function Path:new(...) end end - local parts = {} + local relparts = {} for _, a in ipairs(args) do if self.is_path(a) then - vim.list_extend(parts, a.parts) + vim.list_extend(relparts, a.relparts) else if a ~= "" then - table.insert(parts, a) + table.insert(relparts, a) end end end local _path = iswin and _WindowsPath or _PosixPath local drv, root - drv, root, parts = parse_parts(parts, _path) + drv, root, relparts = parse_parts(relparts, _path) - local proxy = { _path = _path, drv = drv, root = root, parts = parts } + local proxy = { _path = _path, drv = drv, root = root, relparts = relparts } setmetatable(proxy, Path) local obj = { __inner = proxy } @@ -368,7 +369,6 @@ function Path:new(...) end, -- stylua: ignore start __div = function(t, other) return Path.__div(t, other) end, - __concat = function(t, other) return Path.__concat(t, other) end, __tostring = function(t) return Path.__tostring(t) end, __eq = function(t, other) return Path.__eq(t, other) end, __metatable = Path, @@ -381,9 +381,9 @@ end ---@private ---@param drv string? ---@param root string? ----@param parts string[]? +---@param relparts string[]? ---@return string -function Path:_filename(drv, root, parts) +function Path:_filename(drv, root, relparts) drv = vim.F.if_nil(drv, self.drv) drv = self.drv ~= "" and self.drv:gsub(self._path.sep, path.sep) or "" @@ -394,10 +394,10 @@ function Path:_filename(drv, root, parts) root = self.root ~= "" and path.sep:rep(#self.root) or "" end - parts = vim.F.if_nil(parts, self.parts) - local relparts = table.concat(vim.list_slice(parts, 2), path.sep) + relparts = vim.F.if_nil(relparts, self.relparts) + local relpath = table.concat(relparts, path.sep) - return drv .. root .. relparts + return drv .. root .. relpath end ---@param x any @@ -441,11 +441,11 @@ function Path:is_file() return false end ----@param parts string[] path parts +---@param relparts string[] path parts ---@return string[] -local function resolve_dots(parts) +local function resolve_dots(relparts) local new_parts = {} - for _, part in ipairs(parts) do + for _, part in ipairs(relparts) do if part == ".." then if #new_parts > 0 and new_parts[#new_parts] ~= ".." then table.remove(new_parts) @@ -472,9 +472,9 @@ function Path:absolute() return self._absolute end - local parts = resolve_dots(self.parts) + local relparts = resolve_dots(self.relparts) if self:is_absolute() then - self._absolute = self:_filename(nil, nil, parts) + self._absolute = self:_filename(nil, nil, relparts) else -- using fs_realpath over fnamemodify -- fs_realpath resolves symlinks whereas fnamemodify doesn't but we're @@ -519,8 +519,8 @@ function Path:make_relative(to) -- SEE: `Path.relative_to` implementation (3.12) specifically `walk_up` param local matches = true - for i = 1, #to.parts do - if to.parts[i] ~= self.parts[i] then + for i = 1, #to.relparts do + if to.relparts[i] ~= self.relparts[i] then matches = false break end From 32ddebb002f6aae2eb1cf959404f17a06aa9940a Mon Sep 17 00:00:00 2001 From: James Trew Date: Sun, 25 Aug 2024 20:36:36 -0400 Subject: [PATCH 07/43] update Path instantiation and path parsing --- lua/plenary/path2.lua | 148 +++++++++++++++-------------------- tests/plenary/path2_spec.lua | 6 +- 2 files changed, 66 insertions(+), 88 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 0b589825..a42a94be 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -18,7 +18,6 @@ --- to this. Allows us to compute `filename` from "metadata" parsed once on --- instantiation. ---- TODO: rework `split_root` logic according to python 3.12 --- TODO: rework `_filename` according to `_format_parsed_parts` local uv = vim.loop @@ -49,76 +48,46 @@ function _WindowsPath:convert_altsep(p) return (p:gsub(self.altsep, self.sep)) end ----@param part string path +---@param part string path with only `\` separators ---@return string drv ---@return string root ---@return string relpath function _WindowsPath:split_root(part) -- https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats - local prefix = "" + local unc_prefix = "\\\\?\\UNC\\" local first, second = part:sub(1, 1), part:sub(2, 2) - if first == self.sep and second == self.sep then - prefix, part = self:_split_extended_path(part) - first, second = part:sub(1, 1), part:sub(2, 2) - end - - local third = part:sub(3, 3) - - if first == self.sep and second == self.sep and third ~= self.sep then - -- is a UNC path: - -- vvvvvvvvvvvvvvvvvvvvv root - -- \\machine\mountpoint\directory\etc\... - -- directory ^^^^^^^^^^^^^^ + if first == self.sep then + if second == self.sep then + -- UNC drives, e.g. \\server\share or \\?\UNC\server\share + -- Device drives, e.g. \\.\device or \\?\device + local start = part:sub(1, 8):upper() == unc_prefix and 8 or 2 + local index = part:find(self.sep, start) + if index == nil then + return part, "", "" -- paths only has drive info + end - local index = part:find(self.sep, 3) - if index ~= nil then local index2 = part:find(self.sep, index + 1) - if index2 ~= index + 1 then - if index2 == nil then - index2 = #part - end - - if prefix ~= "" then - return prefix + part:sub(2, index2 - 1), self.sep, part:sub(index2 + 1) - else - return part:sub(1, index2 - 1), self.sep, part:sub(index2 + 1) - end + if index2 == nil then + return part, "", "" -- still paths only has drive info end + return part:sub(1, index2 - 1), self.sep, part:sub(index2 + 1) + else + -- Relative path with root, eg. \Windows + return "", part:sub(1, 1), part:sub(2) end - end - - local drv, root = "", "" - if second == ":" and first:match "%a" then - drv, part = part:sub(1, 2), part:sub(3) - first = third - end - - if first == self.sep then - root = first - part = part:gsub("^" .. self.sep .. "+", "") - end - - return prefix .. drv, root, part -end - ----@param p string path ----@return string ----@return string -function _WindowsPath:_split_extended_path(p) - local ext_prefix = [[\\?\]] - local prefix = "" - - if p:sub(1, #ext_prefix) == ext_prefix then - prefix = p:sub(1, 4) - p = p:sub(5) - if p:sub(1, 3) == "UNC" .. self.sep then - prefix = prefix .. p:sub(1, 3) - p = self.sep .. p:sub(4) + elseif part:sub(2, 2) == ":" then + if part:sub(3, 3) == self.sep then + -- absolute path with drive, eg. C:\Windows + return part:sub(1, 2), self.sep, part:sub(3) + else + -- relative path with drive, eg. C:Windows + return part:sub(1, 2), "", part:sub(3) end + else + -- relative path, eg. Windows + return "", "", part end - - return prefix, p end ---@class plenary._PosixPath : plenary._Path @@ -206,21 +175,21 @@ path.root = (function() end)() ---@param parts string[] ----@param _path plenary._Path +---@param _flavor plenary._Path ---@return string drv ---@return string root ---@return string[] -local function parse_parts(parts, _path) +local function parse_parts(parts, _flavor) local drv, root, rel, parsed = "", "", "", {} for i = #parts, 1, -1 do local part = parts[i] - part = _path:convert_altsep(part) + part = _flavor:convert_altsep(part) - drv, root, rel = _path:split_root(part) + drv, root, rel = _flavor:split_root(part) - if rel:match(_path.sep) then - local relparts = vim.split(rel, _path.sep) + if rel:match(_flavor.sep) then + local relparts = vim.split(rel, _flavor.sep) for j = #relparts, 1, -1 do local p = relparts[j] if p ~= "" and p ~= "." then @@ -233,19 +202,18 @@ local function parse_parts(parts, _path) end end - if drv or root then + if drv ~= "" or root ~= "" then if not drv then - for k = #parts, 1, -1 do - local p = parts[k] - p = _path:convert_altsep(p) - drv = _path:split_root(p) - if drv then + for j = #parts, 1, -1 do + local p = parts[j] + p = _flavor:convert_altsep(p) + drv = _flavor:split_root(p) + if drv ~= "" then break end end - - break end + break end end @@ -259,22 +227,30 @@ end ---@class plenary.Path2 ---@field path plenary.path2 ----@field private _path plenary._Path +---@field private _flavor plenary._Path +---@field private _raw_parts string[] ---@field drv string drive name, eg. 'C:' (only for Windows) ---@field root string root path (excludes drive name) ----@field relparts string[] relative path parts excluding separators +---@field relparts string[] path separator separated relative path parts --- ---@field filename string ---@field cwd string ---@field private _absolute string? lazy eval'ed fully resolved absolute path local Path = { path = path } +---@param t plenary.Path2 +---@param k string Path.__index = function(t, k) local raw = rawget(Path, k) if raw then return raw end + if k == "drv" or k == "root" or k == "relparts" then + t.drv, t.root, t.relparts = parse_parts(t._raw_parts, t._flavor) + return rawget(t, k) + end + if k == "filename" then t.filename = t:_filename() return t.filename @@ -337,22 +313,20 @@ function Path:new(...) end end - local relparts = {} + local raw_parts = {} for _, a in ipairs(args) do if self.is_path(a) then - vim.list_extend(relparts, a.relparts) + vim.list_extend(raw_parts, a._raw_parts) else if a ~= "" then - table.insert(relparts, a) + table.insert(raw_parts, a) end end end - local _path = iswin and _WindowsPath or _PosixPath - local drv, root - drv, root, relparts = parse_parts(relparts, _path) + local _flavor = iswin and _WindowsPath or _PosixPath - local proxy = { _path = _path, drv = drv, root = root, relparts = relparts } + local proxy = { _flavor = _flavor, _raw_parts = raw_parts } setmetatable(proxy, Path) local obj = { __inner = proxy } @@ -385,9 +359,9 @@ end ---@return string function Path:_filename(drv, root, relparts) drv = vim.F.if_nil(drv, self.drv) - drv = self.drv ~= "" and self.drv:gsub(self._path.sep, path.sep) or "" + drv = self.drv ~= "" and self.drv:gsub(self._flavor.sep, path.sep) or "" - if self._path.has_drv and drv == "" then + if self._flavor.has_drv and drv == "" then root = "" else root = vim.F.if_nil(root, self.root) @@ -395,7 +369,7 @@ function Path:_filename(drv, root, relparts) end relparts = vim.F.if_nil(relparts, self.relparts) - local relpath = table.concat(relparts, path.sep) + local relpath = table.concat(relparts, path.sep) return drv .. root .. relpath end @@ -412,7 +386,7 @@ function Path:is_absolute() return false end - return self._path.has_drv and self.drv ~= "" + return self._flavor.has_drv and self.drv ~= "" end ---@return boolean @@ -535,8 +509,8 @@ function Path:make_relative(to) end -- vim.o.shellslash = false --- vim.print(p) --- print(p.filename, p:is_absolute(), p:absolute()) +local p = Path:new { "C:", "lua", "..", "README.md" } +print(p.filename) -- vim.o.shellslash = true return Path diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 0df36cf8..fe4e8145 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -49,6 +49,7 @@ describe("Path2", function() describe("filename", function() local function get_paths() local readme_path = vim.fn.fnamemodify("README.md", ":p") + local license_path = vim.fn.fnamemodify("LICENSE", ":p") ---@type [string[]|string, string][] local paths = { @@ -60,6 +61,7 @@ describe("Path2", function() { "./lua//..//README.md", "lua/../README.md" }, { "foo/bar/", "foo/bar" }, { { readme_path }, readme_path }, + { { readme_path, license_path }, license_path }, -- takes only the last abs path } return paths @@ -70,7 +72,7 @@ describe("Path2", function() local input, expect = tc[1], tc[2] it(vim.inspect(input), function() local p = Path:new(input) - assert.are.same(expect, p.filename, p.parts) + assert.are.same(expect, p.filename, p.relparts) end) end end @@ -107,6 +109,8 @@ describe("Path2", function() { [[foo/bar\baz]], [[foo/bar/baz]] }, { [[\\.\C:\Test\Foo.txt]], [[//./C:/Test/Foo.txt]] }, { [[\\?\C:\Test\Foo.txt]], [[//?/C:/Test/Foo.txt]] }, + { [[\\.\UNC\Server\Share\Test\Foo.txt]], [[//./UNC/Server/Share/Test/Foo.txt]] }, + { [[\\?\UNC\Server\Share\Test\Foo.txt]], [[//?/UNC/Server/Share/Test/Foo.txt]] }, { "/foo/bar/baz", "foo/bar/baz" }, } vim.list_extend(paths, get_paths()) From 8c903b72224b150131bcb33850164cd41c2ae306 Mon Sep 17 00:00:00 2001 From: James Trew Date: Sun, 25 Aug 2024 20:42:40 -0400 Subject: [PATCH 08/43] fix up unix stuff a bit --- lua/plenary/path2.lua | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index a42a94be..48149cd7 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -110,9 +110,8 @@ end ---@return string root ---@return string relpath function _PosixPath:split_root(part) - if part:sub(1) == self.sep then - part = (part:gsub("^" .. self.sep, "")) - return "", self.sep, part:sub(2, #part) + if part:sub(1, 1) == self.sep then + return "", self.sep, part:sub(2) end return "", "", part end @@ -386,7 +385,7 @@ function Path:is_absolute() return false end - return self._flavor.has_drv and self.drv ~= "" + return not self._flavor.has_drv or self.drv ~= "" end ---@return boolean @@ -509,8 +508,9 @@ function Path:make_relative(to) end -- vim.o.shellslash = false -local p = Path:new { "C:", "lua", "..", "README.md" } -print(p.filename) +local p = Path:new { "/mnt/c/Users/jtrew/neovim/plenary.nvim/README.md" } +vim.print(p.drv, p.root, p.relparts) +print(p.filename, p:is_absolute()) -- vim.o.shellslash = true return Path From ecde3424a0c3d3ffa51d1a97f77413c07470a3ad Mon Sep 17 00:00:00 2001 From: James Trew Date: Mon, 26 Aug 2024 00:04:41 -0400 Subject: [PATCH 09/43] more instantiation improvements --- lua/plenary/path2.lua | 250 ++++++++++++++++++++++++----------- tests/plenary/path2_spec.lua | 134 +++++++++---------- 2 files changed, 237 insertions(+), 147 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 48149cd7..a7c380c8 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -31,6 +31,7 @@ local hasshellslash = vim.fn.exists "+shellslash" == 1 ---@field case_sensitive boolean ---@field convert_altsep fun(self: plenary._Path, p:string): string ---@field split_root fun(self: plenary._Path, part:string): string, string, string +---@field join fun(self: plenary._Path, path: string, ...: string): string ---@class plenary._WindowsPath : plenary._Path local _WindowsPath = { @@ -48,54 +49,115 @@ function _WindowsPath:convert_altsep(p) return (p:gsub(self.altsep, self.sep)) end ----@param part string path with only `\` separators +--- splits path into drive, root, and relative path components +--- split_root('//server/share/') == { '//server/share', '/', '' } +--- split_root('C:/Users/Barney') == { 'C:', '/', 'Users/Barney' } +--- split_root('C:///spam///ham') == { 'C:', '/', '//spam///ham' } +--- split_root('Windows/notepad') == { '', '', 'Windows/notepad' } +--- https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats +---@param p string path with only `\` separators ---@return string drv ---@return string root ---@return string relpath -function _WindowsPath:split_root(part) - -- https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats +function _WindowsPath:split_root(p) + p = self:convert_altsep(p) + local unc_prefix = "\\\\?\\UNC\\" - local first, second = part:sub(1, 1), part:sub(2, 2) + local first, second = p:sub(1, 1), p:sub(2, 2) if first == self.sep then if second == self.sep then -- UNC drives, e.g. \\server\share or \\?\UNC\server\share -- Device drives, e.g. \\.\device or \\?\device - local start = part:sub(1, 8):upper() == unc_prefix and 8 or 2 - local index = part:find(self.sep, start) + local start = p:sub(1, 8):upper() == unc_prefix and 8 or 2 + local index = p:find(self.sep, start) if index == nil then - return part, "", "" -- paths only has drive info + return p, "", "" -- paths only has drive info end - local index2 = part:find(self.sep, index + 1) + local index2 = p:find(self.sep, index + 1) if index2 == nil then - return part, "", "" -- still paths only has drive info + return p, "", "" -- still paths only has drive info end - return part:sub(1, index2 - 1), self.sep, part:sub(index2 + 1) + return p:sub(1, index2 - 1), self.sep, p:sub(index2 + 1) else -- Relative path with root, eg. \Windows - return "", part:sub(1, 1), part:sub(2) + return "", p:sub(1, 1), p:sub(2) end - elseif part:sub(2, 2) == ":" then - if part:sub(3, 3) == self.sep then + elseif p:sub(2, 2) == ":" then + if p:sub(3, 3) == self.sep then -- absolute path with drive, eg. C:\Windows - return part:sub(1, 2), self.sep, part:sub(3) + return p:sub(1, 2), self.sep, p:sub(3) else -- relative path with drive, eg. C:Windows - return part:sub(1, 2), "", part:sub(3) + return p:sub(1, 2), "", p:sub(3) end else -- relative path, eg. Windows - return "", "", part + return "", "", p end end +---@param path string +---@param ... string +---@return string +function _WindowsPath:join(path, ...) + local paths = { ... } + + local result_drive, result_root, result_path = self:split_root(path) + local parts = {} + + if result_path ~= "" then + table.insert(parts, result_path) + end + + for _, p in ipairs(paths) do + p = self:convert_altsep(p) + local p_drive, p_root, p_path = self:split_root(p) + + if p_root ~= "" then + -- second path is absolute + if p_drive ~= "" or result_drive == "" then + result_drive = p_drive + end + result_root = p_root + parts = { p_path } + elseif p_drive ~= "" and p_drive:lower() ~= result_drive:lower() then + -- drive letter is case insensitive + -- here they don't match => ignore first path, later paths take precedence + result_drive, result_root, parts = p_drive, p_root, { p_path } + else + if p_drive ~= "" then + result_drive = p_drive + end + + if #parts > 0 and parts[#parts]:sub(-1) ~= self.sep then + table.insert(parts, self.sep) + end + + table.insert(parts, p_path) + end + end + + local drv_last_ch = result_drive:sub(-1) + if + result_path ~= "" + and result_root == "" + and result_drive ~= "" + and not (drv_last_ch == self.sep or drv_last_ch == ":") + then + return result_drive .. self.sep .. table.concat(parts) + end + + return result_drive .. result_root .. table.concat(parts) +end + ---@class plenary._PosixPath : plenary._Path local _PosixPath = { sep = "/", altsep = "", has_drv = false, - case_sensitive = true, + case_sensitive = false, } setmetatable(_PosixPath, { __index = _PosixPath }) @@ -116,6 +178,40 @@ function _PosixPath:split_root(part) return "", "", part end +---@param path string +---@param ... string +---@return string +function _PosixPath:join(path, ...) + local paths = { ... } + local parts = {} + + if path ~= "" then + table.insert(parts, path) + end + + for _, p in ipairs(paths) do + if p:sub(1, 1) == self.sep then + parts = { p } -- is absolute, ignore previous path, later paths take precedence + elseif path == "" or path:sub(-1) == self.sep then + table.insert(parts, p) + else + table.insert(parts, self.sep .. p) + end + end + return table.concat(parts) +end + +--[[ + + for b in map(os.fspath, p): + if b.startswith(sep): + path = b + elif not path or path.endswith(sep): + path += b + else: + path += sep + b +]] + local S_IF = { -- S_IFDIR = 0o040000 # directory DIR = 0x4000, @@ -181,44 +277,29 @@ end)() local function parse_parts(parts, _flavor) local drv, root, rel, parsed = "", "", "", {} - for i = #parts, 1, -1 do - local part = parts[i] - part = _flavor:convert_altsep(part) - - drv, root, rel = _flavor:split_root(part) - - if rel:match(_flavor.sep) then - local relparts = vim.split(rel, _flavor.sep) - for j = #relparts, 1, -1 do - local p = relparts[j] - if p ~= "" and p ~= "." then - table.insert(parsed, p) - end - end - else - if rel ~= "" and rel ~= "." then - table.insert(parsed, rel) - end - end + if #parts == 0 then + return drv, root, parsed + end - if drv ~= "" or root ~= "" then - if not drv then - for j = #parts, 1, -1 do - local p = parts[j] - p = _flavor:convert_altsep(p) - drv = _flavor:split_root(p) - if drv ~= "" then - break - end - end - end - break + local sep = _flavor.sep + local p = _flavor:join(unpack(parts)) + drv, root, rel = _flavor:split_root(p) + + if root == "" and drv:sub(1, 1) == sep and drv:sub(-1) ~= sep then + local drv_parts = vim.split(drv, sep) + if #drv_parts == 4 and not (drv_parts[3] == "?" or drv_parts[3] == ".") then + -- e.g. //server/share + root = sep + elseif #drv_parts == 6 then + -- e.g. //?/unc/server/share + root = sep end end - local n = #parsed - for i = 1, math.floor(n / 2) do - parsed[i], parsed[n - i + 1] = parsed[n - i + 1], parsed[i] + for part in vim.gsplit(rel, sep) do + if part ~= "" and part ~= "." then + table.insert(parsed, part) + end end return drv, root, parsed @@ -282,14 +363,37 @@ end ---@return boolean Path.__eq = function(self, other) assert(Path.is_path(self)) - assert(Path.is_path(other) or type(other) == "string") - -- TODO - -- if true then - -- error "not yet implemented" - -- end - return self.filename == other.filename + + local oth_type_str = type(other) == "string" + assert(Path.is_path(other) or oth_type_str) + + if oth_type_str then + other = Path:new(other) + end + ---@cast other plenary.Path2 + + return self:absolute() == other:absolute() end +local _readonly_mt = { + __index = function(t, k) + return t.__inner[k] + end, + __newindex = function(t, k, val) + if k == "_absolute" then + t.__inner[k] = val + return + end + error "'Path' object is read-only" + end, + -- stylua: ignore start + __div = function(t, other) return Path.__div(t, other) end, + __tostring = function(t) return Path.__tostring(t) end, + __eq = function(t, other) return Path.__eq(t, other) end, -- this never gets called + __metatable = Path, + -- stylua: ignore end +} + ---@alias plenary.Path2Args string|plenary.Path2|(string|plenary.Path2)[] ---@param ... plenary.Path2Args @@ -329,24 +433,7 @@ function Path:new(...) setmetatable(proxy, Path) local obj = { __inner = proxy } - setmetatable(obj, { - __index = function(_, k) - return proxy[k] - end, - __newindex = function(_, k, val) - if k == "_absolute" then - proxy[k] = val - return - end - error "'Path' object is read-only" - end, - -- stylua: ignore start - __div = function(t, other) return Path.__div(t, other) end, - __tostring = function(t) return Path.__tostring(t) end, - __eq = function(t, other) return Path.__eq(t, other) end, - __metatable = Path, - -- stylua: ignore end - }) + setmetatable(obj, _readonly_mt) return obj end @@ -468,6 +555,14 @@ function Path:joinpath(...) return Path:new { self, ... } end +---@return string[] # a list of the path's logical parents +function Path:parents() + local res = {} + local abs = self:absolute() + + return res +end + --- makes a path relative to another (by default the cwd). --- if path is already a relative path ---@param to string|plenary.Path2? absolute path to make relative to (default: cwd) @@ -507,10 +602,5 @@ function Path:make_relative(to) -- /home/jt end --- vim.o.shellslash = false -local p = Path:new { "/mnt/c/Users/jtrew/neovim/plenary.nvim/README.md" } -vim.print(p.drv, p.root, p.relparts) -print(p.filename, p:is_absolute()) --- vim.o.shellslash = true return Path diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index fe4e8145..a3638367 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -271,71 +271,71 @@ describe("Path2", function() end) end) - -- describe(":make_relative", function() - -- local root = iswin and "c:\\" or "/" - -- it_cross_plat("can take absolute paths and make them relative to the cwd", function() - -- local p = Path:new { "lua", "plenary", "path.lua" } - -- local absolute = vim.loop.cwd() .. path.sep .. p.filename - -- local relative = Path:new(absolute):make_relative() - -- assert.are.same(p.filename, relative) - -- end) - - -- it_cross_plat("can take absolute paths and make them relative to a given path", function() - -- local r = Path:new { root, "home", "prime" } - -- local p = Path:new { "aoeu", "agen.lua" } - -- local absolute = r.filename .. path.sep .. p.filename - -- local relative = Path:new(absolute):make_relative(r.filename) - -- assert.are.same(relative, p.filename) - -- end) - - -- it_cross_plat("can take double separator absolute paths and make them relative to the cwd", function() - -- local p = Path:new { "lua", "plenary", "path.lua" } - -- local absolute = vim.loop.cwd() .. path.sep .. path.sep .. p.filename - -- local relative = Path:new(absolute):make_relative() - -- assert.are.same(relative, p.filename) - -- end) - - -- it_cross_plat("can take double separator absolute paths and make them relative to a given path", function() - -- local r = Path:new { root, "home", "prime" } - -- local p = Path:new { "aoeu", "agen.lua" } - -- local absolute = r.filename .. path.sep .. path.sep .. p.filename - -- local relative = Path:new(absolute):make_relative(r.filename) - -- assert.are.same(relative, p.filename) - -- end) - - -- it_cross_plat("can take absolute paths and make them relative to a given path with trailing separator", function() - -- local r = Path:new { root, "home", "prime" } - -- local p = Path:new { "aoeu", "agen.lua" } - -- local absolute = r.filename .. path.sep .. p.filename - -- local relative = Path:new(absolute):make_relative(r.filename .. path.sep) - -- assert.are.same(relative, p.filename) - -- end) - - -- it_cross_plat("can take absolute paths and make them relative to the root directory", function() - -- local p = Path:new { "home", "prime", "aoeu", "agen.lua" } - -- local absolute = root .. p.filename - -- local relative = Path:new(absolute):make_relative(root) - -- assert.are.same(relative, p.filename) - -- end) - - -- it_cross_plat("can take absolute paths and make them relative to themselves", function() - -- local p = Path:new { root, "home", "prime", "aoeu", "agen.lua" } - -- local relative = Path:new(p.filename):make_relative(p.filename) - -- assert.are.same(relative, ".") - -- end) - - -- it_cross_plat("should not truncate if path separator is not present after cwd", function() - -- local cwd = "tmp" .. path.sep .. "foo" - -- local p = Path:new { "tmp", "foo_bar", "fileb.lua" } - -- local relative = Path:new(p.filename):make_relative(cwd) - -- assert.are.same(p.filename, relative) - -- end) - - -- it_cross_plat("should not truncate if path separator is not present after cwd and cwd ends in path sep", function() - -- local cwd = "tmp" .. path.sep .. "foo" .. path.sep - -- local p = Path:new { "tmp", "foo_bar", "fileb.lua" } - -- local relative = Path:new(p.filename):make_relative(cwd) - -- assert.are.same(p.filename, relative) - -- end) - -- end) + describe(":make_relative", function() + local root = iswin and "c:\\" or "/" + -- it_cross_plat("can take absolute paths and make them relative to the cwd", function() + -- local p = Path:new { "lua", "plenary", "path.lua" } + -- local absolute = vim.loop.cwd() .. path.sep .. p.filename + -- local relative = Path:new(absolute):make_relative() + -- assert.are.same(p.filename, relative) + -- end) + + -- it_cross_plat("can take absolute paths and make them relative to a given path", function() + -- local r = Path:new { root, "home", "prime" } + -- local p = Path:new { "aoeu", "agen.lua" } + -- local absolute = r.filename .. path.sep .. p.filename + -- local relative = Path:new(absolute):make_relative(r.filename) + -- assert.are.same(relative, p.filename) + -- end) + + -- it_cross_plat("can take double separator absolute paths and make them relative to the cwd", function() + -- local p = Path:new { "lua", "plenary", "path.lua" } + -- local absolute = vim.loop.cwd() .. path.sep .. path.sep .. p.filename + -- local relative = Path:new(absolute):make_relative() + -- assert.are.same(relative, p.filename) + -- end) + + -- it_cross_plat("can take double separator absolute paths and make them relative to a given path", function() + -- local r = Path:new { root, "home", "prime" } + -- local p = Path:new { "aoeu", "agen.lua" } + -- local absolute = r.filename .. path.sep .. path.sep .. p.filename + -- local relative = Path:new(absolute):make_relative(r.filename) + -- assert.are.same(relative, p.filename) + -- end) + + -- it_cross_plat("can take absolute paths and make them relative to a given path with trailing separator", function() + -- local r = Path:new { root, "home", "prime" } + -- local p = Path:new { "aoeu", "agen.lua" } + -- local absolute = r.filename .. path.sep .. p.filename + -- local relative = Path:new(absolute):make_relative(r.filename .. path.sep) + -- assert.are.same(relative, p.filename) + -- end) + + -- it_cross_plat("can take absolute paths and make them relative to the root directory", function() + -- local p = Path:new { "home", "prime", "aoeu", "agen.lua" } + -- local absolute = root .. p.filename + -- local relative = Path:new(absolute):make_relative(root) + -- assert.are.same(relative, p.filename) + -- end) + + -- it_cross_plat("can take absolute paths and make them relative to themselves", function() + -- local p = Path:new { root, "home", "prime", "aoeu", "agen.lua" } + -- local relative = Path:new(p.filename):make_relative(p.filename) + -- assert.are.same(relative, ".") + -- end) + + -- it_cross_plat("should not truncate if path separator is not present after cwd", function() + -- local cwd = "tmp" .. path.sep .. "foo" + -- local p = Path:new { "tmp", "foo_bar", "fileb.lua" } + -- local relative = Path:new(p.filename):make_relative(cwd) + -- assert.are.same(p.filename, relative) + -- end) + + -- it_cross_plat("should not truncate if path separator is not present after cwd and cwd ends in path sep", function() + -- local cwd = "tmp" .. path.sep .. "foo" .. path.sep + -- local p = Path:new { "tmp", "foo_bar", "fileb.lua" } + -- local relative = Path:new(p.filename):make_relative(cwd) + -- assert.are.same(p.filename, relative) + -- end) + end) end) From 9992fd8a194eb9cc64973e29b1b8045a0e2bb258 Mon Sep 17 00:00:00 2001 From: James Trew Date: Mon, 26 Aug 2024 23:53:27 -0400 Subject: [PATCH 10/43] pass most of `make_relative` --- lua/plenary/path2.lua | 139 ++++++++++++++++++++++++++--------- tests/plenary/path2_spec.lua | 127 ++++++++++++++++++-------------- 2 files changed, 175 insertions(+), 91 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index a7c380c8..1cf53f3a 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -263,8 +263,8 @@ path.root = (function() else return function(base) base = base or path.home - local _, root, _ = _WindowsPath:split_root(base) - return root + local drv, root, _ = _WindowsPath:split_root(base) + return ((drv .. root):gsub("\\", path.sep)) end end end)() @@ -309,10 +309,10 @@ end ---@field path plenary.path2 ---@field private _flavor plenary._Path ---@field private _raw_parts string[] ----@field drv string drive name, eg. 'C:' (only for Windows) ----@field root string root path (excludes drive name) +---@field drv string drive name, eg. 'C:' (only for Windows, empty string for Posix) +---@field root string root path (excludes drive name for Windows) ---@field relparts string[] path separator separated relative path parts ---- +---@field sep string path separator (respects 'shellslash' on Windows) ---@field filename string ---@field cwd string ---@field private _absolute string? lazy eval'ed fully resolved absolute path @@ -336,6 +336,10 @@ Path.__index = function(t, k) return t.filename end + if k == "sep" then + return path.sep + end + if k == "cwd" then t.cwd = vim.fn.getcwd() return t.cwd @@ -445,17 +449,17 @@ end ---@return string function Path:_filename(drv, root, relparts) drv = vim.F.if_nil(drv, self.drv) - drv = self.drv ~= "" and self.drv:gsub(self._flavor.sep, path.sep) or "" + drv = self.drv ~= "" and self.drv:gsub(self._flavor.sep, self.sep) or "" if self._flavor.has_drv and drv == "" then root = "" else root = vim.F.if_nil(root, self.root) - root = self.root ~= "" and path.sep:rep(#self.root) or "" + root = self.root ~= "" and self.sep:rep(#self.root) or "" end relparts = vim.F.if_nil(relparts, self.relparts) - local relpath = table.concat(relparts, path.sep) + local relpath = table.concat(relparts, self.sep) return drv .. root .. relpath end @@ -538,10 +542,11 @@ function Path:absolute() else -- using fs_realpath over fnamemodify -- fs_realpath resolves symlinks whereas fnamemodify doesn't but we're - -- resolving/normalizing the path anyways for reasons of compat with old Path + -- resolving/normalizing the path anyways for reasons of compat with old + -- Path local p = uv.fs_realpath(self:_filename()) or Path:new({ self.cwd, self }):absolute() if self.path.isshellslash then - self._absolute = p:gsub("\\", path.sep) + self._absolute = p:gsub("\\", self.sep) else self._absolute = p end @@ -555,52 +560,118 @@ function Path:joinpath(...) return Path:new { self, ... } end +---@return plenary.Path2 +function Path:parent() + local parent = self:iter_parents()() + if parent == nil then + return Path:new(self.filename) + end + return Path:new(parent) +end + +--- a list of the path's logical parents. +--- path is made absolute using cwd if relative ---@return string[] # a list of the path's logical parents function Path:parents() local res = {} - local abs = self:absolute() - + for p in self:iter_parents() do + table.insert(res, p) + end return res end ---- makes a path relative to another (by default the cwd). ---- if path is already a relative path ----@param to string|plenary.Path2? absolute path to make relative to (default: cwd) ----@return string -function Path:make_relative(to) - to = vim.F.if_nil(to, self.cwd) - if type(to) == "string" then +---@return fun(): string? # iterator function +function Path:iter_parents() + local abs = Path:new(self:absolute()) + local root_part = abs.drv .. abs.root + root_part = self.path.isshellslash and root_part:gsub("\\", self.sep) or root_part + + local root_sent = #abs.relparts == 0 + return function() + table.remove(abs.relparts) + if #abs.relparts < 1 then + if not root_sent then + root_sent = true + return root_part + end + return nil + end + return root_part .. table.concat(abs.relparts, self.sep) + end +end + +--- return true if the path is relative to another, otherwise false +---@param to plenary.Path2|string path to compare to +---@return boolean +function Path:is_relative(to) + if not Path.is_path(to) then to = Path:new(to) end + ---@cast to plenary.Path2 - if self:is_absolute() then - local to_abs = to:absolute() + local to_abs = to:absolute() + return self:absolute():sub(1, #to_abs) == to_abs +end - if to_abs == self:absolute() then +--- makes a path relative to another (by default the cwd). +--- if path is already a relative path, it will first be turned absolute using +--- the cwd then made relative to the `to` path. +---@param to string|plenary.Path2? absolute path to make relative to (default: cwd) +---@param walk_up boolean? walk up to the provided path using '..' (default: `false`) +---@return string +function Path:make_relative(to, walk_up) + walk_up = vim.F.if_nil(walk_up, false) + + if to == nil then + if not self:is_absolute() then return "." - else - -- TODO end - else + + to = Path:new(self.cwd) + elseif type(to) == "string" then + to = Path:new(to) ---@cast to plenary.Path2 end - -- SEE: `Path.relative_to` implementation (3.12) specifically `walk_up` param + local abs = self:absolute() + if abs == to:absolute() then + return "." + end + + if self:is_relative(to) then + return Path:new(abs:sub(#to:absolute() + 1)).filename + end - local matches = true - for i = 1, #to.relparts do - if to.relparts[i] ~= self.relparts[i] then - matches = false + if not walk_up then + error(string.format("'%s' is not in the subpath of '%s'", self, to)) + end + + local steps = {} + local common_path + for parent in to:iter_parents() do + table.insert(steps, "..") + print(parent, abs) + if abs:sub(1, #parent) == parent then + common_path = parent break end end - if matches then - return "." + if not common_path then + error(string.format("'%s' and '%s' have different anchors", self, to)) end - -- /home/jt/foo/bar/baz - -- /home/jt + local res_path = abs:sub(#common_path + 1) + return table.concat(steps, self.sep) .. res_path end +-- vim.o.shellslash = false + +local root = "C:/" +local p = Path:new(Path.path.root(vim.fn.getcwd())) +vim.print("p parent", p.filename, p:parents(), p:parent().filename) +-- local absolute = p:absolute() +-- local relative = Path:new(absolute):make_relative(Path:new "C:/Windows", true) +-- print(p.filename, absolute, relative) +vim.o.shellslash = true return Path diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index a3638367..0c37d2c9 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -1,6 +1,6 @@ local Path = require "plenary.path2" local path = Path.path --- local compat = require "plenary.compat" +local compat = require "plenary.compat" local iswin = vim.loop.os_uname().sysname == "Windows_NT" local hasshellslash = vim.fn.exists "+shellslash" == 1 @@ -12,25 +12,21 @@ local function set_shellslash(bool) end end -local function it_ssl(name, test_fn) - if not hasshellslash then - it(name, test_fn) - else - local orig = vim.o.shellslash - vim.o.shellslash = true - it(name .. " - shellslash", test_fn) - - vim.o.shellslash = false - it(name .. " - noshellslash", test_fn) - vim.o.shellslash = orig - end -end - local function it_cross_plat(name, test_fn) if not iswin then it(name .. " - unix", test_fn) else - it_ssl(name .. " - windows", test_fn) + if not hasshellslash then + it(name .. " - windows", test_fn) + else + local orig = vim.o.shellslash + vim.o.shellslash = true + it(name .. " - windows (shellslash)", test_fn) + + vim.o.shellslash = false + it(name .. " - windows (noshellslash)", test_fn) + vim.o.shellslash = orig + end end end @@ -179,7 +175,7 @@ describe("Path2", function() local function get_windows_paths() local nossl = hasshellslash and not vim.o.shellslash - local drive = Path:new(vim.loop.cwd()).drv + local drive = Path:new(vim.fn.getcwd()).drv local readme_path = vim.fn.fnamemodify("README.md", ":p") ---@type [string[]|string, string, boolean][] @@ -273,56 +269,56 @@ describe("Path2", function() describe(":make_relative", function() local root = iswin and "c:\\" or "/" - -- it_cross_plat("can take absolute paths and make them relative to the cwd", function() - -- local p = Path:new { "lua", "plenary", "path.lua" } - -- local absolute = vim.loop.cwd() .. path.sep .. p.filename - -- local relative = Path:new(absolute):make_relative() - -- assert.are.same(p.filename, relative) - -- end) + it_cross_plat("can take absolute paths and make them relative to the cwd", function() + local p = Path:new { "lua", "plenary", "path.lua" } + local absolute = vim.fn.getcwd() .. path.sep .. p.filename + local relative = Path:new(absolute):make_relative() + assert.are.same(p.filename, relative) + end) - -- it_cross_plat("can take absolute paths and make them relative to a given path", function() - -- local r = Path:new { root, "home", "prime" } - -- local p = Path:new { "aoeu", "agen.lua" } - -- local absolute = r.filename .. path.sep .. p.filename - -- local relative = Path:new(absolute):make_relative(r.filename) - -- assert.are.same(relative, p.filename) - -- end) + it_cross_plat("can take absolute paths and make them relative to a given path", function() + local r = Path:new { root, "home", "prime" } + local p = Path:new { "aoeu", "agen.lua" } + local absolute = r.filename .. path.sep .. p.filename + local relative = Path:new(absolute):make_relative(r.filename) + assert.are.same(p.filename, relative) + end) - -- it_cross_plat("can take double separator absolute paths and make them relative to the cwd", function() - -- local p = Path:new { "lua", "plenary", "path.lua" } - -- local absolute = vim.loop.cwd() .. path.sep .. path.sep .. p.filename - -- local relative = Path:new(absolute):make_relative() - -- assert.are.same(relative, p.filename) - -- end) + it_cross_plat("can take double separator absolute paths and make them relative to the cwd", function() + local p = Path:new { "lua", "plenary", "path.lua" } + local absolute = vim.fn.getcwd() .. path.sep .. path.sep .. p.filename + local relative = Path:new(absolute):make_relative() + assert.are.same(p.filename, relative) + end) - -- it_cross_plat("can take double separator absolute paths and make them relative to a given path", function() - -- local r = Path:new { root, "home", "prime" } - -- local p = Path:new { "aoeu", "agen.lua" } - -- local absolute = r.filename .. path.sep .. path.sep .. p.filename - -- local relative = Path:new(absolute):make_relative(r.filename) - -- assert.are.same(relative, p.filename) - -- end) + it_cross_plat("can take double separator absolute paths and make them relative to a given path", function() + local r = Path:new { root, "home", "prime" } + local p = Path:new { "aoeu", "agen.lua" } + local absolute = r.filename .. path.sep .. path.sep .. p.filename + local relative = Path:new(absolute):make_relative(r.filename) + assert.are.same(p.filename, relative) + end) - -- it_cross_plat("can take absolute paths and make them relative to a given path with trailing separator", function() - -- local r = Path:new { root, "home", "prime" } - -- local p = Path:new { "aoeu", "agen.lua" } - -- local absolute = r.filename .. path.sep .. p.filename - -- local relative = Path:new(absolute):make_relative(r.filename .. path.sep) - -- assert.are.same(relative, p.filename) - -- end) + it_cross_plat("can take absolute paths and make them relative to a given path with trailing separator", function() + local r = Path:new { root, "home", "prime" } + local p = Path:new { "aoeu", "agen.lua" } + local absolute = r.filename .. path.sep .. p.filename + local relative = Path:new(absolute):make_relative(r.filename .. path.sep) + assert.are.same(p.filename, relative) + end) -- it_cross_plat("can take absolute paths and make them relative to the root directory", function() - -- local p = Path:new { "home", "prime", "aoeu", "agen.lua" } + -- local p = Path:new { root, "prime", "aoeu", "agen.lua" } -- local absolute = root .. p.filename -- local relative = Path:new(absolute):make_relative(root) - -- assert.are.same(relative, p.filename) + -- assert.are.same(p.filename, relative) -- end) - -- it_cross_plat("can take absolute paths and make them relative to themselves", function() - -- local p = Path:new { root, "home", "prime", "aoeu", "agen.lua" } - -- local relative = Path:new(p.filename):make_relative(p.filename) - -- assert.are.same(relative, ".") - -- end) + it_cross_plat("can take absolute paths and make them relative to themselves", function() + local p = Path:new { root, "home", "prime", "aoeu", "agen.lua" } + local relative = Path:new(p.filename):make_relative(p.filename) + assert.are.same(".", relative) + end) -- it_cross_plat("should not truncate if path separator is not present after cwd", function() -- local cwd = "tmp" .. path.sep .. "foo" @@ -338,4 +334,21 @@ describe("Path2", function() -- assert.are.same(p.filename, relative) -- end) end) + + describe("parents", function() + it_cross_plat("should extract the ancestors of the path", function() + local p = Path:new(vim.fn.getcwd()) + local parents = p:parents() + assert(compat.islist(parents)) + for _, parent in pairs(parents) do + assert.are.same(type(parent), "string") + end + end) + + it_cross_plat("should return itself if it corresponds to path.root", function() + local p = Path:new(Path.path.root(vim.fn.getcwd())) + assert.are.same(p:absolute(), p:parent():absolute()) + -- assert.are.same(p, p:parent()) + end) + end) end) From a21b58bf34d74b3f0c94d492e5e0b9d72f2309bf Mon Sep 17 00:00:00 2001 From: James Trew Date: Tue, 27 Aug 2024 00:31:04 -0400 Subject: [PATCH 11/43] finish up `make_relative` --- lua/plenary/path2.lua | 50 ++++++++++++++++++++++++------------ tests/plenary/path2_spec.lua | 37 +++++++++++++------------- 2 files changed, 52 insertions(+), 35 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 1cf53f3a..51cdd406 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -17,8 +17,18 @@ --- things like `path.filename = 'foo'` but now explicitly adding some barrier --- to this. Allows us to compute `filename` from "metadata" parsed once on --- instantiation. - ---- TODO: rework `_filename` according to `_format_parsed_parts` +--- +--- - FIX: `Path:make_relative` throws error if you try to make a path relative +--- to another path that is not in the same subpath. +--- +--- eg. `Path:new("foo/bar_baz"):make_relative("foo/bar")` => errors as you +--- can't get to "foo/bar_baz" from "foo/bar" without going up in directory. +--- This would previously return "foo/bar_baz" which is wrong. +--- +--- Adds an option to walk up path to compensate. +--- +--- eg. `Path:new("foo/bar_baz"):make_relative("foo/bar", true)` => returns +--- "../bar_baz" local uv = vim.loop local iswin = uv.os_uname().sysname == "Windows_NT" @@ -609,8 +619,21 @@ function Path:is_relative(to) end ---@cast to plenary.Path2 + if to == self then + return true + end + + -- NOTE: could probably be optimized by letting _WindowsPath/_WindowsPath + -- handle this. + local to_abs = to:absolute() - return self:absolute():sub(1, #to_abs) == to_abs + for parent in self:iter_parents() do + if to_abs == parent then + return true + end + end + + return false end --- makes a path relative to another (by default the cwd). @@ -620,6 +643,11 @@ end ---@param walk_up boolean? walk up to the provided path using '..' (default: `false`) ---@return string function Path:make_relative(to, walk_up) + -- NOTE: could probably take some shortcuts and avoid some `Path:new` calls + -- by allowing _WindowsPath/_PosixPath handle this individually. + -- As always, Windows root complicates things, so generating a new Path often + -- easier/less error prone than manual string manipulate but at the cost of + -- perf. walk_up = vim.F.if_nil(walk_up, false) if to == nil then @@ -649,7 +677,6 @@ function Path:make_relative(to, walk_up) local common_path for parent in to:iter_parents() do table.insert(steps, "..") - print(parent, abs) if abs:sub(1, #parent) == parent then common_path = parent break @@ -660,18 +687,9 @@ function Path:make_relative(to, walk_up) error(string.format("'%s' and '%s' have different anchors", self, to)) end - local res_path = abs:sub(#common_path + 1) - return table.concat(steps, self.sep) .. res_path + local res_path = abs:sub(#common_path + 1):gsub("^" .. self.sep, "") + table.insert(steps, res_path) + return Path:new(steps).filename end --- vim.o.shellslash = false - -local root = "C:/" -local p = Path:new(Path.path.root(vim.fn.getcwd())) -vim.print("p parent", p.filename, p:parents(), p:parent().filename) --- local absolute = p:absolute() --- local relative = Path:new(absolute):make_relative(Path:new "C:/Windows", true) --- print(p.filename, absolute, relative) -vim.o.shellslash = true - return Path diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 0c37d2c9..ca5035fe 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -307,12 +307,12 @@ describe("Path2", function() assert.are.same(p.filename, relative) end) - -- it_cross_plat("can take absolute paths and make them relative to the root directory", function() - -- local p = Path:new { root, "prime", "aoeu", "agen.lua" } - -- local absolute = root .. p.filename - -- local relative = Path:new(absolute):make_relative(root) - -- assert.are.same(p.filename, relative) - -- end) + it_cross_plat("can take absolute paths and make them relative to the root directory", function() + local p = Path:new { root, "prime", "aoeu", "agen.lua" } + local absolute = root .. p.filename + local relative = Path:new(absolute):make_relative(root) + assert.are.same(p.filename, relative) + end) it_cross_plat("can take absolute paths and make them relative to themselves", function() local p = Path:new { root, "home", "prime", "aoeu", "agen.lua" } @@ -320,19 +320,18 @@ describe("Path2", function() assert.are.same(".", relative) end) - -- it_cross_plat("should not truncate if path separator is not present after cwd", function() - -- local cwd = "tmp" .. path.sep .. "foo" - -- local p = Path:new { "tmp", "foo_bar", "fileb.lua" } - -- local relative = Path:new(p.filename):make_relative(cwd) - -- assert.are.same(p.filename, relative) - -- end) - - -- it_cross_plat("should not truncate if path separator is not present after cwd and cwd ends in path sep", function() - -- local cwd = "tmp" .. path.sep .. "foo" .. path.sep - -- local p = Path:new { "tmp", "foo_bar", "fileb.lua" } - -- local relative = Path:new(p.filename):make_relative(cwd) - -- assert.are.same(p.filename, relative) - -- end) + it_cross_plat("should fail to make relative a path to somewhere not in the subpath", function() + assert.has_error(function() + _ = Path:new({ "tmp", "foo_bar", "fileb.lua" }):make_relative(Path:new { "tmp", "foo" }) + end) + end) + + it_cross_plat("can walk upwards out of current subpath", function() + local p = Path:new { "foo", "bar", "baz" } + local cwd = Path:new { "foo", "foo_inner" } + local expect = Path:new { "..", "bar", "baz" } + assert.are.same(expect.filename, p:make_relative(cwd, true)) + end) end) describe("parents", function() From 53587fea9216c298d8d3a69b286905ea9ae7cf7e Mon Sep 17 00:00:00 2001 From: James Trew Date: Tue, 27 Aug 2024 21:18:07 -0400 Subject: [PATCH 12/43] fix home directory --- lua/plenary/path2.lua | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 51cdd406..cd9d29de 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -230,7 +230,7 @@ local S_IF = { } ---@class plenary.path2 ----@field home string home directory path +---@field home string? home directory path ---@field sep string OS path separator respecting 'shellslash' ---@field isshellslash boolean whether shellslash is on (always false on unix systems) --- @@ -241,7 +241,6 @@ local S_IF = { ---@field root fun(base: string?):string ---@field S_IF { DIR: integer, REG: integer } stat filetype bitmask local path = setmetatable({ - home = vim.fn.getcwd(), -- respects shellslash unlike vim.uv.cwd() S_IF = S_IF, }, { __index = function(t, k) @@ -262,6 +261,19 @@ local path = setmetatable({ return t.isshellslash and "/" or "\\" end + + if k == "home" then + if not iswin then + t.home = uv.os_homedir() + return t.home + end + + local home = uv.os_homedir() + if home == nil then + return home + end + return (home:gsub("\\", t.sep)) + end end, }) From 7d2186a0e5f68085b773f90366eba4f646648f2c Mon Sep 17 00:00:00 2001 From: James Trew Date: Tue, 27 Aug 2024 21:18:35 -0400 Subject: [PATCH 13/43] add `shorten` --- lua/plenary/path2.lua | 67 +++++++++++++++++++++++++++++++++--- tests/plenary/path2_spec.lua | 60 ++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 4 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index cd9d29de..df9a9afa 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -3,9 +3,6 @@ --- including 'shellslash' support. --- Effort to improve performance made (notably `:absolue` ~2x faster). --- ---- Some finiky behaviors ironed out ---- eg. `:normalize` ---- TODO: demonstrate --- --- BREAKING CHANGES: --- - `Path.new` no longer supported (think it's more confusing that helpful @@ -29,6 +26,28 @@ --- --- eg. `Path:new("foo/bar_baz"):make_relative("foo/bar", true)` => returns --- "../bar_baz" +--- +--- - remove `Path:normalize`. It doesn't make any sense. eg. this test case +--- ```lua +--- it("can normalize ~ when file is within home directory (trailing slash)", function() +--- local home = "/home/test/" +--- local p = Path:new { home, "./test_file" } +--- p.path.home = home +--- p._cwd = "/tmp/lua" +--- assert.are.same("~/test_file", p:normalize()) +--- end) +--- ``` +--- if the idea is to make `/home/test/test_file` relative to `/tmp/lua`, the result +--- should be `../../home/test/test_file`, only then can you substitue the +--- home directory for `~`. +--- So should really be `../../~/test_file`. But using `~` in a relative path +--- like that looks weird to me. And as this function first makes paths +--- relative, you will never get a leading `~` (since `~` literally +--- represents the absolute path of the home directory). +--- To top it off, something like `../../~/test_file` is impossible on Windows. +--- `C:/Users/test/test_file` relative to `C:/Windows/temp` is +--- `../../Users/test/test_file` and there's no home directory absolute path +--- in this. local uv = vim.loop local iswin = uv.os_uname().sysname == "Windows_NT" @@ -482,8 +501,12 @@ function Path:_filename(drv, root, relparts) relparts = vim.F.if_nil(relparts, self.relparts) local relpath = table.concat(relparts, self.sep) + local res = drv .. root .. relpath - return drv .. root .. relpath + if res ~= "" then + return res + end + return "." end ---@param x any @@ -704,4 +727,40 @@ function Path:make_relative(to, walk_up) return Path:new(steps).filename end + +--- Shorten path parts. +--- By default, shortens all part except the last tail part to a length of 1. +--- eg. +--- ```lua +--- local p = Path:new("this/is/a/long/path") +--- p:shorten() -- Output: "t/i/a/l/path" +--- ``` +---@param len integer? length to shorthen path parts to (default: `1`) +--- indices of path parts to exclude from being shortened, supports negative index +---@param excludes integer[]? +---@return string +function Path:shorten(len, excludes) + len = vim.F.if_nil(len, 1) + excludes = vim.F.if_nil(excludes, { #self.relparts }) + + local new_parts = {} + + for i, part in ipairs(self.relparts) do + local neg_i = -(#self.relparts + 1) + i + if #part > len and not vim.list_contains(excludes, i) and not vim.list_contains(excludes, neg_i) then + part = part:sub(1, len) + end + table.insert(new_parts, part) + end + + return self:_filename(nil, nil, new_parts) +end + +-- vim.o.shellslash = false +local long_path = "/this/is/a/long/path" +local p = Path:new(long_path) +local short_path = p:shorten() +print(p.filename, short_path) +vim.o.shellslash = true + return Path diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index ca5035fe..69da1501 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -58,6 +58,7 @@ describe("Path2", function() { "foo/bar/", "foo/bar" }, { { readme_path }, readme_path }, { { readme_path, license_path }, license_path }, -- takes only the last abs path + { ".", "." }, } return paths @@ -334,6 +335,65 @@ describe("Path2", function() end) end) + describe(":shorten", function() + it_cross_plat("can shorten a path", function() + local long_path = "this/is/a/long/path" + local short_path = Path:new(long_path):shorten() + assert.are.same(short_path, plat_path "t/i/a/l/path") + end) + + it_cross_plat("can shorten a path's components to a given length", function() + local long_path = "this/is/a/long/path" + local short_path = Path:new(long_path):shorten(2) + assert.are.same(short_path, plat_path "th/is/a/lo/path") + + -- without the leading / + long_path = "this/is/a/long/path" + short_path = Path:new(long_path):shorten(3) + assert.are.same(short_path, plat_path "thi/is/a/lon/path") + + -- where len is greater than the length of the final component + long_path = "this/is/an/extremely/long/path" + short_path = Path:new(long_path):shorten(5) + assert.are.same(short_path, plat_path "this/is/an/extre/long/path") + end) + + it_cross_plat("can shorten a path's components when excluding parts", function() + local long_path = "this/is/a/long/path" + local short_path = Path:new(long_path):shorten(nil, { 1, -1 }) + assert.are.same(short_path, plat_path "this/i/a/l/path") + + -- without the leading / + long_path = "this/is/a/long/path" + short_path = Path:new(long_path):shorten(nil, { 1, -1 }) + assert.are.same(short_path, plat_path "this/i/a/l/path") + + -- where excluding positions greater than the number of parts + long_path = "this/is/an/extremely/long/path" + short_path = Path:new(long_path):shorten(nil, { 2, 4, 6, 8 }) + assert.are.same(short_path, plat_path "t/is/a/extremely/l/path") + + -- where excluding positions less than the negation of the number of parts + long_path = "this/is/an/extremely/long/path" + short_path = Path:new(long_path):shorten(nil, { -2, -4, -6, -8 }) + assert.are.same(short_path, plat_path "this/i/an/e/long/p") + end) + + it_cross_plat("can shorten a path's components to a given length and exclude positions", function() + local long_path = "this/is/a/long/path" + local short_path = Path:new(long_path):shorten(2, { 1, -1 }) + assert.are.same(short_path, plat_path "this/is/a/lo/path") + + long_path = "this/is/a/long/path" + short_path = Path:new(long_path):shorten(3, { 2, -2 }) + assert.are.same(short_path, plat_path "thi/is/a/long/pat") + + long_path = "this/is/an/extremely/long/path" + short_path = Path:new(long_path):shorten(5, { 3, -3 }) + assert.are.same(short_path, plat_path "this/is/an/extremely/long/path") + end) + end) + describe("parents", function() it_cross_plat("should extract the ancestors of the path", function() local p = Path:new(vim.fn.getcwd()) From 030ee626eb67b2c7502b09d2fda64390efcf0af2 Mon Sep 17 00:00:00 2001 From: James Trew Date: Tue, 27 Aug 2024 22:09:33 -0400 Subject: [PATCH 14/43] add mkdir & rmdir --- lua/plenary/path2.lua | 121 +++++++++++++++++++++++++++++++++-- tests/plenary/path2_spec.lua | 64 ++++++++++++++++++ 2 files changed, 178 insertions(+), 7 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index df9a9afa..ced0246d 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -49,6 +49,7 @@ --- `../../Users/test/test_file` and there's no home directory absolute path --- in this. +local bit = require "plenary.bit" local uv = vim.loop local iswin = uv.os_uname().sysname == "Windows_NT" local hasshellslash = vim.fn.exists "+shellslash" == 1 @@ -524,6 +525,40 @@ function Path:is_absolute() return not self._flavor.has_drv or self.drv ~= "" end +--- Get file status. +--- Will throw error if path doesn't exist. +---@return uv.aliases.fs_stat_table +function Path:stat() + local res, _, err_msg = uv.fs_stat(self:absolute()) + if res == nil then + error(err_msg) + end + return res +end + +--- Get file status. Like `Path:stat` but if the path points to a symbolic +--- link, returns the symbolic link's information. +--- Will throw error if path doesn't exist. +---@return uv.aliases.fs_stat_table +function Path:lstat() + local res, _, err_msg = uv.fs_lstat(self:absolute()) + if res == nil then + error(err_msg) + end + return res +end + +---@return integer +function Path:permission() + local stat = self:stat() + local perm = bit.band(stat.mode, 0x1FF) + local owner = bit.rshift(perm, 6) + local group = bit.rshift(perm, 3) % 8 + local user = perm % 8 + + return owner * 100 + group * 10 + user +end + ---@return boolean function Path:exists() local stat = uv.fs_stat(self:absolute()) @@ -727,7 +762,6 @@ function Path:make_relative(to, walk_up) return Path:new(steps).filename end - --- Shorten path parts. --- By default, shortens all part except the last tail part to a length of 1. --- eg. @@ -756,11 +790,84 @@ function Path:shorten(len, excludes) return self:_filename(nil, nil, new_parts) end --- vim.o.shellslash = false -local long_path = "/this/is/a/long/path" -local p = Path:new(long_path) -local short_path = p:shorten() -print(p.filename, short_path) -vim.o.shellslash = true +---@class plenary.Path2.mkdirOpts +---@field mode integer? permission to give to the directory, no umask effect will be applied (default: `o777`) +---@field parents boolean? creates parent directories if true and necessary (default: `false`) +---@field exists_ok boolean? ignores error if true and target directory exists (default: `false`) + +--- Create directory +---@param opts plenary.Path2.mkdirOpts? +---@return boolean success +function Path:mkdir(opts) + opts = opts or {} + opts.mode = vim.F.if_nil(opts.mode, 511) + opts.parents = vim.F.if_nil(opts.parents, false) + opts.exists_ok = vim.F.if_nil(opts.exists_ok, false) + + local abs_path = self:absolute() + + if not opts.exists_ok and self:exists() then + error(string.format("FileExistsError: %s", abs_path)) + end + + local ok, err_msg, err_code = uv.fs_mkdir(abs_path, opts.mode) + if ok then + return true + end + if err_code == "EEXIST" then + return true + end + if err_code == "ENOENT" then + if not opts.parents or self.parent == self then + error(err_msg) + end + self:parent():mkdir { mode = opts.mode } + uv.fs_mkdir(abs_path, opts.mode) + return true + end + + error(err_msg) +end + +--- Delete directory +function Path:rmdir() + if not self:exists() then + return + end + + local ok, err_msg = uv.fs_rmdir(self:absolute()) + if not ok then + error(err_msg) + end +end + +---@class plenary.Path2.touchOpts +---@field mode integer? permissions to give to the file if created (default: `o666`) +--- create parent directories if true and necessary. can optionally take a mode value +--- for the mkdir function (default: `false`) +---@field parents boolean|integer? + +--- 'touch' file. +--- If it doesn't exist, creates it including optionally, the parent directories +---@param opts plenary.Path2.touchOpts? +---@return boolean success +function Path:touch(opts) + opts = opts or {} + opts.mode = vim.F.if_nil(opts.mode, 438) + opts.parents = vim.F.if_nil(opts.parents, false) + + local abs_path = self:absolute() + + if self:exists() then + local new_time = os.time() + uv.fs_utime(abs_path, new_time, new_time) + return true + end + + if not not opts.parents then + local mode = type(opts.parents) == "number" and opts.parents ---@cast mode number? + _ = Path:new(self:parent()):mkdir { mode = mode, parents = true } + end +end return Path diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 69da1501..ba0fba6f 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -394,6 +394,70 @@ describe("Path2", function() end) end) + local function assert_permission(expect, actual) + if iswin then + return + end + assert.equal(expect, actual) + end + + describe("mkdir / rmdir", function() + it_cross_plat("can create and delete directories", function() + local p = Path:new "_dir_not_exist" + + p:rmdir() + assert.is_false(p:exists()) + + p:mkdir() + assert.is_true(p:exists()) + assert.is_true(p:is_dir()) + assert_permission(0777, p:permission()) + + p:rmdir() + assert.is_false(p:exists()) + end) + + it_cross_plat("fails when exists_ok is false", function() + local p = Path:new "lua" + assert.has_error(function() + p:mkdir { exists_ok = false } + end) + end) + + it_cross_plat("fails when parents is not passed", function() + local p = Path:new("impossible", "dir") + assert.has_error(function() + p:mkdir { parents = false } + end) + assert.is_false(p:exists()) + end) + + it_cross_plat("can create nested directories", function() + local p = Path:new("impossible", "dir") + assert.has_no_error(function() + p:mkdir { parents = true } + end) + assert.is_true(p:exists()) + + p:rmdir() + Path:new("impossible"):rmdir() + assert.is_false(p:exists()) + assert.is_false(Path:new("impossible"):exists()) + end) + + -- it_cross_plat("can set different modes", function() + -- local p = Path:new "_dir_not_exist" + -- assert.has_no_error(function() + -- p:mkdir { mode = 0755 } + -- end) + -- print(vim.uv.fs_stat(p:absolute()).mode) + -- assert_permission(0755, p:permission()) + + -- p:rmdir() + -- assert.is_false(p:exists()) + -- end) + end) + describe("parents", function() it_cross_plat("should extract the ancestors of the path", function() local p = Path:new(vim.fn.getcwd()) From 34c4f55a693673e7f8dd57886b388c16efd8c3c8 Mon Sep 17 00:00:00 2001 From: James Trew Date: Wed, 28 Aug 2024 23:09:50 -0400 Subject: [PATCH 15/43] minor fixes --- lua/plenary/path2.lua | 15 ++---------- tests/plenary/path2_spec.lua | 46 +++++++++++++++++++++--------------- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index ced0246d..e9d5f94f 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -222,7 +222,7 @@ function _PosixPath:join(path, ...) for _, p in ipairs(paths) do if p:sub(1, 1) == self.sep then parts = { p } -- is absolute, ignore previous path, later paths take precedence - elseif path == "" or path:sub(-1) == self.sep then + elseif #parts > 1 and parts[#parts]:sub(-1) == self.sep then table.insert(parts, p) else table.insert(parts, self.sep .. p) @@ -231,17 +231,6 @@ function _PosixPath:join(path, ...) return table.concat(parts) end ---[[ - - for b in map(os.fspath, p): - if b.startswith(sep): - path = b - elif not path or path.endswith(sep): - path += b - else: - path += sep + b -]] - local S_IF = { -- S_IFDIR = 0o040000 # directory DIR = 0x4000, @@ -736,7 +725,7 @@ function Path:make_relative(to, walk_up) end if self:is_relative(to) then - return Path:new(abs:sub(#to:absolute() + 1)).filename + return Path:new((abs:sub(#to:absolute() + 1):gsub("^" .. self.sep, ""))).filename end if not walk_up then diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index ba0fba6f..aa80cf72 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -55,6 +55,7 @@ describe("Path2", function() { { "lua/../README.md" }, "lua/../README.md" }, { { "./lua/../README.md" }, "lua/../README.md" }, { "./lua//..//README.md", "lua/../README.md" }, + { { "foo", "bar", "baz" }, "foo/bar/baz" }, { "foo/bar/", "foo/bar" }, { { readme_path }, readme_path }, { { readme_path, license_path }, license_path }, -- takes only the last abs path @@ -269,7 +270,16 @@ describe("Path2", function() end) describe(":make_relative", function() - local root = iswin and "c:\\" or "/" + local root = function() + if not iswin then + return "/" + end + if hasshellslash and vim.o.shellslash then + return "C:/" + end + return "C:\\" + end + it_cross_plat("can take absolute paths and make them relative to the cwd", function() local p = Path:new { "lua", "plenary", "path.lua" } local absolute = vim.fn.getcwd() .. path.sep .. p.filename @@ -278,7 +288,7 @@ describe("Path2", function() end) it_cross_plat("can take absolute paths and make them relative to a given path", function() - local r = Path:new { root, "home", "prime" } + local r = Path:new { root(), "home", "prime" } local p = Path:new { "aoeu", "agen.lua" } local absolute = r.filename .. path.sep .. p.filename local relative = Path:new(absolute):make_relative(r.filename) @@ -293,7 +303,7 @@ describe("Path2", function() end) it_cross_plat("can take double separator absolute paths and make them relative to a given path", function() - local r = Path:new { root, "home", "prime" } + local r = Path:new { root(), "home", "prime" } local p = Path:new { "aoeu", "agen.lua" } local absolute = r.filename .. path.sep .. path.sep .. p.filename local relative = Path:new(absolute):make_relative(r.filename) @@ -301,7 +311,7 @@ describe("Path2", function() end) it_cross_plat("can take absolute paths and make them relative to a given path with trailing separator", function() - local r = Path:new { root, "home", "prime" } + local r = Path:new { root(), "home", "prime" } local p = Path:new { "aoeu", "agen.lua" } local absolute = r.filename .. path.sep .. p.filename local relative = Path:new(absolute):make_relative(r.filename .. path.sep) @@ -309,14 +319,13 @@ describe("Path2", function() end) it_cross_plat("can take absolute paths and make them relative to the root directory", function() - local p = Path:new { root, "prime", "aoeu", "agen.lua" } - local absolute = root .. p.filename - local relative = Path:new(absolute):make_relative(root) - assert.are.same(p.filename, relative) + local p = Path:new { root(), "prime", "aoeu", "agen.lua" } + local relative = Path:new(p:absolute()):make_relative(root()) + assert.are.same((p.filename:gsub(root(), "")), relative) end) it_cross_plat("can take absolute paths and make them relative to themselves", function() - local p = Path:new { root, "home", "prime", "aoeu", "agen.lua" } + local p = Path:new { root(), "home", "prime", "aoeu", "agen.lua" } local relative = Path:new(p.filename):make_relative(p.filename) assert.are.same(".", relative) end) @@ -445,17 +454,16 @@ describe("Path2", function() assert.is_false(Path:new("impossible"):exists()) end) - -- it_cross_plat("can set different modes", function() - -- local p = Path:new "_dir_not_exist" - -- assert.has_no_error(function() - -- p:mkdir { mode = 0755 } - -- end) - -- print(vim.uv.fs_stat(p:absolute()).mode) - -- assert_permission(0755, p:permission()) + it_cross_plat("can set different modes", function() + local p = Path:new "_dir_not_exist" + assert.has_no_error(function() + p:mkdir { mode = 0755 } + end) + assert_permission(0755, p:permission()) - -- p:rmdir() - -- assert.is_false(p:exists()) - -- end) + p:rmdir() + assert.is_false(p:exists()) + end) end) describe("parents", function() From c1503167c9d2f348d460b9c0122bc988300bcc89 Mon Sep 17 00:00:00 2001 From: James Trew Date: Wed, 28 Aug 2024 23:16:54 -0400 Subject: [PATCH 16/43] more fixes --- lua/plenary/path2.lua | 6 ++++++ tests/plenary/path2_spec.lua | 18 +++++++++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index e9d5f94f..ba26534d 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -342,6 +342,7 @@ end ---@field private _raw_parts string[] ---@field drv string drive name, eg. 'C:' (only for Windows, empty string for Posix) ---@field root string root path (excludes drive name for Windows) +---@field anchor string drive + root (eg 'C:/' for Windows, just '/' otherwise) ---@field relparts string[] path separator separated relative path parts ---@field sep string path separator (respects 'shellslash' on Windows) ---@field filename string @@ -362,6 +363,11 @@ Path.__index = function(t, k) return rawget(t, k) end + if k == "anchor" then + t.anchor = t.drv .. t.root + return t.anchor + end + if k == "filename" then t.filename = t:_filename() return t.filename diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index aa80cf72..f37670c1 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -233,39 +233,39 @@ describe("Path2", function() describe(".exists()", function() it_cross_plat("finds files that exist", function() - assert.are.same(true, Path:new("README.md"):exists()) + assert.is_true(Path:new("README.md"):exists()) end) it_cross_plat("returns false for files that do not exist", function() - assert.are.same(false, Path:new("asdf.md"):exists()) + assert.is_false(Path:new("asdf.md"):exists()) end) end) describe(".is_dir()", function() it_cross_plat("should find directories that exist", function() - assert.are.same(true, Path:new("lua"):is_dir()) + assert.is_true(Path:new("lua"):is_dir()) end) it_cross_plat("should return false when the directory does not exist", function() - assert.are.same(false, Path:new("asdf"):is_dir()) + assert.is_false(Path:new("asdf"):is_dir()) end) it_cross_plat("should not show files as directories", function() - assert.are.same(false, Path:new("README.md"):is_dir()) + assert.is_false(Path:new("README.md"):is_dir()) end) end) describe(".is_file()", function() it_cross_plat("should not allow directories", function() - assert.are.same(true, not Path:new("lua"):is_file()) + assert.is_true(not Path:new("lua"):is_file()) end) it_cross_plat("should return false when the file does not exist", function() - assert.are.same(true, not Path:new("asdf"):is_file()) + assert.is_true(not Path:new("asdf"):is_file()) end) it_cross_plat("should show files as file", function() - assert.are.same(true, Path:new("README.md"):is_file()) + assert.is_true(Path:new("README.md"):is_file()) end) end) @@ -321,7 +321,7 @@ describe("Path2", function() it_cross_plat("can take absolute paths and make them relative to the root directory", function() local p = Path:new { root(), "prime", "aoeu", "agen.lua" } local relative = Path:new(p:absolute()):make_relative(root()) - assert.are.same((p.filename:gsub(root(), "")), relative) + assert.are.same((p.filename:gsub("^" .. root(), "")), relative) end) it_cross_plat("can take absolute paths and make them relative to themselves", function() From 9f8ab3420bf85379d6968480254083fc23f2c76c Mon Sep 17 00:00:00 2001 From: James Trew Date: Wed, 28 Aug 2024 23:35:57 -0400 Subject: [PATCH 17/43] add touch --- lua/plenary/path2.lua | 21 +++++++++++++++ tests/plenary/path2_spec.lua | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index ba26534d..adee895a 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -531,6 +531,11 @@ function Path:stat() return res end +---@deprecated +function Path:_stat() + return self:stat() +end + --- Get file status. Like `Path:stat` but if the path points to a symbolic --- link, returns the symbolic link's information. --- Will throw error if path doesn't exist. @@ -863,6 +868,22 @@ function Path:touch(opts) local mode = type(opts.parents) == "number" and opts.parents ---@cast mode number? _ = Path:new(self:parent()):mkdir { mode = mode, parents = true } end + + local fd, _, err_msg = uv.fs_open(self:absolute(), "w", opts.mode) + if fd == nil then + error(err_msg) + end + + local ok + ok, _, err_msg = uv.fs_close(fd) + if not ok then + error(err_msg) + end + + return true +end + +function Path:rm(opts) end return Path diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index f37670c1..f0fec4c5 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -466,6 +466,56 @@ describe("Path2", function() end) end) + describe("touch/rm", function() + it("can create and delete new files", function() + local p = Path:new "test_file.lua" + assert(pcall(p.touch, p)) + assert(p:exists()) + + p:rm() + assert(not p:exists()) + end) + + it("does not effect already created files but updates last access", function() + local p = Path:new "README.md" + local last_atime = p:stat().atime.sec + local last_mtime = p:stat().mtime.sec + + local lines = p:readlines() + + assert(pcall(p.touch, p)) + print(p:stat().atime.sec > last_atime) + print(p:stat().mtime.sec > last_mtime) + assert(p:exists()) + + assert.are.same(lines, p:readlines()) + end) + + it("does not create dirs if nested in none existing dirs and parents not set", function() + local p = Path:new { "nested", "nested2", "test_file.lua" } + assert(not pcall(p.touch, p, { parents = false })) + assert(not p:exists()) + end) + + it("does create dirs if nested in none existing dirs", function() + local p1 = Path:new { "nested", "nested2", "test_file.lua" } + local p2 = Path:new { "nested", "asdf", ".hidden" } + local d1 = Path:new { "nested", "dir", ".hidden" } + assert(pcall(p1.touch, p1, { parents = true })) + assert(pcall(p2.touch, p2, { parents = true })) + assert(pcall(d1.mkdir, d1, { parents = true })) + assert(p1:exists()) + assert(p2:exists()) + assert(d1:exists()) + + Path:new({ "nested" }):rm { recursive = true } + assert(not p1:exists()) + assert(not p2:exists()) + assert(not d1:exists()) + assert(not Path:new({ "nested" }):exists()) + end) + end) + describe("parents", function() it_cross_plat("should extract the ancestors of the path", function() local p = Path:new(vim.fn.getcwd()) From 324e52b804dd2094c15cb80d764594f736b12349 Mon Sep 17 00:00:00 2001 From: James Trew Date: Thu, 29 Aug 2024 15:17:35 -0400 Subject: [PATCH 18/43] delete path4 --- lua/plenary/path4.lua | 672 -------------------------- tests/plenary/path4_spec.lua | 910 ----------------------------------- 2 files changed, 1582 deletions(-) delete mode 100644 lua/plenary/path4.lua delete mode 100644 tests/plenary/path4_spec.lua diff --git a/lua/plenary/path4.lua b/lua/plenary/path4.lua deleted file mode 100644 index 81b7bcd1..00000000 --- a/lua/plenary/path4.lua +++ /dev/null @@ -1,672 +0,0 @@ ---[[ -- [x] path -- [x] path.home -- [x] path.sep -- [x] path.root -- [x] path.S_IF - - [ ] band - - [ ] concat_paths - - [ ] is_root - - [ ] _split_by_separator - - [ ] is_uri - - [ ] is_absolute - - [ ] _normalize_path - - [ ] clean -- [x] Path -- [ ] check_self -- [x] Path.__index -- [x] Path.__div -- [x] Path.__tostring -- [x] Path.__concat -- [x] Path.is_path -- [x] Path:new -- [x] Path:_fs_filename -- [x] Path:_stat -- [x] Path:_st_mode -- [x] Path:joinpath -- [x] Path:absolute -- [x] Path:exists -- [ ] Path:expand -- [x] Path:make_relative -- [ ] Path:normalize -- [ ] shorten_len -- [ ] shorten -- [ ] Path:shorten -- [ ] Path:mkdir -- [ ] Path:rmdir -- [ ] Path:rename -- [ ] Path:copy -- [ ] Path:touch -- [ ] Path:rm -- [ ] Path:is_dir -- [x] Path:is_absolute -- [ ] Path:_split - - [ ] _get_parent -- [ ] Path:parent -- [ ] Path:parents -- [ ] Path:is_file -- [ ] Path:open -- [ ] Path:close -- [ ] Path:write -- [ ] Path:_read -- [ ] Path:_read_async -- [ ] Path:read -- [ ] Path:head -- [ ] Path:tail -- [ ] Path:readlines -- [ ] Path:iter -- [ ] Path:readbyterange --[ ] Path:find_upwards -]] - -local uv = vim.loop - -local iswin = uv.os_uname().sysname == "Windows_NT" -local hasshellslash = vim.fn.exists "+shellslash" == 1 - -local S_IF = { - -- S_IFDIR = 0o040000 # directory - DIR = 0x4000, - -- S_IFREG = 0o100000 # regular file - REG = 0x8000, -} - ----@class plenary.path ----@field home string home directory path ----@field sep string OS path separator respecting 'shellslash' ---- ---- OS separator for paths returned by libuv functions. ---- Note: libuv will happily take either path separator regardless of 'shellslash'. ----@field private _uv_sep string ---- ---- get the root directory path. ---- On Windows, this is determined from the current working directory in order ---- to capture the current disk name. But can be calculated from another path ---- using the optional `base` parameter. ----@field root fun(base: string?):string ----@field S_IF { DIR: integer, REG: integer } stat filetype bitmask -local path = setmetatable({ - home = vim.fn.getcwd(), -- respects shellslash unlike vim.uv.cwd() - S_IF = S_IF, - _uv_sep = iswin and "\\" or "/", -}, { - __index = function(t, k) - local raw = rawget(t, k) - if raw then - return raw - end - - if k == "sep" then - if not iswin then - t.sep = "/" - return t.sep - end - - return (hasshellslash and vim.o.shellslash) and "/" or "\\" - end - end, -}) - -path.root = (function() - if not iswin then - return function() - return "/" - end - else - return function(base) - base = base or path.home - local disk = base:match "^[%a]:" - if disk then - return disk .. path.sep - end - return string.rep(path.sep, 2) -- UNC - end - end -end)() - ---- WARNING: Should really avoid using this. It's more like ---- `maybe_uri_maybe_not`. There are both false positives and false negative ---- edge cases. ---- ---- Approximates if a filename is a valid URI by checking if the filename ---- starts with a plausible scheme. ---- ---- A valid URI scheme begins with a letter, followed by any number of letters, ---- numbers and `+`, `.`, `-` and ends with a `:`. ---- ---- To disambiguate URI schemes from Windows path, we also check up to 2 ---- characters after the `:` to make sure it's followed by `//`. ---- ---- Two major caveats according to our checks: ---- - a "valid" URI is also a valid unix relative path so any relative unix ---- path that's in the shape of a URI according to our check will be flagged ---- as a URI. ---- - relative Windows paths like `C:Projects/apilibrary/apilibrary.sln` will ---- be caught as a URI. ---- ----@param filename string ----@return boolean -local function is_uri(filename) - local ch = filename:byte(1) or 0 - - -- is not alpha? - if not ((ch >= 97 and ch <= 122) or (ch >= 65 and ch <= 90)) then - return false - end - - local scheme_end = 0 - for i = 2, #filename do - ch = filename:byte(i) - if - (ch >= 97 and ch <= 122) -- a-z - or (ch >= 65 and ch <= 90) -- A-Z - or (ch >= 48 and ch <= 57) -- 0-9 - or ch == 43 -- `+` - or ch == 46 -- `.` - or ch == 45 -- `-` - then -- luacheck: ignore 542 - -- pass - elseif ch == 58 then - scheme_end = i - break - else - return false - end - end - - if scheme_end == 0 then - return false - end - - local next = filename:byte(scheme_end + 1) or 0 - if next == 0 then - -- nothing following the scheme - return false - elseif next == 92 then -- `\` - -- could be Windows absolute path but not a uri - return false - elseif next == 47 and (filename:byte(scheme_end + 2) or 0) ~= 47 then -- `/` - -- still could be Windows absolute path using `/` seps but not a uri - return false - end - return true -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 p string Path to split. ---- @return string, string, boolean : prefix, body, whether path is invalid. -local function split_windows_path(p) - 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 = p:match(pattern) - - if match then - prefix = prefix .. match --[[ @as string ]] - p = p: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, p, false - end - elseif match_to_prefix "^//" then - -- Process UNC path, return early if it's invalid - if not process_unc_path() then - return prefix, p, false - end - elseif p:match "^%w:" then - -- Drive paths - prefix, p = p:sub(1, 2), p: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) - p = trailing_slash .. p --[[ @as string ]] - end - - return prefix, p, 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 p string Path to resolve. ----@return string # Resolved path. -local function path_resolve_dot(p) - local is_path_absolute = vim.startswith(p, "/") - local new_path_components = {} - - for component in vim.gsplit(p, "/") do - if component == "." or component == "" then - -- 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 - -- 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 - ---- Resolves '.' and '..' in the path, removes extra path separator. ---- ---- For Windows, converts separator `\` to `/` to simplify many operations. ---- ---- Credit to famiu. This is basically neovim core `vim.fs.normalize`. ----@param p string path ----@return string -local function normalize_path(p) - if p == "" or is_uri(p) then - return p - end - - if iswin then - p = p:gsub("\\", "/") - end - - local double_slash = vim.startswith(p, "//") and not vim.startswith(p, "///") - local prefix = "" - - if iswin then - local valid - prefix, p, valid = split_windows_path(p) - if not valid then - return prefix .. p - end - prefix = prefix:gsub("/+", "/") - end - - p = path_resolve_dot(p) - p = (double_slash and "/" or "") .. prefix .. p - - if p == "" then - p = "." - end - - return p -end - ----@class plenary.Path ----@field path plenary.path ----@field filename string path as a string ---- ---- internal string representation of the path that's normalized and uses `/` ---- as path separator. makes many other operations much easier to work with. ----@field private _name string ----@field private _sep string path separator taking into account 'shellslash' on windows ----@field private _absolute string? absolute path ----@field private _cwd string? cwd path ----@field private _fs_stat table fs_stat -local Path = { - path = path, -} - -Path.__index = function(t, k) - local raw = rawget(Path, k) - if raw then - return raw - end - - if k == "_cwd" then - local cwd = uv.fs_realpath "." - if cwd ~= nil then - cwd = (cwd:gsub(path._uv_sep, "/")) - end - t._cwd = cwd - return t._cwd - end - - if k == "_absolute" then - local absolute = uv.fs_realpath(t._name) - if absolute ~= nil then - absolute = (absolute:gsub(path._uv_sep, "/")) - end - t._absolute = absolute - return absolute - end - - if k == "_fs_stat" then - t._fs_stat = uv.fs_stat(t._absolute or t._name) or {} - return t._fs_stat - end -end - ----@param other plenary.Path|string ----@return plenary.Path -Path.__div = function(self, other) - assert(Path.is_path(self)) - assert(Path.is_path(other) or type(other) == "string") - - return self:joinpath(other) -end - ----@return string -Path.__tostring = function(self) - return self._name -end - --- TODO: See where we concat the table, and maybe we could make this work. -Path.__concat = function(self, other) - return self.filename .. other -end - -Path.is_path = function(a) - return getmetatable(a) == Path -end - ----@param parts string[] ----@param sep string ----@return string -local function unix_path_str(parts, sep) - -- any sep other than `/` is not a valid sep but allowing for backwards compat reasons - local flat_parts = {} - for _, part in ipairs(parts) do - vim.list_extend(flat_parts, vim.split(part, sep)) - end - - return (table.concat(flat_parts, sep):gsub(sep .. "+", sep)) -end - ----@param parts string[] ----@param sep string ----@return string -local function windows_path_str(parts, sep) - local disk = parts[1]:match "^[%a]:" - local is_disk_root = parts[1]:match "^[%a]:[\\/]" ~= nil - local is_unc = parts[1]:match "^\\\\" or parts[1]:match "^//" - - local flat_parts = {} - for _, part in ipairs(parts) do - vim.list_extend(flat_parts, vim.split(part, "[\\/]")) - end - - if not is_disk_root and flat_parts[1] == disk then - table.remove(flat_parts, 1) - local p = disk .. table.concat(flat_parts, sep) - return (p:gsub(sep .. "+", sep)) - end - if is_unc then - table.remove(flat_parts, 1) - table.remove(flat_parts, 1) - local body = (table.concat(flat_parts, sep):gsub(sep .. "+", sep)) - return sep .. sep .. body - end - return (table.concat(flat_parts, sep):gsub(sep .. "+", sep)) -end - ----@return plenary.Path -function Path:new(...) - local args = { ... } - - if type(self) == "string" then - table.insert(args, 1, self) - self = Path - end - - local path_input - if #args == 1 then - if Path.is_path(args[1]) then - local p = args[1] ---@cast p plenary.Path - return p - end - if type(args[1]) == "table" then - path_input = args[1] - else - assert(type(args[1]) == "string", "unexpected path input\n" .. vim.inspect(path_input)) - path_input = args - end - else - path_input = args - end - - assert(type(path_input) == "table", vim.inspect(path_input)) - ---@cast path_input {[integer]: (string)|plenary.Path, sep: string?} - - local sep = path.sep - sep = path_input.sep or path.sep - path_input.sep = nil - path_input = vim.tbl_map(function(part) - if Path.is_path(part) then - return part.filename - else - assert(type(part) == "string", vim.inspect(path_input)) - return vim.trim(part) - end - end, path_input) - - assert(#path_input > 0, "can't create Path out of nothing") - - local path_string - if iswin then - path_string = windows_path_str(path_input, sep) - else - path_string = unix_path_str(path_input, sep) - end - - local proxy = { - -- precompute normalized path using `/` as sep - _name = normalize_path(path_string), - filename = path_string, - _sep = sep, - } - - setmetatable(proxy, Path) - - local obj = { __inner = proxy } - setmetatable(obj, { - __index = function(_, k) - return proxy[k] - end, - __newindex = function(t, k, val) - if k == "filename" then - proxy.filename, proxy._name = val, normalize_path(val) - proxy._absolute, proxy._fs_stat = nil, nil - elseif k == "_name" then - proxy.filename, proxy._name = (val:gsub("/", t._sep)), val - proxy._absolute, proxy._fs_stat = nil, nil - else - proxy[k] = val - end - end, - -- stylua: ignore start - __div = function(t, other) return Path.__div(t, other) end, - __concat = function(t, other) return Path.__concat(t, other) end, - __tostring = function(t) return Path.__tostring(t) end, - __metatable = Path, - -- stylua: ignore end - }) - - return obj -end - ----@return string -function Path:absolute() - if self:is_absolute() then - return (self._name:gsub("/", self._sep)) - end - return (normalize_path(self._cwd .. self._sep .. self._name):gsub("/", self._sep)) -end - ----@return string -function Path:_fs_filename() - return self:absolute() or self.filename -end - ----@return table -function Path:_stat() - return self._fs_stat -end - ----@return number -function Path:_st_mode() - return self:_stat().mode or 0 -end - ----@return boolean -function Path:exists() - return not vim.tbl_isempty(self:_stat()) -end - ----@return boolean -function Path:is_dir() - return self:_stat().type == "directory" -end - ----@return boolean -function Path:is_file() - return self:_stat().type == "file" -end - ---- For POSIX path, anything starting with a `/` is considered a absolute path. ---- ---- ---- For Windows, it's a little more involved. ---- ---- Disk names are single letters. They MUST be followed by a `:` + separator to be ---- considered an absolute path. eg. ---- C:\Documents\Newsletters\Summer2018.pdf -> An absolute file path from the root of drive C:. - ---- UNC paths are also considered absolute. eg. \\Server2\Share\Test\Foo.txt ---- ---- Any other valid paths are relative. eg. ---- C:Projects\apilibrary\apilibrary.sln -> A relative path from the current directory of the C: drive. ---- 2018\January.xlsx -> A relative path to a file in a subdirectory of the current directory. ---- \Program Files\Custom Utilities\StringFinder.exe -> A relative path from the root of the current drive. ---- ..\Publications\TravelBrochure.pdf -> A relative path to a file in a directory starting from the current directory. ----@return boolean -function Path:is_absolute() - if not iswin then - return string.sub(self._name, 1, 1) == "/" - end - - if string.match(self._name, "^[%a]:/.*$") ~= nil then - return true - elseif string.match(self._name, "^//") then - return true - end - - return false -end - ----@return plenary.Path -function Path:joinpath(...) - return Path:new(self._name, ...) -end - ---- Make an absolute path relative to another path. ---- ---- No-op if path is a URI. ----@param cwd string? path to make relative to (default: cwd) ----@return string # new filename -function Path:make_relative(cwd) - if is_uri(self._name) then - return self.filename - end - - cwd = Path:new(vim.F.if_nil(cwd, self._cwd))._name - if self._name == cwd then - self._name = "." - return self.filename - end - - if cwd:sub(#cwd, #cwd) ~= "/" then - cwd = cwd .. "/" - end - - if not self:is_absolute() then - self._name = normalize_path(cwd .. self._name) - end - - if self._name:sub(1, #cwd) == cwd then - self._name = self._name:sub(#cwd + 1, -1) - return self.filename - end - - -- if self._name:sub(1, #cwd) == cwd then - -- self._name = self._name:sub(#cwd + 1, -1) - -- else - -- error(string.format("'%s' is not a subpath of '%s'", self.filename, cwd)) - -- end - return self.filename -end - ---- Makes the path relative to cwd or provided path and resolves any internal ---- '.' and '..' in relative paths according. Substitutes home directory ---- with `~` if applicable. Deduplicates path separators and trims any trailing ---- separators. ---- ---- No-op if path is a URI. ----@param cwd string? path to make relative to (default: cwd) ----@return string -function Path:normalize(cwd) - if is_uri(self._name) then - return self.filename - end - - self:make_relative(cwd) - - local home = path.home - if home:sub(-1) ~= self._sep then - home = home .. self._sep - end - - local start, finish = self._name:find(home, 1, true) - if start == 1 then - self._name = "~/" .. self._name:sub(finish + 1, -1) - end - - return (self._name:gsub("/", self._sep)) -end - --- local p = Path:new "C:/Windows/temp/lua/plenary/path.lua" --- print(p.filename, p:normalize "C:/Windows/temp/lua") - --- local p = Path:new "../lua/plenary/path.lua" --- print(p:normalize("C:/Windows/temp/lua")) - -return Path diff --git a/tests/plenary/path4_spec.lua b/tests/plenary/path4_spec.lua deleted file mode 100644 index d5e86452..00000000 --- a/tests/plenary/path4_spec.lua +++ /dev/null @@ -1,910 +0,0 @@ -local Path = require "plenary.path4" -local path = Path.path --- local compat = require "plenary.compat" -local iswin = vim.loop.os_uname().sysname == "Windows_NT" - -local hasshellslash = vim.fn.exists "+shellslash" == 1 - ----@param bool boolean -local function set_shellslash(bool) - if hasshellslash then - vim.o.shellslash = bool - end -end - -local function it_ssl(name, test_fn) - if not hasshellslash then - it(name, test_fn) - else - local orig = vim.o.shellslash - vim.o.shellslash = true - it(name .. " - shellslash", test_fn) - - vim.o.shellslash = false - it(name .. " - noshellslash", test_fn) - vim.o.shellslash = orig - end -end - -local function it_cross_plat(name, test_fn) - if not iswin then - it(name .. " - unix", test_fn) - else - it_ssl(name .. " - windows", test_fn) - end -end - ---- convert unix path into window paths -local function plat_path(p) - if not iswin then - return p - end - if hasshellslash and vim.o.shellslash then - return p - end - return p:gsub("/", "\\") -end - -describe("absolute", function() - describe("unix", function() - if iswin then - return - end - end) - - describe("windows", function() - if not iswin then - return - end - - describe("shellslash", function() - set_shellslash(true) - end) - - describe("noshellslash", function() - set_shellslash(false) - end) - end) -end) - -describe("Path", function() - describe("filename", function() - local function get_paths() - local readme_path = vim.fn.fnamemodify("README.md", ":p") - - ---@type [string[]|string, string][] - local paths = { - { "README.md", "README.md" }, - { { "README.md" }, "README.md" }, - { { "lua", "..", "README.md" }, "lua/../README.md" }, - { { "lua/../README.md" }, "lua/../README.md" }, - { { "./lua/../README.md" }, "./lua/../README.md" }, - { "./lua//..//README.md", "./lua/../README.md" }, - { { readme_path }, readme_path }, - } - - return paths - end - - local function test_filename(test_cases) - for _, tc in ipairs(test_cases) do - local input, expect = tc[1], tc[2] - it(vim.inspect(input), function() - local p = Path:new(input) - assert.are.same(expect, p.filename) - end) - end - end - - describe("unix", function() - if iswin then - return - end - test_filename(get_paths()) - end) - - describe("windows", function() - if not iswin then - return - end - - local function get_windows_paths() - local nossl = hasshellslash and not vim.o.shellslash - - ---@type [string[]|string, string][] - local paths = { - { [[C:\Documents\Newsletters\Summer2018.pdf]], [[C:/Documents/Newsletters/Summer2018.pdf]] }, - { [[C:\\Documents\\Newsletters\Summer2018.pdf]], [[C:/Documents/Newsletters/Summer2018.pdf]] }, - { { [[C:\Documents\Newsletters\Summer2018.pdf]] }, [[C:/Documents/Newsletters/Summer2018.pdf]] }, - { { [[C:/Documents/Newsletters/Summer2018.pdf]] }, [[C:/Documents/Newsletters/Summer2018.pdf]] }, - { { [[\\Server2\Share\Test\Foo.txt]] }, [[//Server2/Share/Test/Foo.txt]] }, - { { [[//Server2/Share/Test/Foo.txt]] }, [[//Server2/Share/Test/Foo.txt]] }, - { [[//Server2//Share//Test/Foo.txt]], [[//Server2/Share/Test/Foo.txt]] }, - { [[\\Server2\\Share\\Test\Foo.txt]], [[//Server2/Share/Test/Foo.txt]] }, - { { "C:", "lua", "..", "README.md" }, "C:lua/../README.md" }, - { { "C:/", "lua", "..", "README.md" }, "C:/lua/../README.md" }, - { "C:lua/../README.md", "C:lua/../README.md" }, - { "C:/lua/../README.md", "C:/lua/../README.md" }, - { [[foo/bar\baz]], [[foo/bar/baz]] }, - { [[\\.\C:\Test\Foo.txt]], [[//./C:/Test/Foo.txt]] }, - { [[\\?\C:\Test\Foo.txt]], [[//?/C:/Test/Foo.txt]] }, - } - vim.list_extend(paths, get_paths()) - - if nossl then - paths = vim.tbl_map(function(tc) - return { tc[1], (tc[2]:gsub("/", "\\")) } - end, paths) - end - - return paths - end - - it("custom sep", function() - local p = Path:new { "foo\\bar/baz", sep = "/" } - assert.are.same(p.filename, "foo/bar/baz") - end) - - describe("noshellslash", function() - set_shellslash(false) - test_filename(get_windows_paths()) - end) - - describe("shellslash", function() - set_shellslash(true) - test_filename(get_windows_paths()) - end) - end) - end) - - describe("absolute", function() - local function get_paths() - local readme_path = vim.fn.fnamemodify("README.md", ":p") - - ---@type [string[]|string, string, boolean][] - local paths = { - { "README.md", readme_path, false }, - { { "lua", "..", "README.md" }, readme_path, false }, - { { readme_path }, readme_path, true }, - } - return paths - end - - local function test_absolute(test_cases) - for _, tc in ipairs(test_cases) do - local input, expect, is_absolute = tc[1], tc[2], tc[3] - it(vim.inspect(input), function() - local p = Path:new(input) - assert.are.same(expect, p:absolute()) - assert.are.same(is_absolute, p:is_absolute()) - end) - end - end - - describe("unix", function() - if iswin then - return - end - test_absolute(get_paths()) - end) - - describe("windows", function() - if not iswin then - return - end - - local function get_windows_paths() - local nossl = hasshellslash and not vim.o.shellslash - local disk = path.root():match "^[%a]:" - local readme_path = vim.fn.fnamemodify("README.md", ":p") - - ---@type [string[]|string, string, boolean][] - local paths = { - { [[C:\Documents\Newsletters\Summer2018.pdf]], [[C:/Documents/Newsletters/Summer2018.pdf]], true }, - { [[C:/Documents/Newsletters/Summer2018.pdf]], [[C:/Documents/Newsletters/Summer2018.pdf]], true }, - { [[\\Server2\Share\Test\Foo.txt]], [[//Server2/Share/Test/Foo.txt]], true }, - { [[//Server2/Share/Test/Foo.txt]], [[//Server2/Share/Test/Foo.txt]], true }, - { [[\\.\C:\Test\Foo.txt]], [[//./C:/Test/Foo.txt]], true }, - { [[\\?\C:\Test\Foo.txt]], [[//?/C:/Test/Foo.txt]], true }, - { readme_path, readme_path, true }, - { disk .. [[lua/../README.md]], readme_path, false }, - { { disk, "lua", "..", "README.md" }, readme_path, false }, - } - vim.list_extend(paths, get_paths()) - - if nossl then - paths = vim.tbl_map(function(tc) - return { tc[1], (tc[2]:gsub("/", "\\")), tc[3] } - end, paths) - end - - return paths - end - - describe("shellslash", function() - set_shellslash(true) - test_absolute(get_windows_paths()) - end) - - describe("noshellslash", function() - set_shellslash(false) - test_absolute(get_windows_paths()) - end) - end) - end) - - it_cross_plat("can join paths by constructor or join path", function() - assert.are.same(Path:new("lua", "plenary"), Path:new("lua"):joinpath "plenary") - end) - - it_cross_plat("can join paths with /", function() - assert.are.same(Path:new("lua", "plenary"), Path:new "lua" / "plenary") - end) - - it_cross_plat("can join paths with paths", function() - assert.are.same(Path:new("lua", "plenary"), Path:new("lua", Path:new "plenary")) - end) - - it_cross_plat("inserts slashes", function() - assert.are.same("lua" .. path.sep .. "plenary", Path:new("lua", "plenary").filename) - end) - - describe(".exists()", function() - it_cross_plat("finds files that exist", function() - assert.are.same(true, Path:new("README.md"):exists()) - end) - - it_cross_plat("returns false for files that do not exist", function() - assert.are.same(false, Path:new("asdf.md"):exists()) - end) - end) - - describe(".is_dir()", function() - it_cross_plat("should find directories that exist", function() - assert.are.same(true, Path:new("lua"):is_dir()) - end) - - it_cross_plat("should return false when the directory does not exist", function() - assert.are.same(false, Path:new("asdf"):is_dir()) - end) - - it_cross_plat("should not show files as directories", function() - assert.are.same(false, Path:new("README.md"):is_dir()) - end) - end) - - describe(".is_file()", function() - it_cross_plat("should not allow directories", function() - assert.are.same(true, not Path:new("lua"):is_file()) - end) - - it_cross_plat("should return false when the file does not exist", function() - assert.are.same(true, not Path:new("asdf"):is_file()) - end) - - it_cross_plat("should show files as file", function() - assert.are.same(true, Path:new("README.md"):is_file()) - end) - end) - - describe(":new", function() - it_cross_plat("can be called with or without colon", function() - -- This will work, cause we used a colon - local with_colon = Path:new "lua" - local no_colon = Path.new "lua" - - assert.are.same(with_colon, no_colon) - end) - end) - - describe(":make_relative", function() - local root = iswin and "c:\\" or "/" - it_cross_plat("can take absolute paths and make them relative to the cwd", function() - local p = Path:new { "lua", "plenary", "path.lua" } - local absolute = vim.loop.cwd() .. path.sep .. p.filename - local relative = Path:new(absolute):make_relative() - assert.are.same(p.filename, relative) - end) - - it_cross_plat("can take absolute paths and make them relative to a given path", function() - local r = Path:new { root, "home", "prime" } - local p = Path:new { "aoeu", "agen.lua" } - local absolute = r.filename .. path.sep .. p.filename - local relative = Path:new(absolute):make_relative(r.filename) - assert.are.same(relative, p.filename) - end) - - it_cross_plat("can take double separator absolute paths and make them relative to the cwd", function() - local p = Path:new { "lua", "plenary", "path.lua" } - local absolute = vim.loop.cwd() .. path.sep .. path.sep .. p.filename - local relative = Path:new(absolute):make_relative() - assert.are.same(relative, p.filename) - end) - - it_cross_plat("can take double separator absolute paths and make them relative to a given path", function() - local r = Path:new { root, "home", "prime" } - local p = Path:new { "aoeu", "agen.lua" } - local absolute = r.filename .. path.sep .. path.sep .. p.filename - local relative = Path:new(absolute):make_relative(r.filename) - assert.are.same(relative, p.filename) - end) - - it_cross_plat("can take absolute paths and make them relative to a given path with trailing separator", function() - local r = Path:new { root, "home", "prime" } - local p = Path:new { "aoeu", "agen.lua" } - local absolute = r.filename .. path.sep .. p.filename - local relative = Path:new(absolute):make_relative(r.filename .. path.sep) - assert.are.same(relative, p.filename) - end) - - it_cross_plat("can take absolute paths and make them relative to the root directory", function() - local p = Path:new { "home", "prime", "aoeu", "agen.lua" } - local absolute = root .. p.filename - local relative = Path:new(absolute):make_relative(root) - assert.are.same(relative, p.filename) - end) - - it_cross_plat("can take absolute paths and make them relative to themselves", function() - local p = Path:new { root, "home", "prime", "aoeu", "agen.lua" } - local relative = Path:new(p.filename):make_relative(p.filename) - assert.are.same(relative, ".") - end) - - it_cross_plat("should not truncate if path separator is not present after cwd", function() - local cwd = "tmp" .. path.sep .. "foo" - local p = Path:new { "tmp", "foo_bar", "fileb.lua" } - local relative = Path:new(p.filename):make_relative(cwd) - assert.are.same(p.filename, relative) - end) - - it_cross_plat("should not truncate if path separator is not present after cwd and cwd ends in path sep", function() - local cwd = "tmp" .. path.sep .. "foo" .. path.sep - local p = Path:new { "tmp", "foo_bar", "fileb.lua" } - local relative = Path:new(p.filename):make_relative(cwd) - assert.are.same(p.filename, relative) - end) - end) - - describe(":normalize", function() - local home = iswin and "C:/Users/test/" or "/home/test/" - local tmp_lua = iswin and "C:/Windows/Temp/lua" or "/tmp/lua" - - it_cross_plat("handles absolute paths with '.' and double separators", function() - local orig = iswin and "C:/Users/test/./p//path.lua" or "/home/test/./p//path.lua" - local final = Path:new(orig):normalize(home) - local expect = plat_path "p/path.lua" - assert.are.same(expect, final) - end) - - it_cross_plat("handles relative paths with '.' and double separators", function() - local orig = "lua//plenary/./path.lua" - local final = Path:new(orig):normalize() - local expect = plat_path "lua/plenary/path.lua" - assert.are.same(expect, final) - end) - - it_cross_plat("can normalize relative paths containing ..", function() - local orig = "lua/plenary/path.lua/../path.lua" - local final = Path:new(orig):normalize() - local expect = plat_path "lua/plenary/path.lua" - assert.are.same(expect, final) - end) - - it_cross_plat("can normalize relative paths with initial ..", function() - local p = Path:new "../lua/plenary/path.lua" - local expect = plat_path "lua/plenary/path.lua" - assert.are.same(expect, p:normalize(tmp_lua)) - end) - - -- it_cross_plat("can normalize relative paths to absolute when initial .. count matches cwd parts", function() - -- local p = Path:new "../../tmp/lua/plenary/path.lua" - -- assert.are.same("/tmp/lua/plenary/path.lua", p:normalize(tmp_lua)) - -- end) - - -- it_cross_plat("can normalize ~ when file is within home directory (trailing slash)", function() - -- local p = Path:new { home, "./test_file" } - -- p.path.home = home - -- p._cwd = tmp_lua - -- assert.are.same("~/test_file", p:normalize()) - -- end) - - -- it_cross_plat("can normalize ~ when file is within home directory (no trailing slash)", function() - -- local p = Path:new { home, "./test_file" } - -- p.path.home = home - -- p._cwd = tmp_lua - -- assert.are.same("~/test_file", p:normalize()) - -- end) - - -- it_cross_plat("handles usernames with a dash at the end", function() - -- local p = Path:new { home, "test_file" } - -- p.path.home = home - -- p._cwd = tmp_lua - -- assert.are.same("~/test_file", p:normalize()) - -- end) - - -- it_cross_plat("handles filenames with the same prefix as the home directory", function() - -- local pstr = iswin and "C:/Users/test.old/test_file" or "/home/test.old/test_file" - -- local p = Path:new(pstr) - -- p.path.home = home - -- assert.are.same(pstr, p:normalize()) - -- end) - end) - - -- describe(":shorten", function() - -- it_cross_plat("can shorten a path", function() - -- local long_path = "/this/is/a/long/path" - -- local short_path = Path:new(long_path):shorten() - -- assert.are.same(short_path, "/t/i/a/l/path") - -- end) - - -- it_cross_plat("can shorten a path's components to a given length", function() - -- local long_path = "/this/is/a/long/path" - -- local short_path = Path:new(long_path):shorten(2) - -- assert.are.same(short_path, "/th/is/a/lo/path") - - -- -- without the leading / - -- long_path = "this/is/a/long/path" - -- short_path = Path:new(long_path):shorten(3) - -- assert.are.same(short_path, "thi/is/a/lon/path") - - -- -- where len is greater than the length of the final component - -- long_path = "this/is/an/extremely/long/path" - -- short_path = Path:new(long_path):shorten(5) - -- assert.are.same(short_path, "this/is/an/extre/long/path") - -- end) - - -- it_cross_plat("can shorten a path's components when excluding parts", function() - -- local long_path = "/this/is/a/long/path" - -- local short_path = Path:new(long_path):shorten(nil, { 1, -1 }) - -- assert.are.same(short_path, "/this/i/a/l/path") - - -- -- without the leading / - -- long_path = "this/is/a/long/path" - -- short_path = Path:new(long_path):shorten(nil, { 1, -1 }) - -- assert.are.same(short_path, "this/i/a/l/path") - - -- -- where excluding positions greater than the number of parts - -- long_path = "this/is/an/extremely/long/path" - -- short_path = Path:new(long_path):shorten(nil, { 2, 4, 6, 8 }) - -- assert.are.same(short_path, "t/is/a/extremely/l/path") - - -- -- where excluding positions less than the negation of the number of parts - -- long_path = "this/is/an/extremely/long/path" - -- short_path = Path:new(long_path):shorten(nil, { -2, -4, -6, -8 }) - -- assert.are.same(short_path, "this/i/an/e/long/p") - -- end) - - -- it_cross_plat("can shorten a path's components to a given length and exclude positions", function() - -- local long_path = "/this/is/a/long/path" - -- local short_path = Path:new(long_path):shorten(2, { 1, -1 }) - -- assert.are.same(short_path, "/this/is/a/lo/path") - - -- long_path = "this/is/a/long/path" - -- short_path = Path:new(long_path):shorten(3, { 2, -2 }) - -- assert.are.same(short_path, "thi/is/a/long/pat") - - -- long_path = "this/is/an/extremely/long/path" - -- short_path = Path:new(long_path):shorten(5, { 3, -3 }) - -- assert.are.same(short_path, "this/is/an/extremely/long/path") - -- end) - -- end) - - -- describe("mkdir / rmdir", function() - -- it_cross_plat("can create and delete directories", function() - -- local p = Path:new "_dir_not_exist" - - -- p:rmdir() - -- assert(not p:exists(), "After rmdir, it should not exist") - - -- p:mkdir() - -- assert(p:exists()) - - -- p:rmdir() - -- assert(not p:exists()) - -- end) - - -- it_cross_plat("fails when exists_ok is false", function() - -- local p = Path:new "lua" - -- assert(not pcall(p.mkdir, p, { exists_ok = false })) - -- end) - - -- it_cross_plat("fails when parents is not passed", function() - -- local p = Path:new("impossible", "dir") - -- assert(not pcall(p.mkdir, p, { parents = false })) - -- assert(not p:exists()) - -- end) - - -- it_cross_plat("can create nested directories", function() - -- local p = Path:new("impossible", "dir") - -- assert(pcall(p.mkdir, p, { parents = true })) - -- assert(p:exists()) - - -- p:rmdir() - -- Path:new("impossible"):rmdir() - -- assert(not p:exists()) - -- assert(not Path:new("impossible"):exists()) - -- end) - -- end) - - -- describe("touch", function() - -- it_cross_plat("can create and delete new files", function() - -- local p = Path:new "test_file.lua" - -- assert(pcall(p.touch, p)) - -- assert(p:exists()) - - -- p:rm() - -- assert(not p:exists()) - -- end) - - -- it_cross_plat("does not effect already created files but updates last access", function() - -- local p = Path:new "README.md" - -- local last_atime = p:_stat().atime.sec - -- local last_mtime = p:_stat().mtime.sec - - -- local lines = p:readlines() - - -- assert(pcall(p.touch, p)) - -- print(p:_stat().atime.sec > last_atime) - -- print(p:_stat().mtime.sec > last_mtime) - -- assert(p:exists()) - - -- assert.are.same(lines, p:readlines()) - -- end) - - -- it_cross_plat("does not create dirs if nested in none existing dirs and parents not set", function() - -- local p = Path:new { "nested", "nested2", "test_file.lua" } - -- assert(not pcall(p.touch, p, { parents = false })) - -- assert(not p:exists()) - -- end) - - -- it_cross_plat("does create dirs if nested in none existing dirs", function() - -- local p1 = Path:new { "nested", "nested2", "test_file.lua" } - -- local p2 = Path:new { "nested", "asdf", ".hidden" } - -- local d1 = Path:new { "nested", "dir", ".hidden" } - -- assert(pcall(p1.touch, p1, { parents = true })) - -- assert(pcall(p2.touch, p2, { parents = true })) - -- assert(pcall(d1.mkdir, d1, { parents = true })) - -- assert(p1:exists()) - -- assert(p2:exists()) - -- assert(d1:exists()) - - -- Path:new({ "nested" }):rm { recursive = true } - -- assert(not p1:exists()) - -- assert(not p2:exists()) - -- assert(not d1:exists()) - -- assert(not Path:new({ "nested" }):exists()) - -- end) - -- end) - - -- describe("rename", function() - -- it_cross_plat("can rename a file", function() - -- local p = Path:new "a_random_filename.lua" - -- assert(pcall(p.touch, p)) - -- assert(p:exists()) - - -- assert(pcall(p.rename, p, { new_name = "not_a_random_filename.lua" })) - -- assert.are.same("not_a_random_filename.lua", p.filename) - - -- p:rm() - -- end) - - -- it_cross_plat("can handle an invalid filename", function() - -- local p = Path:new "some_random_filename.lua" - -- assert(pcall(p.touch, p)) - -- assert(p:exists()) - - -- assert(not pcall(p.rename, p, { new_name = "" })) - -- assert(not pcall(p.rename, p)) - -- assert.are.same("some_random_filename.lua", p.filename) - - -- p:rm() - -- end) - - -- it_cross_plat("can move to parent dir", function() - -- local p = Path:new "some_random_filename.lua" - -- assert(pcall(p.touch, p)) - -- assert(p:exists()) - - -- assert(pcall(p.rename, p, { new_name = "../some_random_filename.lua" })) - -- assert.are.same(vim.loop.fs_realpath(Path:new("../some_random_filename.lua"):absolute()), p:absolute()) - - -- p:rm() - -- end) - - -- it_cross_plat("cannot rename to an existing filename", function() - -- local p1 = Path:new "a_random_filename.lua" - -- local p2 = Path:new "not_a_random_filename.lua" - -- assert(pcall(p1.touch, p1)) - -- assert(pcall(p2.touch, p2)) - -- assert(p1:exists()) - -- assert(p2:exists()) - - -- assert(not pcall(p1.rename, p1, { new_name = "not_a_random_filename.lua" })) - -- assert.are.same(p1.filename, "a_random_filename.lua") - - -- p1:rm() - -- p2:rm() - -- end) - -- end) - - -- describe("copy", function() - -- it_cross_plat("can copy a file", function() - -- local p1 = Path:new "a_random_filename.rs" - -- local p2 = Path:new "not_a_random_filename.rs" - -- assert(pcall(p1.touch, p1)) - -- assert(p1:exists()) - - -- assert(pcall(p1.copy, p1, { destination = "not_a_random_filename.rs" })) - -- assert.are.same(p1.filename, "a_random_filename.rs") - -- assert.are.same(p2.filename, "not_a_random_filename.rs") - - -- p1:rm() - -- p2:rm() - -- end) - - -- it_cross_plat("can copy to parent dir", function() - -- local p = Path:new "some_random_filename.lua" - -- assert(pcall(p.touch, p)) - -- assert(p:exists()) - - -- assert(pcall(p.copy, p, { destination = "../some_random_filename.lua" })) - -- assert(pcall(p.exists, p)) - - -- p:rm() - -- Path:new(vim.loop.fs_realpath "../some_random_filename.lua"):rm() - -- end) - - -- it_cross_plat("cannot copy an existing file if override false", function() - -- local p1 = Path:new "a_random_filename.rs" - -- local p2 = Path:new "not_a_random_filename.rs" - -- assert(pcall(p1.touch, p1)) - -- assert(pcall(p2.touch, p2)) - -- assert(p1:exists()) - -- assert(p2:exists()) - - -- assert(pcall(p1.copy, p1, { destination = "not_a_random_filename.rs", override = false })) - -- assert.are.same(p1.filename, "a_random_filename.rs") - -- assert.are.same(p2.filename, "not_a_random_filename.rs") - - -- p1:rm() - -- p2:rm() - -- end) - - -- it_cross_plat("fails when copying folders non-recursively", function() - -- local src_dir = Path:new "src" - -- src_dir:mkdir() - -- src_dir:joinpath("file1.lua"):touch() - - -- local trg_dir = Path:new "trg" - -- local status = xpcall(function() - -- src_dir:copy { destination = trg_dir, recursive = false } - -- end, function() end) - -- -- failed as intended - -- assert(status == false) - - -- src_dir:rm { recursive = true } - -- end) - - -- it_cross_plat("can copy directories recursively", function() - -- -- vim.tbl_flatten doesn't work here as copy doesn't return a list - -- local flatten - -- flatten = function(ret, t) - -- for _, v in pairs(t) do - -- if type(v) == "table" then - -- flatten(ret, v) - -- else - -- table.insert(ret, v) - -- end - -- end - -- end - - -- -- setup directories - -- local src_dir = Path:new "src" - -- local trg_dir = Path:new "trg" - -- src_dir:mkdir() - - -- -- set up sub directory paths for creation and testing - -- local sub_dirs = { "sub_dir1", "sub_dir1/sub_dir2" } - -- local src_dirs = { src_dir } - -- local trg_dirs = { trg_dir } - -- -- {src, trg}_dirs is a table with all directory levels by {src, trg} - -- for _, dir in ipairs(sub_dirs) do - -- table.insert(src_dirs, src_dir:joinpath(dir)) - -- table.insert(trg_dirs, trg_dir:joinpath(dir)) - -- end - - -- -- generate {file}_{level}.lua on every directory level in src - -- -- src - -- -- ├── file1_1.lua - -- -- ├── file2_1.lua - -- -- ├── .file3_1.lua - -- -- └── sub_dir1 - -- -- ├── file1_2.lua - -- -- ├── file2_2.lua - -- -- ├── .file3_2.lua - -- -- └── sub_dir2 - -- -- ├── file1_3.lua - -- -- ├── file2_3.lua - -- -- └── .file3_3.lua - -- local files = { "file1", "file2", ".file3" } - -- for _, file in ipairs(files) do - -- for level, dir in ipairs(src_dirs) do - -- local p = dir:joinpath(file .. "_" .. level .. ".lua") - -- assert(pcall(p.touch, p, { parents = true, exists_ok = true })) - -- assert(p:exists()) - -- end - -- end - - -- for _, hidden in ipairs { true, false } do - -- -- override = `false` should NOT copy as it was copied beforehand - -- for _, override in ipairs { true, false } do - -- local success = src_dir:copy { destination = trg_dir, recursive = true, override = override, hidden = hidden } - -- -- the files are already created because we iterate first with `override=true` - -- -- hence, we test here that no file ops have been committed: any value in tbl of tbls should be false - -- if not override then - -- local file_ops = {} - -- flatten(file_ops, success) - -- -- 3 layers with at at least 2 and at most 3 files (`hidden = true`) - -- local num_files = not hidden and 6 or 9 - -- assert(#file_ops == num_files) - -- for _, op in ipairs(file_ops) do - -- assert(op == false) - -- end - -- else - -- for _, file in ipairs(files) do - -- for level, dir in ipairs(trg_dirs) do - -- local p = dir:joinpath(file .. "_" .. level .. ".lua") - -- -- file 3 is hidden - -- if not (file == files[3]) then - -- assert(p:exists()) - -- else - -- assert(p:exists() == hidden) - -- end - -- end - -- end - -- end - -- -- only clean up once we tested that we dont want to copy - -- -- if `override=true` - -- if not override then - -- trg_dir:rm { recursive = true } - -- end - -- end - -- end - - -- src_dir:rm { recursive = true } - -- end) - -- end) - - -- describe("parents", function() - -- it_cross_plat("should extract the ancestors of the path", function() - -- local p = Path:new(vim.loop.cwd()) - -- local parents = p:parents() - -- assert(compat.islist(parents)) - -- for _, parent in pairs(parents) do - -- assert.are.same(type(parent), "string") - -- end - -- end) - -- it_cross_plat("should return itself if it corresponds to path.root", function() - -- local p = Path:new(Path.path.root(vim.loop.cwd())) - -- assert.are.same(p:parent(), p) - -- end) - -- end) - - -- describe("read parts", function() - -- it_cross_plat("should read head of file", function() - -- local p = Path:new "LICENSE" - -- local data = p:head() - -- local should = [[MIT License - - -- Copyright (c) 2020 TJ DeVries - - -- Permission is hereby granted, free of charge, to any person obtaining a copy - -- of this software and associated documentation files (the "Software"), to deal - -- in the Software without restriction, including without limitation the rights - -- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - -- copies of the Software, and to permit persons to whom the Software is - -- furnished to do so, subject to the following conditions:]] - -- assert.are.same(should, data) - -- end) - - -- it_cross_plat("should read the first line of file", function() - -- local p = Path:new "LICENSE" - -- local data = p:head(1) - -- local should = [[MIT License]] - -- assert.are.same(should, data) - -- end) - - -- it_cross_plat("head should max read whole file", function() - -- local p = Path:new "LICENSE" - -- local data = p:head(1000) - -- local should = [[MIT License - - -- Copyright (c) 2020 TJ DeVries - - -- Permission is hereby granted, free of charge, to any person obtaining a copy - -- of this software and associated documentation files (the "Software"), to deal - -- in the Software without restriction, including without limitation the rights - -- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - -- copies of the Software, and to permit persons to whom the Software is - -- furnished to do so, subject to the following conditions: - - -- The above copyright notice and this permission notice shall be included in all - -- copies or substantial portions of the Software. - - -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - -- SOFTWARE.]] - -- assert.are.same(should, data) - -- end) - - -- it_cross_plat("should read tail of file", function() - -- local p = Path:new "LICENSE" - -- local data = p:tail() - -- local should = [[The above copyright notice and this permission notice shall be included in all - -- copies or substantial portions of the Software. - - -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - -- SOFTWARE.]] - -- assert.are.same(should, data) - -- end) - - -- it_cross_plat("should read the last line of file", function() - -- local p = Path:new "LICENSE" - -- local data = p:tail(1) - -- local should = [[SOFTWARE.]] - -- assert.are.same(should, data) - -- end) - - -- it_cross_plat("tail should max read whole file", function() - -- local p = Path:new "LICENSE" - -- local data = p:tail(1000) - -- local should = [[MIT License - - -- Copyright (c) 2020 TJ DeVries - - -- Permission is hereby granted, free of charge, to any person obtaining a copy - -- of this software and associated documentation files (the "Software"), to deal - -- in the Software without restriction, including without limitation the rights - -- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - -- copies of the Software, and to permit persons to whom the Software is - -- furnished to do so, subject to the following conditions: - - -- The above copyright notice and this permission notice shall be included in all - -- copies or substantial portions of the Software. - - -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - -- SOFTWARE.]] - -- assert.are.same(should, data) - -- end) - -- end) - - -- describe("readbyterange", function() - -- it_cross_plat("should read bytes at given offset", function() - -- local p = Path:new "LICENSE" - -- local data = p:readbyterange(13, 10) - -- local should = "Copyright " - -- assert.are.same(should, data) - -- end) - - -- it_cross_plat("supports negative offset", function() - -- local p = Path:new "LICENSE" - -- local data = p:readbyterange(-10, 10) - -- local should = "SOFTWARE.\n" - -- assert.are.same(should, data) - -- end) - -- end) -end) From 826613f86f8e8426ac8af9b771155ab59f8ca53e Mon Sep 17 00:00:00 2001 From: James Trew Date: Sat, 31 Aug 2024 10:20:00 -0400 Subject: [PATCH 19/43] add 'touch', 'rm', & read methods --- lua/plenary/path2.lua | 195 ++++++++++++++++++++++++++++++++--- tests/plenary/path2_spec.lua | 90 ++++++++++------ 2 files changed, 238 insertions(+), 47 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index adee895a..c20609fa 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -524,9 +524,9 @@ end --- Will throw error if path doesn't exist. ---@return uv.aliases.fs_stat_table function Path:stat() - local res, _, err_msg = uv.fs_stat(self:absolute()) + local res, err = uv.fs_stat(self:absolute()) if res == nil then - error(err_msg) + error(err) end return res end @@ -541,9 +541,9 @@ end --- Will throw error if path doesn't exist. ---@return uv.aliases.fs_stat_table function Path:lstat() - local res, _, err_msg = uv.fs_lstat(self:absolute()) + local res, err = uv.fs_lstat(self:absolute()) if res == nil then - error(err_msg) + error(err) end return res end @@ -797,7 +797,6 @@ end --- Create directory ---@param opts plenary.Path2.mkdirOpts? ----@return boolean success function Path:mkdir(opts) opts = opts or {} opts.mode = vim.F.if_nil(opts.mode, 511) @@ -812,10 +811,10 @@ function Path:mkdir(opts) local ok, err_msg, err_code = uv.fs_mkdir(abs_path, opts.mode) if ok then - return true + return end if err_code == "EEXIST" then - return true + return end if err_code == "ENOENT" then if not opts.parents or self.parent == self then @@ -823,7 +822,7 @@ function Path:mkdir(opts) end self:parent():mkdir { mode = opts.mode } uv.fs_mkdir(abs_path, opts.mode) - return true + return end error(err_msg) @@ -850,7 +849,6 @@ end --- 'touch' file. --- If it doesn't exist, creates it including optionally, the parent directories ---@param opts plenary.Path2.touchOpts? ----@return boolean success function Path:touch(opts) opts = opts or {} opts.mode = vim.F.if_nil(opts.mode, 438) @@ -861,29 +859,192 @@ function Path:touch(opts) if self:exists() then local new_time = os.time() uv.fs_utime(abs_path, new_time, new_time) - return true + return end if not not opts.parents then - local mode = type(opts.parents) == "number" and opts.parents ---@cast mode number? + local mode = type(opts.parents) == "number" and opts.parents or nil ---@cast mode number? _ = Path:new(self:parent()):mkdir { mode = mode, parents = true } end - local fd, _, err_msg = uv.fs_open(self:absolute(), "w", opts.mode) + local fd, err = uv.fs_open(self:absolute(), "w", opts.mode) if fd == nil then - error(err_msg) + error(err) end local ok - ok, _, err_msg = uv.fs_close(fd) + ok, err = uv.fs_close(fd) if not ok then - error(err_msg) + error(err) end - - return true end +---@class plenary.Path2.rmOpts +---@field recursive boolean? remove directories and their content recursively (defaul: `false`) + +--- rm file or optional recursively remove directories and their content recursively +---@param opts plenary.Path2.rmOpts? function Path:rm(opts) + opts = opts or {} + opts.recursive = vim.F.if_nil(opts.recursive, false) + + if not opts.recursive or not self:is_dir() then + local ok, err = uv.fs_unlink(self:absolute()) + if ok then + return + end + if self:is_dir() then + error(string.format("Cannnot rm director '%s'.", self:absolute())) + end + error(err) + end + + for p, dirs, files in self:walk(false) do + for _, file in ipairs(files) do + print("delete file", file, (p / file):absolute()) + local _, err = uv.fs_unlink((p / file):absolute()) + if err then + error(err) + end + end + + for _, dir in ipairs(dirs) do + print("delete dir", dir, (p / dir):absolute()) + local _, err = uv.fs_rmdir((p / dir):absolute()) + if err then + error(err) + end + end + end + + self:rmdir() +end + +--- read file synchronously or asynchronously +---@param callback fun(data: string)? callback to use for async version, nil for default +---@return string? data +function Path:read(callback) + if not self:is_file() then + error(string.format("'%s' is not a file", self:absolute())) + end + + if callback == nil then + return self:_read_sync() + end + return self:_read_async(callback) +end + +---@private +---@return string +function Path:_read_sync() + local fd, err = uv.fs_open(self:absolute(), "r", 438) + if fd == nil then + error(err) + end + + local stat = self:stat() + local data + data, err = uv.fs_read(fd, stat.size, 0) + if data == nil then + error(err) + end + return data +end + +---@private +---@param callback fun(data: string) callback to use for async version, nil for default +function Path:_read_async(callback) + uv.fs_open(self:absolute(), "r", 438, function(err_open, fd) + if err_open then + error(err_open) + end + + uv.fs_fstat(fd, function(err_stat, stat) + if err_stat or stat == nil then + error(err_stat) + end + + uv.fs_read(fd, stat.size, 0, function(err_read, data) + if err_read or data == nil then + error(err_read) + end + callback(data) + end) + end) + end) +end + +--- read lines of a file into a list +---@return string[] +function Path:readlines() + local data = assert(self:read()) + return vim.split(data, "\r?\n") +end + +--- get an iterator for lines text in a file +---@return fun(): string? +function Path:iter_lines() + local data = assert(self:read()) + return vim.gsplit(data, "\r?\n") +end + +---@param top_down boolean? walk from current path down (default: `true`) +---@return fun(): plenary.Path2?, string[]?, string[]? # iterator which yields (dirpath, dirnames, filenames) +function Path:walk(top_down) + top_down = vim.F.if_nil(top_down, true) + + local queue = { self } ---@type plenary.Path2[] + local curr_fs = nil ---@type uv_fs_t? + local curr_path = nil ---@type plenary.Path2 + + local rev_res = {} ---@type [plenary.Path2, string[], string[]] + + return function() + while #queue > 0 or curr_fs do + if curr_fs == nil then + local p = table.remove(queue, 1) + local fs, err = uv.fs_scandir(p:absolute()) + + if fs == nil then + error(err) + end + curr_path = p + curr_fs = fs + end + + if curr_fs then + local dirs = {} + local files = {} + while true do + local name, ty = uv.fs_scandir_next(curr_fs) + if name == nil then + curr_fs = nil + break + end + + if ty == "directory" then + table.insert(queue, Path:new { curr_path, name }) + table.insert(dirs, name) + else + table.insert(files, name) + end + end + + if top_down then + return curr_path, dirs, files + else + table.insert(rev_res, { curr_path, dirs, files }) + end + end + end + + if not top_down and #rev_res > 0 then + local res = table.remove(rev_res) + return res[1], res[2], res[3] + end + + return nil + end end return Path diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index f0fec4c5..499eae94 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -1,7 +1,8 @@ local Path = require "plenary.path2" local path = Path.path local compat = require "plenary.compat" -local iswin = vim.loop.os_uname().sysname == "Windows_NT" +local uv = vim.loop +local iswin = uv.os_uname().sysname == "Windows_NT" local hasshellslash = vim.fn.exists "+shellslash" == 1 @@ -411,6 +412,12 @@ describe("Path2", function() end describe("mkdir / rmdir", function() + after_each(function() + uv.fs_rmdir "_dir_not_exist" + uv.fs_rmdir "impossible/dir" + uv.fs_rmdir "impossible" + end) + it_cross_plat("can create and delete directories", function() local p = Path:new "_dir_not_exist" @@ -467,52 +474,75 @@ describe("Path2", function() end) describe("touch/rm", function() - it("can create and delete new files", function() + after_each(function() + uv.fs_unlink "test_file.lua" + uv.fs_unlink "nested/nested2/test_file.lua" + uv.fs_rmdir "nested/nested2" + uv.fs_unlink "nested/asdf/.hidden" + uv.fs_rmdir "nested/asdf" + uv.fs_unlink "nested/dir/.hidden" + uv.fs_rmdir "nested/dir" + uv.fs_rmdir "nested" + end) + + it_cross_plat("can create and delete new files", function() local p = Path:new "test_file.lua" - assert(pcall(p.touch, p)) - assert(p:exists()) + assert.not_error(function() + p:touch() + end) + assert.is_true(p:exists()) - p:rm() - assert(not p:exists()) + assert.not_error(function() + p:rm() + end) + assert.is_true(not p:exists()) end) - it("does not effect already created files but updates last access", function() + it_cross_plat("does not effect already created files but updates last access", function() local p = Path:new "README.md" - local last_atime = p:stat().atime.sec - local last_mtime = p:stat().mtime.sec - local lines = p:readlines() - assert(pcall(p.touch, p)) - print(p:stat().atime.sec > last_atime) - print(p:stat().mtime.sec > last_mtime) - assert(p:exists()) + assert.no_error(function() + p:touch() + end) + assert.is_true(p:exists()) assert.are.same(lines, p:readlines()) end) - it("does not create dirs if nested in none existing dirs and parents not set", function() + it_cross_plat("does not create dirs if nested in none existing dirs and parents not set", function() local p = Path:new { "nested", "nested2", "test_file.lua" } - assert(not pcall(p.touch, p, { parents = false })) - assert(not p:exists()) + assert.has_error(function() + p:touch { parents = false } + end) + assert.is_false(p:exists()) end) - it("does create dirs if nested in none existing dirs", function() + it_cross_plat("does create dirs if nested in none existing dirs", function() local p1 = Path:new { "nested", "nested2", "test_file.lua" } local p2 = Path:new { "nested", "asdf", ".hidden" } local d1 = Path:new { "nested", "dir", ".hidden" } - assert(pcall(p1.touch, p1, { parents = true })) - assert(pcall(p2.touch, p2, { parents = true })) - assert(pcall(d1.mkdir, d1, { parents = true })) - assert(p1:exists()) - assert(p2:exists()) - assert(d1:exists()) - - Path:new({ "nested" }):rm { recursive = true } - assert(not p1:exists()) - assert(not p2:exists()) - assert(not d1:exists()) - assert(not Path:new({ "nested" }):exists()) + + assert.no_error(function() + p1:touch { parents = true } + end) + assert.no_error(function() + p2:touch { parents = true } + end) + assert.no_error(function() + d1:mkdir { parents = true } + end) + assert.is_true(p1:exists()) + assert.is_true(p2:exists()) + assert.is_true(d1:exists()) + + assert.no_error(function() + Path:new({ "nested" }):rm { recursive = true } + end) + assert.is_false(p1:exists()) + assert.is_false(p2:exists()) + assert.is_false(d1:exists()) + assert.is_false(Path:new({ "nested" }):exists()) end) end) From 577995cb8db439c97e24e82c470ec8b888c49d94 Mon Sep 17 00:00:00 2001 From: James Trew Date: Sat, 31 Aug 2024 11:04:17 -0400 Subject: [PATCH 20/43] rename --- lua/plenary/path2.lua | 37 ++++++++++++++- tests/plenary/path2_spec.lua | 89 ++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index c20609fa..c070b9f4 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -48,6 +48,8 @@ --- `C:/Users/test/test_file` relative to `C:/Windows/temp` is --- `../../Users/test/test_file` and there's no home directory absolute path --- in this. +--- +--- - rename returns new path rather than mutating path local bit = require "plenary.bit" local uv = vim.loop @@ -346,6 +348,7 @@ end ---@field relparts string[] path separator separated relative path parts ---@field sep string path separator (respects 'shellslash' on Windows) ---@field filename string +---@field name string the final path component (eg. "foo/bar/baz.lua" -> "baz.lua") ---@field cwd string ---@field private _absolute string? lazy eval'ed fully resolved absolute path local Path = { path = path } @@ -363,6 +366,15 @@ Path.__index = function(t, k) return rawget(t, k) end + if k == "name" then + if #t.relparts > 0 then + t.name = t.relparts[#t.relparts] + else + t.name = "" + end + return t.name + end + if k == "anchor" then t.anchor = t.drv .. t.root return t.anchor @@ -901,7 +913,6 @@ function Path:rm(opts) for p, dirs, files in self:walk(false) do for _, file in ipairs(files) do - print("delete file", file, (p / file):absolute()) local _, err = uv.fs_unlink((p / file):absolute()) if err then error(err) @@ -909,7 +920,6 @@ function Path:rm(opts) end for _, dir in ipairs(dirs) do - print("delete dir", dir, (p / dir):absolute()) local _, err = uv.fs_rmdir((p / dir):absolute()) if err then error(err) @@ -920,6 +930,29 @@ function Path:rm(opts) self:rmdir() end +---@class plenary.Path2.renameOpts +---@field new_name string|plenary.Path2 destination path + +---@param opts plenary.Path2.renameOpts +---@return plenary.Path2 +function Path:rename(opts) + if not opts.new_name or opts.new_name == "" then + error "Please provide the new name!" + end + + local new_path = self:parent() / opts.new_name ---@type plenary.Path2 + + if new_path:exists() then + error "File or directory already exists!" + end + + local _, err = uv.fs_rename(self:absolute(), new_path:absolute()) + if err ~= nil then + error(err) + end + return new_path +end + --- read file synchronously or asynchronously ---@param callback fun(data: string)? callback to use for async version, nil for default ---@return string? data diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 499eae94..1ff3634a 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -546,6 +546,95 @@ describe("Path2", function() end) end) + describe("rename", function() + after_each(function() + uv.fs_unlink "a_random_filename.lua" + uv.fs_unlink "not_a_random_filename.lua" + uv.fs_unlink "some_random_filename.lua" + uv.fs_unlink "../some_random_filename.lua" + end) + + it_cross_plat("can rename a file", function() + local p = Path:new "a_random_filename.lua" + assert.no_error(function() + p:touch() + end) + assert.is_true(p:exists()) + + local new_p + assert.no_error(function() + new_p = p:rename { new_name = "not_a_random_filename.lua" } + end) + assert.not_nil(new_p) + assert.are.same("not_a_random_filename.lua", new_p.name) + end) + + it_cross_plat("can handle an invalid filename", function() + local p = Path:new "some_random_filename.lua" + assert.no_error(function() + p:touch() + end) + assert.is_true(p:exists()) + + assert.has_error(function() + p:rename { new_name = "" } + end) + assert.has_error(function() + ---@diagnostic disable-next-line: missing-fields + p:rename {} + end) + + assert.are.same("some_random_filename.lua", p.name) + end) + + it_cross_plat("can move to parent dir", function() + local p = Path:new "some_random_filename.lua" + assert.no_error(function() + p:touch() + end) + assert.is_true(p:exists()) + + local new_p + assert.no_error(function() + new_p = p:rename { new_name = "../some_random_filename.lua" } + end) + assert.not_nil(new_p) + assert.are.same(Path:new("../some_random_filename.lua"):absolute(), new_p:absolute()) + end) + + it_cross_plat("cannot rename to an existing filename", function() + local p1 = Path:new "a_random_filename.lua" + local p2 = Path:new "not_a_random_filename.lua" + assert.no_error(function() + p1:touch() + p2:touch() + end) + assert.is_true(p1:exists()) + assert.is_true(p2:exists()) + + assert.has_error(function() + p1:rename { new_name = "not_a_random_filename.lua" } + end) + assert.are.same(p1.filename, "a_random_filename.lua") + end) + + it_cross_plat("handles Path as new_name", function() + local p1 = Path:new "a_random_filename.lua" + local p2 = Path:new "not_a_random_filename.lua" + assert.no_error(function() + p1:touch() + end) + assert.is_true(p1:exists()) + + local new_p + assert.no_error(function() + new_p = p1:rename { new_name = p2 } + end) + assert.not_nil(new_p) + assert.are.same("not_a_random_filename.lua", new_p.name) + end) + end) + describe("parents", function() it_cross_plat("should extract the ancestors of the path", function() local p = Path:new(vim.fn.getcwd()) From 4e164d33b0200636264965ffc3ed0859d9e5a03b Mon Sep 17 00:00:00 2001 From: James Trew Date: Sat, 31 Aug 2024 20:54:55 -0400 Subject: [PATCH 21/43] implement `copy` --- lua/plenary/path2.lua | 78 ++++++++++++-- tests/plenary/path2_spec.lua | 197 +++++++++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+), 8 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index c070b9f4..897a08aa 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -49,7 +49,12 @@ --- `../../Users/test/test_file` and there's no home directory absolute path --- in this. --- ---- - rename returns new path rather than mutating path +--- - `rename` returns new path rather than mutating path +--- +--- - `copy` +--- - drops interactive mode +--- - return value table is pre-flattened +--- - return value table value is `{success: boolean, err: string?}` rather than just `boolean` local bit = require "plenary.bit" local uv = vim.loop @@ -876,7 +881,7 @@ function Path:touch(opts) if not not opts.parents then local mode = type(opts.parents) == "number" and opts.parents or nil ---@cast mode number? - _ = Path:new(self:parent()):mkdir { mode = mode, parents = true } + _ = Path:new(self:parent()):mkdir { mode = mode, parents = true, exists_ok = true } end local fd, err = uv.fs_open(self:absolute(), "w", opts.mode) @@ -901,8 +906,8 @@ function Path:rm(opts) opts.recursive = vim.F.if_nil(opts.recursive, false) if not opts.recursive or not self:is_dir() then - local ok, err = uv.fs_unlink(self:absolute()) - if ok then + local ok, err, code = uv.fs_unlink(self:absolute()) + if ok or code == "ENOENT" then return end if self:is_dir() then @@ -913,15 +918,15 @@ function Path:rm(opts) for p, dirs, files in self:walk(false) do for _, file in ipairs(files) do - local _, err = uv.fs_unlink((p / file):absolute()) - if err then + local _, err, code = uv.fs_unlink((p / file):absolute()) + if err and code ~= "ENOENT" then error(err) end end for _, dir in ipairs(dirs) do - local _, err = uv.fs_rmdir((p / dir):absolute()) - if err then + local _, err, code = uv.fs_rmdir((p / dir):absolute()) + if err and code ~= "ENOENT" then error(err) end end @@ -953,6 +958,63 @@ function Path:rename(opts) return new_path end +---@class plenary.Path2.copyOpts +---@field destination string|plenary.Path2 target file path to copy to +---@field recursive boolean? whether to copy folders recursively (default: `false`) +---@field override boolean? whether to override files (default: `true`) +---@field respect_gitignore boolean? skip folders ignored by all detected `gitignore`s (default: `false`) +---@field hidden boolean? whether to add hidden files in recursively copying folders (default: `true`) +---@field parents boolean? whether to create possibly non-existing parent dirs of `opts.destination` (default: `false`) +---@field exists_ok boolean? whether ok if `opts.destination` exists, if so folders are merged (default: `true`) + +---@param opts plenary.Path2.copyOpts +---@return {[plenary.Path2]: {success:boolean, err: string?}} # indicating success of copy; nested tables constitute sub dirs +function Path:copy(opts) + opts.recursive = vim.F.if_nil(opts.recursive, false) + opts.override = vim.F.if_nil(opts.override, true) + + local dest = self:parent() / opts.destination ---@type plenary.Path2 + + local success = {} ---@type {[plenary.Path2]: {success: boolean, err: string?}} + + if not self:is_dir() then + local ok, err = uv.fs_copyfile(self:absolute(), dest:absolute(), { excl = not opts.override }) + success[dest] = { success = ok or false, err = err } + return success + end + + if not opts.recursive then + error(string.format("Warning: %s was not copied as `recursive=false`", self:absolute())) + end + + opts.respect_gitignore = vim.F.if_nil(opts.respect_gitignore, false) + opts.hidden = vim.F.if_nil(opts.hidden, true) + opts.parents = vim.F.if_nil(opts.parents, false) + opts.exists_ok = vim.F.if_nil(opts.exists_ok, true) + + dest:mkdir { parents = opts.parents, exists_ok = opts.exists_ok } + + local scan = require "plenary.scandir" + local data = scan.scan_dir(self.filename, { + respect_gitignore = opts.respect_gitignore, + hidden = opts.hidden, + depth = 1, + add_dirs = true, + }) + + for _, entry in ipairs(data) do + local entry_path = Path:new(entry) + local new_dest = dest / entry_path.name + -- clear destination as it might be Path table otherwise failing w/ extend + opts.destination = nil + local new_opts = vim.tbl_deep_extend("force", opts, { destination = new_dest }) + -- nil: not overriden if `override = false` + local res = entry_path:copy(new_opts) + success = vim.tbl_deep_extend("force", success, res) + end + return success +end + --- read file synchronously or asynchronously ---@param callback fun(data: string)? callback to use for async version, nil for default ---@return string? data diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 1ff3634a..d32e5ffd 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -635,6 +635,203 @@ describe("Path2", function() end) end) + describe("copy", function() + after_each(function() + uv.fs_unlink "a_random_filename.rs" + uv.fs_unlink "not_a_random_filename.rs" + uv.fs_unlink "some_random_filename.rs" + uv.fs_unlink "../some_random_filename.rs" + Path:new("src"):rm { recursive = true } + Path:new("trg"):rm { recursive = true } + end) + + it_cross_plat("can copy a file with string destination", function() + local p1 = Path:new "a_random_filename.rs" + local p2 = Path:new "not_a_random_filename.rs" + p1:touch() + assert.is_true(p1:exists()) + + assert.no_error(function() + p1:copy { destination = "not_a_random_filename.rs" } + end) + assert.is_true(p1:exists()) + assert.are.same(p1.filename, "a_random_filename.rs") + assert.are.same(p2.filename, "not_a_random_filename.rs") + end) + + it_cross_plat("can copy a file with Path destination", function() + local p1 = Path:new "a_random_filename.rs" + local p2 = Path:new "not_a_random_filename.rs" + p1:touch() + assert.is_true(p1:exists()) + + assert.no_error(function() + p1:copy { destination = p2 } + end) + assert.is_true(p1:exists()) + assert.is_true(p2:exists()) + assert.are.same(p1.filename, "a_random_filename.rs") + assert.are.same(p2.filename, "not_a_random_filename.rs") + end) + + it_cross_plat("can copy to parent dir", function() + local p = Path:new "some_random_filename.rs" + p:touch() + assert.is_true(p:exists()) + + assert.no_error(function() + p:copy { destination = "../some_random_filename.rs" } + end) + assert.is_true(p:exists()) + end) + + it_cross_plat("cannot copy an existing file if override false", function() + local p1 = Path:new "a_random_filename.rs" + local p2 = Path:new "not_a_random_filename.rs" + p1:touch() + p2:touch() + assert.is_true(p1:exists()) + assert.is_true(p2:exists()) + + assert(pcall(p1.copy, p1, { destination = "not_a_random_filename.rs", override = false })) + assert.no_error(function() + p1:copy { destination = "not_a_random_filename.rs", override = false } + end) + assert.are.same(p1.filename, "a_random_filename.rs") + assert.are.same(p2.filename, "not_a_random_filename.rs") + end) + + it_cross_plat("fails when copying folders non-recursively", function() + local src_dir = Path:new "src" + src_dir:mkdir() + src_dir:joinpath("file1.lua"):touch() + + local trg_dir = Path:new "trg" + assert.has_error(function() + src_dir:copy { destination = trg_dir, recursive = false } + end) + end) + + describe("can copy directories recursively", function() + local src_dir = Path:new "src" + local trg_dir = Path:new "trg" + + local files = { "file1", "file2", ".file3" } + -- set up sub directory paths for creation and testing + local sub_dirs = { "sub_dir1", "sub_dir1/sub_dir2" } + local src_dirs = { src_dir } + local trg_dirs = { trg_dir } + -- {src, trg}_dirs is a table with all directory levels by {src, trg} + for _, dir in ipairs(sub_dirs) do + table.insert(src_dirs, src_dir:joinpath(dir)) + table.insert(trg_dirs, trg_dir:joinpath(dir)) + end + + -- vim.tbl_flatten doesn't work here as copy doesn't return a list + local function flatten(ret, t) + for _, v in pairs(t) do + if type(v) == "table" then + flatten(ret, v) + else + table.insert(ret, v) + end + end + end + + before_each(function() + -- generate {file}_{level}.lua on every directory level in src + -- src + -- ├── file1_1.lua + -- ├── file2_1.lua + -- ├── .file3_1.lua + -- └── sub_dir1 + -- ├── file1_2.lua + -- ├── file2_2.lua + -- ├── .file3_2.lua + -- └── sub_dir2 + -- ├── file1_3.lua + -- ├── file2_3.lua + -- └── .file3_3.lua + + src_dir:mkdir() + + for _, file in ipairs(files) do + for level, dir in ipairs(src_dirs) do + local p = dir:joinpath(file .. "_" .. level .. ".lua") + p:touch { parents = true, exists_ok = true } + assert.is_true(p:exists()) + end + end + end) + + it_cross_plat("hidden=true, override=true", function() + local success + assert.no_error(function() + success = src_dir:copy { destination = trg_dir, recursive = true, override = true, hidden = true } + end) + + assert.not_nil(success) + assert.are.same(9, vim.tbl_count(success)) + for _, res in pairs(success) do + assert.is_true(res.success) + end + end) + + it_cross_plat("hidden=true, override=false", function() + -- setup + assert.no_error(function() + src_dir:copy { destination = trg_dir, recursive = true, override = true, hidden = true } + end) + + local success + assert.no_error(function() + success = src_dir:copy { destination = trg_dir, recursive = true, override = false, hidden = true } + end) + + assert.not_nil(success) + assert.are.same(9, vim.tbl_count(success)) + for _, res in pairs(success) do + assert.is_false(res.success) + assert.not_nil(res.err) + assert.not_nil(res.err:match "^EEXIST:") + end + end) + + it_cross_plat("hidden=false, override=true", function() + local success + assert.no_error(function() + success = src_dir:copy { destination = trg_dir, recursive = true, override = true, hidden = false } + end) + + assert.not_nil(success) + assert.are.same(6, vim.tbl_count(success)) + for _, res in pairs(success) do + assert.is_true(res.success) + end + end) + + it_cross_plat("hidden=false, override=false", function() + -- setup + assert.no_error(function() + src_dir:copy { destination = trg_dir, recursive = true, override = true, hidden = true } + end) + + local success + assert.no_error(function() + success = src_dir:copy { destination = trg_dir, recursive = true, override = false, hidden = false } + end) + + assert.not_nil(success) + assert.are.same(6, vim.tbl_count(success)) + for _, res in pairs(success) do + assert.is_false(res.success) + assert.not_nil(res.err) + assert.not_nil(res.err:match "^EEXIST:") + end + end) + end) + end) + describe("parents", function() it_cross_plat("should extract the ancestors of the path", function() local p = Path:new(vim.fn.getcwd()) From 72c8734edee246718b475248155da0a95d8ad17e Mon Sep 17 00:00:00 2001 From: James Trew Date: Sun, 1 Sep 2024 00:42:16 -0400 Subject: [PATCH 22/43] head and tail --- lua/plenary/path2.lua | 122 ++++++++++++++++++++++++++++++++++- tests/plenary/path2_spec.lua | 107 +++++++++++++++++++++++++++++- 2 files changed, 226 insertions(+), 3 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 897a08aa..54bab9e0 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -8,7 +8,8 @@ --- - `Path.new` no longer supported (think it's more confusing that helpful --- and not really used as far as I can tell) --- ---- - drop `__concat` metamethod? it was untested, not sure how functional it is +--- - drop `__concat` metamethod? it was untested and had some todo comment, +--- not sure how functional it is --- --- - `Path` objects are now "read-only", I don't think people were ever doing --- things like `path.filename = 'foo'` but now explicitly adding some barrier @@ -27,6 +28,9 @@ --- eg. `Path:new("foo/bar_baz"):make_relative("foo/bar", true)` => returns --- "../bar_baz" --- +--- - error handling is generally more loud, ie. emit errors from libuv rather +--- than swallowing it +--- --- - remove `Path:normalize`. It doesn't make any sense. eg. this test case --- ```lua --- it("can normalize ~ when file is within home directory (trailing slash)", function() @@ -55,6 +59,9 @@ --- - drops interactive mode --- - return value table is pre-flattened --- - return value table value is `{success: boolean, err: string?}` rather than just `boolean` +--- +--- - drops `check_self` mechanism (ie. doing `Path.read("some/file/path")`) +--- seems unnecessary... just do `Path:new("some/file/path"):read()` local bit = require "plenary.bit" local uv = vim.loop @@ -1029,15 +1036,26 @@ function Path:read(callback) return self:_read_async(callback) end +---@private +---@return uv.aliases.fs_stat_table +function Path:_get_readable_stat() + local stat = self:stat() + if stat.type ~= "file" then + error(string.format("Cannot read non-file '%s'.", self:absolute())) + end + return stat +end + ---@private ---@return string function Path:_read_sync() + local stat = self:_get_readable_stat() + local fd, err = uv.fs_open(self:absolute(), "r", 438) if fd == nil then error(err) end - local stat = self:stat() local data data, err = uv.fs_read(fd, stat.size, 0) if data == nil then @@ -1083,6 +1101,106 @@ function Path:iter_lines() return vim.gsplit(data, "\r?\n") end +--- read the first few lines of a file +---@param lines integer? number of lines to read from the head of the file (default: `10`) +---@return string data +function Path:head(lines) + local stat = self:_get_readable_stat() + + lines = vim.F.if_nil(lines, 10) + local chunk_size = 256 + + local fd, err = uv.fs_open(self:absolute(), "r", 438) + if fd == nil then + error(err) + end + + local data = {} + local read_chunk ---@type string? + local index, count = 0, 0 + while count < lines and index < stat.size do + read_chunk, err = uv.fs_read(fd, chunk_size, index) + if read_chunk == nil then + error(err) + end + + local i = 1 + while i <= #read_chunk do + local ch = read_chunk:byte(i) + if ch == 10 then -- `\n` + count = count + 1 + if count >= lines then + break + end + end + index = index + 1 + i = i + 1 + end + + table.insert(data, read_chunk:sub(1, i)) + end + + _, err = uv.fs_close(fd) + if err ~= nil then + error(err) + end + + return (table.concat(data):gsub("\n$", "")) +end + +--- read the last few lines of a file +---@param lines integer? number of lines to read from the tail of the file (default: `10`) +---@return string data +function Path:tail(lines) + local stat = self:_get_readable_stat() + + lines = vim.F.if_nil(lines, 10) + local chunk_size = 256 + + local fd, err = uv.fs_open(self:absolute(), "r", 438) + if fd == nil then + error(err) + end + + local data = {} + local read_chunk ---@type string? + local index, count = stat.size - 1, 0 + while count < lines and index > 0 do + local real_index = index - chunk_size + if real_index < 0 then + chunk_size = chunk_size + real_index + real_index = 0 + end + + read_chunk, err = uv.fs_read(fd, chunk_size, real_index) + if read_chunk == nil then + error(err) + end + + local i = #read_chunk + while i > 0 do + local ch = read_chunk:byte(i) + if ch == 10 then -- `\n` + count = count + 1 + if count >= lines then + break + end + end + index = index - 1 + i = i - 1 + end + + table.insert(data, 1, read_chunk:sub(i + 1, #read_chunk)) + end + + _, err = uv.fs_close(fd) + if err ~= nil then + error(err) + end + + return (table.concat(data):gsub("\n$", "")) +end + ---@param top_down boolean? walk from current path down (default: `true`) ---@return fun(): plenary.Path2?, string[]?, string[]? # iterator which yields (dirpath, dirnames, filenames) function Path:walk(top_down) diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index d32e5ffd..5d4487dc 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -845,7 +845,112 @@ describe("Path2", function() it_cross_plat("should return itself if it corresponds to path.root", function() local p = Path:new(Path.path.root(vim.fn.getcwd())) assert.are.same(p:absolute(), p:parent():absolute()) - -- assert.are.same(p, p:parent()) + assert.are.same(p, p:parent()) + end) + end) + + describe("head", function() + it_cross_plat("should read head of file", function() + local p = Path:new "LICENSE" + local data = p:head() + local should = [[MIT License + +Copyright (c) 2020 TJ DeVries + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions:]] + + assert.are.same(should, data) + end) + + it_cross_plat("should read the first line of file", function() + local p = Path:new "LICENSE" + local data = p:head(1) + local should = [[MIT License]] + assert.are.same(should, data) + end) + + it_cross_plat("head should max read whole file", function() + local p = Path:new "LICENSE" + local data = p:head(1000) + local should = [[MIT License + +Copyright (c) 2020 TJ DeVries + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.]] + assert.are.same(should, data) + end) + end) + + describe("tail", function() + it_cross_plat("should read tail of file", function() + local p = Path:new "LICENSE" + local data = p:tail() + local should = [[The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.]] + assert.are.same(should, data) + end) + + it_cross_plat("should read the last line of file", function() + local p = Path:new "LICENSE" + local data = p:tail(1) + local should = [[SOFTWARE.]] + assert.are.same(should, data) + end) + + it_cross_plat("tail should max read whole file", function() + local p = Path:new "LICENSE" + local data = p:tail(1000) + local should = [[MIT License + +Copyright (c) 2020 TJ DeVries + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.]] + assert.are.same(should, data) end) end) end) From f3c01695203e6e754b92244028039a220992db06 Mon Sep 17 00:00:00 2001 From: James Trew Date: Sun, 1 Sep 2024 10:51:54 -0400 Subject: [PATCH 23/43] add `write` and fix read file closing --- lua/plenary/path2.lua | 62 ++++++++++++++++++++++++++++++++++-- tests/plenary/path2_spec.lua | 46 ++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 54bab9e0..6432130b 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -888,7 +888,7 @@ function Path:touch(opts) if not not opts.parents then local mode = type(opts.parents) == "number" and opts.parents or nil ---@cast mode number? - _ = Path:new(self:parent()):mkdir { mode = mode, parents = true, exists_ok = true } + self:parent():mkdir { mode = mode, parents = true, exists_ok = true } end local fd, err = uv.fs_open(self:absolute(), "w", opts.mode) @@ -1061,11 +1061,16 @@ function Path:_read_sync() if data == nil then error(err) end + + _, err = uv.fs_close(fd) + if err ~= nil then + error(err) + end return data end ---@private ----@param callback fun(data: string) callback to use for async version, nil for default +---@param callback fun(data: string) function Path:_read_async(callback) uv.fs_open(self:absolute(), "r", 438, function(err_open, fd) if err_open then @@ -1081,7 +1086,12 @@ function Path:_read_async(callback) if err_read or data == nil then error(err_read) end - callback(data) + uv.fs_close(fd, function(err_close) + if err_close then + error(err_close) + end + callback(data) + end) end) end) end) @@ -1201,6 +1211,38 @@ function Path:tail(lines) return (table.concat(data):gsub("\n$", "")) end +--- write to file +---@param data string|string[] data to write +---@param flags uv.aliases.fs_access_flags|integer flag used to open file (eg. "w" or "a") +---@param mode integer? mode used to open file (default: `438`) +---@return number # bytes written +function Path:write(data, flags, mode) + vim.validate { + txt = { data, { "s", "t" } }, + flags = { flags, { "s", "n" } }, + mode = { mode, "n", true }, + } + + mode = vim.F.if_nil(mode, 438) + local fd, err = uv.fs_open(self:absolute(), flags, mode) + if fd == nil then + error(err) + end + + local b + b, err = uv.fs_write(fd, data, -1) + if b == nil then + error(err) + end + + _, err = uv.fs_close(fd) + if err ~= nil then + error(err) + end + + return b +end + ---@param top_down boolean? walk from current path down (default: `true`) ---@return fun(): plenary.Path2?, string[]?, string[]? # iterator which yields (dirpath, dirnames, filenames) function Path:walk(top_down) @@ -1260,4 +1302,18 @@ function Path:walk(top_down) end end +--[[ +Fail || Path2 write write string - windows (noshellslash) + ./lua/plenary/path2.lua:896: EPERM: operation not permitted: C:\Users\jtrew\neovim\plenary.nvim\foobar + + stack traceback: + ...s/jtrew/neovim/plenary.nvim/tests/plenary/path2_spec.lua:859: in function <...s/jtrew/neovim/plenary.nvim/tests/plenary/path2_spec.lua:857> + +]] + +vim.o.shellslash = false +local p = Path:new "foobar" +p:touch() +vim.o.shellslash = true + return Path diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 5d4487dc..b3c27c71 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -849,6 +849,52 @@ describe("Path2", function() end) end) + describe("write", function() + after_each(function() + uv.fs_unlink "foobar.txt" + end) + + it_cross_plat("can write string", function() + local p = Path:new "foobar.txt" + p:touch() + assert.no_error(function() + p:write("hello world", "w") + end) + + local content = p:read() + assert.not_nil(content) + assert.are.same("hello world", content) + end) + + it_cross_plat("can append string", function() + local p = Path:new "foobar.txt" + p:touch() + assert.no_error(function() + p:write("hello world", "w") + end) + + assert.no_error(function() + p:write("\ngoodbye", "a") + end) + + local content = p:read() + assert.not_nil(content) + assert.are.same("hello world\ngoodbye", content) + end) + + it_cross_plat("can write string list", function() + local p = Path:new "foobar.txt" + p:touch() + assert.no_error(function() + p:write({ "hello", " ", "world" }, "w") + end) + + local content = p:read() + assert.not_nil(content) + assert.are.same("hello world", content) + end) + end) + describe("head", function() it_cross_plat("should read head of file", function() local p = Path:new "LICENSE" From 21afd791a0e2a0fda1dc90f1ef34cf3f11d7cca4 Mon Sep 17 00:00:00 2001 From: James Trew Date: Sun, 1 Sep 2024 14:49:47 -0400 Subject: [PATCH 24/43] improve head/tail line endings compat --- lua/plenary/path2.lua | 56 ++++++++++++---------- tests/plenary/path2_spec.lua | 90 +++++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 26 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 6432130b..bdb524b8 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -62,6 +62,8 @@ --- --- - drops `check_self` mechanism (ie. doing `Path.read("some/file/path")`) --- seems unnecessary... just do `Path:new("some/file/path"):read()` +--- +--- - renamed `iter` into `iter_lines` for more clarity local bit = require "plenary.bit" local uv = vim.loop @@ -1115,6 +1117,10 @@ end ---@param lines integer? number of lines to read from the head of the file (default: `10`) ---@return string data function Path:head(lines) + vim.validate { + lines = { lines, "n", true }, + } + local stat = self:_get_readable_stat() lines = vim.F.if_nil(lines, 10) @@ -1138,11 +1144,17 @@ function Path:head(lines) while i <= #read_chunk do local ch = read_chunk:byte(i) if ch == 10 then -- `\n` - count = count + 1 - if count >= lines then - break + if read_chunk:byte(i - 1) ~= 13 then + count = count + 1 end + elseif ch == 13 then + count = count + 1 end + + if count >= lines then + break + end + index = index + 1 i = i + 1 end @@ -1155,13 +1167,17 @@ function Path:head(lines) error(err) end - return (table.concat(data):gsub("\n$", "")) + return (table.concat(data):gsub("[\r\n]$", "")) end --- read the last few lines of a file ---@param lines integer? number of lines to read from the tail of the file (default: `10`) ---@return string data function Path:tail(lines) + vim.validate { + lines = { lines, "n", true }, + } + local stat = self:_get_readable_stat() lines = vim.F.if_nil(lines, 10) @@ -1174,7 +1190,7 @@ function Path:tail(lines) local data = {} local read_chunk ---@type string? - local index, count = stat.size - 1, 0 + local index, count = stat.size, -1 while count < lines and index > 0 do local real_index = index - chunk_size if real_index < 0 then @@ -1190,12 +1206,18 @@ function Path:tail(lines) local i = #read_chunk while i > 0 do local ch = read_chunk:byte(i) - if ch == 10 then -- `\n` - count = count + 1 - if count >= lines then - break + if ch == 13 then + if read_chunk:byte(i + 1) ~= 10 then + count = count + 1 end + elseif ch == 10 then + count = count + 1 + end + + if count >= lines then + break end + index = index - 1 i = i - 1 end @@ -1208,7 +1230,7 @@ function Path:tail(lines) error(err) end - return (table.concat(data):gsub("\n$", "")) + return (table.concat(data):gsub("[\r\n]$", "")) end --- write to file @@ -1302,18 +1324,4 @@ function Path:walk(top_down) end end ---[[ -Fail || Path2 write write string - windows (noshellslash) - ./lua/plenary/path2.lua:896: EPERM: operation not permitted: C:\Users\jtrew\neovim\plenary.nvim\foobar - - stack traceback: - ...s/jtrew/neovim/plenary.nvim/tests/plenary/path2_spec.lua:859: in function <...s/jtrew/neovim/plenary.nvim/tests/plenary/path2_spec.lua:857> - -]] - -vim.o.shellslash = false -local p = Path:new "foobar" -p:touch() -vim.o.shellslash = true - return Path diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index b3c27c71..7c32a159 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -895,7 +895,18 @@ describe("Path2", function() end) end) + local function diff_str(a, b) + a = a:gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t") + b = b:gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t") + ---@diagnostic disable-next-line: missing-fields + return vim.diff(a, b, {}) + end + describe("head", function() + after_each(function() + uv.fs_unlink "foobar.txt" + end) + it_cross_plat("should read head of file", function() local p = Path:new "LICENSE" local data = p:head() @@ -920,7 +931,7 @@ furnished to do so, subject to the following conditions:]] assert.are.same(should, data) end) - it_cross_plat("head should max read whole file", function() + it_cross_plat("should max read whole file", function() local p = Path:new "LICENSE" local data = p:head(1000) local should = [[MIT License @@ -946,9 +957,43 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.]] assert.are.same(should, data) end) + + it_cross_plat("handles unix lf line endings", function() + local p = Path:new "foobar.txt" + p:touch() + + local txt = "foo\nbar\nbaz" + p:write(txt, "w") + local data = p:head() + assert.are.same(txt, data, diff_str(txt, data)) + end) + + it_cross_plat("handles windows crlf line endings", function() + local p = Path:new "foobar.txt" + p:touch() + + local txt = "foo\r\nbar\r\nbaz" + p:write(txt, "w") + local data = p:head() + assert.are.same(txt, data, diff_str(txt, data)) + end) + + it_cross_plat("handles mac cr line endings", function() + local p = Path:new "foobar.txt" + p:touch() + + local txt = "foo\rbar\rbaz" + p:write(txt, "w") + local data = p:head() + assert.are.same(txt, data, diff_str(txt, data)) + end) end) describe("tail", function() + after_each(function() + uv.fs_unlink "foobar.txt" + end) + it_cross_plat("should read tail of file", function() local p = Path:new "LICENSE" local data = p:tail() @@ -972,7 +1017,7 @@ SOFTWARE.]] assert.are.same(should, data) end) - it_cross_plat("tail should max read whole file", function() + it_cross_plat("should max read whole file", function() local p = Path:new "LICENSE" local data = p:tail(1000) local should = [[MIT License @@ -998,5 +1043,46 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.]] assert.are.same(should, data) end) + + it_cross_plat("handles unix lf line endings", function() + local p = Path:new "foobar.txt" + p:touch() + + local txt = "foo\nbar\nbaz" + p:write(txt, "w") + local data = p:tail() + assert.are.same(txt, data, diff_str(txt, data)) + end) + + it_cross_plat("handles windows crlf line endings", function() + local p = Path:new "foobar.txt" + p:touch() + + local txt = "foo\r\nbar\r\nbaz" + p:write(txt, "w") + local data = p:tail() + assert.are.same(txt, data, diff_str(txt, data)) + end) + + it_cross_plat("handles mac cr line endings", function() + local p = Path:new "foobar.txt" + p:touch() + + local txt = "foo\rbar\rbaz" + p:write(txt, "w") + local data = p:tail() + assert.are.same(txt, data, diff_str(txt, data)) + end) + + it_cross_plat("handles extra newlines", function() + local p = Path:new "foobar.txt" + p:touch() + + local txt = "foo\nbar\nbaz\n\n\n" + local expect = "foo\nbar\nbaz\n\n" + p:write(txt, "w") + local data = p:tail() + assert.are.same(expect, data, diff_str(expect, data)) + end) end) end) From 25b3f636859c2c79f076c90846ae9f99f0dd2814 Mon Sep 17 00:00:00 2001 From: James Trew Date: Sun, 1 Sep 2024 15:23:22 -0400 Subject: [PATCH 25/43] add param validation --- lua/plenary/path2.lua | 62 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index bdb524b8..2f419a3d 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -537,6 +537,19 @@ function Path.is_path(x) return getmetatable(x) == Path end +---@param x any +---@return boolean +local function is_path_like(x) + return type(x) == "string" or Path.is_path(x) +end + +local function is_path_like_opt(x) + if x == nil then + return true + end + return is_path_like(x) +end + ---@return boolean function Path:is_absolute() if self.root == "" then @@ -710,6 +723,8 @@ end ---@param to plenary.Path2|string path to compare to ---@return boolean function Path:is_relative(to) + vim.validate { to = { to, is_path_like } } + if not Path.is_path(to) then to = Path:new(to) end @@ -739,6 +754,11 @@ end ---@param walk_up boolean? walk up to the provided path using '..' (default: `false`) ---@return string function Path:make_relative(to, walk_up) + vim.validate { + to = { to, is_path_like_opt }, + walk_up = { walk_up, "b", true }, + } + -- NOTE: could probably take some shortcuts and avoid some `Path:new` calls -- by allowing _WindowsPath/_PosixPath handle this individually. -- As always, Windows root complicates things, so generating a new Path often @@ -800,6 +820,11 @@ end ---@param excludes integer[]? ---@return string function Path:shorten(len, excludes) + vim.validate { + len = { len, "n", true }, + excludes = { excludes, "t", true }, + } + len = vim.F.if_nil(len, 1) excludes = vim.F.if_nil(excludes, { #self.relparts }) @@ -825,6 +850,12 @@ end ---@param opts plenary.Path2.mkdirOpts? function Path:mkdir(opts) opts = opts or {} + vim.validate { + mode = { opts.mode, "n", true }, + parents = { opts.parents, "b", true }, + exists_ok = { opts.exists_ok, "b", true }, + } + opts.mode = vim.F.if_nil(opts.mode, 511) opts.parents = vim.F.if_nil(opts.parents, false) opts.exists_ok = vim.F.if_nil(opts.exists_ok, false) @@ -877,6 +908,10 @@ end ---@param opts plenary.Path2.touchOpts? function Path:touch(opts) opts = opts or {} + vim.validate { + mode = { opts.mode, "n", true }, + parents = { opts.parents, { "n", "b" }, true }, + } opts.mode = vim.F.if_nil(opts.mode, 438) opts.parents = vim.F.if_nil(opts.parents, false) @@ -912,6 +947,7 @@ end ---@param opts plenary.Path2.rmOpts? function Path:rm(opts) opts = opts or {} + vim.validate { recursive = { opts.recursive, "b", true } } opts.recursive = vim.F.if_nil(opts.recursive, false) if not opts.recursive or not self:is_dir() then @@ -950,6 +986,8 @@ end ---@param opts plenary.Path2.renameOpts ---@return plenary.Path2 function Path:rename(opts) + vim.validate { new_name = { opts.new_name, is_path_like } } + if not opts.new_name or opts.new_name == "" then error "Please provide the new name!" end @@ -979,6 +1017,12 @@ end ---@param opts plenary.Path2.copyOpts ---@return {[plenary.Path2]: {success:boolean, err: string?}} # indicating success of copy; nested tables constitute sub dirs function Path:copy(opts) + vim.validate { + destination = { opts.destination, is_path_like }, + recursive = { opts.recursive, "b", true }, + override = { opts.override, "b", true }, + } + opts.recursive = vim.F.if_nil(opts.recursive, false) opts.override = vim.F.if_nil(opts.override, true) @@ -996,6 +1040,13 @@ function Path:copy(opts) error(string.format("Warning: %s was not copied as `recursive=false`", self:absolute())) end + vim.validate { + respect_gitignore = { opts.respect_gitignore, "b", true }, + hidden = { opts.hidden, "b", true }, + parents = { opts.parents, "b", true }, + exists_ok = { opts.exists_ok, "b", true }, + } + opts.respect_gitignore = vim.F.if_nil(opts.respect_gitignore, false) opts.hidden = vim.F.if_nil(opts.hidden, true) opts.parents = vim.F.if_nil(opts.parents, false) @@ -1028,6 +1079,8 @@ end ---@param callback fun(data: string)? callback to use for async version, nil for default ---@return string? data function Path:read(callback) + vim.validate { callback = { callback, "f", true } } + if not self:is_file() then error(string.format("'%s' is not a file", self:absolute())) end @@ -1117,9 +1170,7 @@ end ---@param lines integer? number of lines to read from the head of the file (default: `10`) ---@return string data function Path:head(lines) - vim.validate { - lines = { lines, "n", true }, - } + vim.validate { lines = { lines, "n", true } } local stat = self:_get_readable_stat() @@ -1174,9 +1225,7 @@ end ---@param lines integer? number of lines to read from the tail of the file (default: `10`) ---@return string data function Path:tail(lines) - vim.validate { - lines = { lines, "n", true }, - } + vim.validate { lines = { lines, "n", true } } local stat = self:_get_readable_stat() @@ -1265,6 +1314,7 @@ function Path:write(data, flags, mode) return b end +--- iterate over contents in the current path recursive ---@param top_down boolean? walk from current path down (default: `true`) ---@return fun(): plenary.Path2?, string[]?, string[]? # iterator which yields (dirpath, dirnames, filenames) function Path:walk(top_down) From 1d9bafc62e155bf3398158481768ec407e5e335b Mon Sep 17 00:00:00 2001 From: James Trew Date: Sun, 1 Sep 2024 15:29:03 -0400 Subject: [PATCH 26/43] add `readbyterange` --- lua/plenary/path2.lua | 47 ++++++++++++++++++++++++++++++++++++ tests/plenary/path2_spec.lua | 16 ++++++++++++ 2 files changed, 63 insertions(+) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 2f419a3d..fb43a3d8 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -1282,6 +1282,53 @@ function Path:tail(lines) return (table.concat(data):gsub("[\r\n]$", "")) end +---@param offset integer +---@param length integer +---@return string +function Path:readbyterange(offset, length) + vim.validate { + offset = { offset, "n" }, + length = { length, "n" }, + } + + local stat = self:_get_readable_stat() + local fd, err = uv.fs_open(self:absolute(), "r", 438) + if fd == nil then + error(err) + end + + if offset < 0 then + offset = stat.size + offset + -- Windows fails if offset is < 0 even though offset is defined as signed + -- http://docs.libuv.org/en/v1.x/fs.html#c.uv_fs_read + if offset < 0 then + offset = 0 + end + end + + local data = "" + local read_chunk + while #data < length do + -- local read_chunk = assert(uv.fs_read(fd, length - #data, offset)) + read_chunk, err = uv.fs_read(fd, length - #data, offset) + if read_chunk == nil then + error(err) + end + if #read_chunk == 0 then + break + end + data = data .. read_chunk + offset = offset + #read_chunk + end + + _, err = uv.fs_close(fd) + if err ~= nil then + error(err) + end + + return data +end + --- write to file ---@param data string|string[] data to write ---@param flags uv.aliases.fs_access_flags|integer flag used to open file (eg. "w" or "a") diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 7c32a159..9c741906 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -1085,4 +1085,20 @@ SOFTWARE.]] assert.are.same(expect, data, diff_str(expect, data)) end) end) + + describe("readbyterange", function() + it_cross_plat("should read bytes at given offset", function() + local p = Path:new "LICENSE" + local data = p:readbyterange(13, 10) + local should = "Copyright " + assert.are.same(should, data) + end) + + it_cross_plat("supports negative offset", function() + local p = Path:new "LICENSE" + local data = p:readbyterange(-10, 10) + local should = "SOFTWARE.\n" + assert.are.same(should, data) + end) + end) end) From a76ee04bc1db0202f2bd2d0a6cd901f350eeecf9 Mon Sep 17 00:00:00 2001 From: James Trew Date: Sun, 1 Sep 2024 20:25:14 -0400 Subject: [PATCH 27/43] add `find_upwards` --- lua/plenary/path2.lua | 26 ++++++++++++++++++++++++++ tests/plenary/path2_spec.lua | 22 ++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index fb43a3d8..648f2122 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -64,6 +64,8 @@ --- seems unnecessary... just do `Path:new("some/file/path"):read()` --- --- - renamed `iter` into `iter_lines` for more clarity +--- +--- - `find_upwards` returns `nil` if file not found rather than an empty string local bit = require "plenary.bit" local uv = vim.loop @@ -1421,4 +1423,28 @@ function Path:walk(top_down) end end +--- Search for a filename up from the current path, including the current +--- directory, searching up to the root directory. +--- Returns the `Path` of the first item found. +--- Returns `nil` if filename is not found. +---@param filename string +---@return plenary.Path2? +function Path:find_upwards(filename) + vim.validate { filename = { filename, "s" } } + + if self:is_dir() then + local target = self / filename + if target:exists() then + return target + end + end + + for parent in self:iter_parents() do + local target = Path:new { parent, filename } + if target:exists() then + return target + end + end +end + return Path diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 9c741906..a1e9bdeb 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -1101,4 +1101,26 @@ SOFTWARE.]] assert.are.same(should, data) end) end) + + describe("find_upwards", function() + it_cross_plat("finds file in current dir", function() + local p = Path:new "lua/plenary" + local res = assert(p:find_upwards "busted.lua") + local expect = Path:new "lua/plenary/busted.lua" + assert.are.same(expect, res) + end) + + it_cross_plat("finds file in parent dir", function() + local p = Path:new "lua/plenary" + local res = assert(p:find_upwards "say.lua") + local expect = Path:new "lua/say.lua" + assert.are.same(expect, res) + end) + + it_cross_plat("doesn't find file", function() + local p = Path:new "." + local res = p:find_upwards "aisohtenaishoetnaishoetnasihoetnashitoen" + assert.is_nil(res) + end) + end) end) From 7b2f24fd877915490bc0cf9f3194aeb90b9d255c Mon Sep 17 00:00:00 2001 From: James Trew Date: Sun, 1 Sep 2024 22:31:26 -0400 Subject: [PATCH 28/43] add `expand` --- lua/plenary/path2.lua | 84 ++++++++++++++++++++++++++++++++++++ tests/plenary/path2_spec.lua | 76 +++++++++++++++++++++++++++----- 2 files changed, 149 insertions(+), 11 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 648f2122..53b3440a 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -67,6 +67,14 @@ --- --- - `find_upwards` returns `nil` if file not found rather than an empty string + +-- TODO: could probably do with more `make_relative` tests +-- - walk up close to root +-- - add "walk_up" in test name +-- TODO: shorten: i think `vim.list_contains` is not nvim-0.7 compat (maybe use like a set?) +-- TODO: verify unix tests pass +-- TODO: add windows test for path2_spec only? + local bit = require "plenary.bit" local uv = vim.loop local iswin = uv.os_uname().sysname == "Windows_NT" @@ -80,6 +88,7 @@ local hasshellslash = vim.fn.exists "+shellslash" == 1 ---@field convert_altsep fun(self: plenary._Path, p:string): string ---@field split_root fun(self: plenary._Path, part:string): string, string, string ---@field join fun(self: plenary._Path, path: string, ...: string): string +---@field expand fun(self: plenary._Path, parts: string[], sep: string?): string[] ---@class plenary._WindowsPath : plenary._Path local _WindowsPath = { @@ -200,6 +209,38 @@ function _WindowsPath:join(path, ...) return result_drive .. result_root .. table.concat(parts) end +---@param parts string[] +---@param sep string +---@return string[] new_path +function _WindowsPath:expand(parts, sep) + -- Variables have a percent sign on both sides: %ThisIsAVariable% + -- The variable name can include spaces, punctuation and mixed case: + -- %_Another Ex.ample% + -- But they aren't case sensitive + -- + -- A variable name may include any of the following characters: + -- A-Z, a-z, 0-9, # $ ' ( ) * + , - . ? @ [ ] _ { } ~ + -- The first character of the name must not be numeric. + + -- this would be MUCH cleaner to implement with LPEG but backwards compatibility... + local pattern = "%%[A-Za-z#$'()*+,%-.?@[%]_{}~][A-Za-z0-9#$'()*+,%-.?@[%]_{}~]*%%" + + local new_parts = {} + for _, part in ipairs(parts) do + part = part:gsub(pattern, function(m) + local var_name = m:sub(2):sub(1, -2) + + ---@diagnostic disable-next-line: missing-parameter + local var = uv.os_getenv(var_name) + return var and (var:gsub("\\", sep)) or m + end) + + table.insert(new_parts, part) + end + + return new_parts +end + ---@class plenary._PosixPath : plenary._Path local _PosixPath = { sep = "/", @@ -249,6 +290,34 @@ function _PosixPath:join(path, ...) return table.concat(parts) end +---@param parts string[] +---@return string[] new_path +function _PosixPath:expand(parts) + -- Environment variable names used by the utilities in the Shell and + -- Utilities volume of IEEE Std 1003.1-2001 consist solely of uppercase + -- letters, digits, and the '_' (underscore) from the characters defined in + -- Portable Character Set and do not begin with a digit. Other characters may + -- be permitted by an implementation; applications shall tolerate the + -- presence of such names. + + local pattern = "%$[A-Z_][A-Z0-9_]*" + + local new_parts = {} + for _, part in ipairs(parts) do + part = part:gsub(pattern, function(m) + local var_name = m:sub(2) + + ---@diagnostic disable-next-line: missing-parameter + local var = uv.os_getenv(var_name) + return var or m + end) + + table.insert(new_parts, part) + end + + return new_parts +end + local S_IF = { -- S_IFDIR = 0o040000 # directory DIR = 0x4000, @@ -650,6 +719,9 @@ end --- if given path doesn't exists and isn't already an absolute path, creates --- one using the cwd --- +--- DOES NOT expand environment variables and home/user constructs (`~` and `~user`). +--- Use `expand` for this. +--- --- respects 'shellslash' on Windows ---@return string function Path:absolute() @@ -675,6 +747,18 @@ function Path:absolute() return self._absolute end +--- get the environment variable expanded filename +---@return string +function Path:expand() + local relparts = self._flavor:expand(self.relparts, self.sep) + local filename = self:_filename(nil, nil, relparts) + + filename = filename:gsub("^~([^" .. self.sep .. "]+)" .. self.sep, function(m) + return Path:new(self.path.home):parent().filename .. self.sep .. m .. self.sep + end) + return (filename:gsub("^~", self.path.home)) +end + ---@param ... plenary.Path2Args ---@return plenary.Path2 function Path:joinpath(...) diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index a1e9bdeb..598fc3e0 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -13,21 +13,25 @@ local function set_shellslash(bool) end end +local function it_win(name, test_fn) + if not hasshellslash then + it(name, test_fn) + else + local orig = vim.o.shellslash + vim.o.shellslash = true + it(name .. " (shellslash)", test_fn) + + vim.o.shellslash = false + it(name .. " (noshellslash)", test_fn) + vim.o.shellslash = orig + end +end + local function it_cross_plat(name, test_fn) if not iswin then it(name .. " - unix", test_fn) else - if not hasshellslash then - it(name .. " - windows", test_fn) - else - local orig = vim.o.shellslash - vim.o.shellslash = true - it(name .. " - windows (shellslash)", test_fn) - - vim.o.shellslash = false - it(name .. " - windows (noshellslash)", test_fn) - vim.o.shellslash = orig - end + it_win(name .. " - windows", test_fn) end end @@ -1123,4 +1127,54 @@ SOFTWARE.]] assert.is_nil(res) end) end) + + describe("expand", function() + uv.os_setenv("BARVAR", "bar") + + describe("unix", function() + if iswin then + return + end + + it("match valid env var", function() + local p = Path:new "foo/$BARVAR/baz" + assert.are.same("foo/bar/baz", p:expand()) + end) + + it_win("ignore invalid env var", function() + local p = Path:new "foo/$NOT_A_REAL_ENV_VAR/baz" + assert.are.same(p.filename, p:expand()) + end) + end) + + describe("windows", function() + if not iswin then + return + end + + it_win("match valid env var", function() + local p = Path:new "foo/%BARVAR%/baz" + local expect = Path:new "foo/bar/baz" + assert.are.same(expect.filename, p:expand()) + end) + + it_win("ignore invalid env var", function() + local p = Path:new "foo/%NOT_A_REAL_ENV_VAR%/baz" + assert.are.same(p.filename, p:expand()) + end) + end) + + it_cross_plat("matches ~", function() + local p = Path:new "~/hello" + local expect = Path:new { path.home, "hello" } + assert.are.same(expect.filename, p:expand()) + end) + + it_cross_plat("matches ~user", function() + local p = Path:new "~otheruser/hello" + local home = Path:new(path.home):parent() / "otheruser" + local expect = home / "hello" + assert.are.same(expect.filename, p:expand()) + end) + end) end) From 41b9d569fa6972a9e098b8ff709e7901f4d861b2 Mon Sep 17 00:00:00 2001 From: James Trew Date: Mon, 2 Sep 2024 15:16:15 -0400 Subject: [PATCH 29/43] fix linting errors/tests --- lua/plenary/path2.lua | 61 +++++++++++++++++++++--------------- tests/plenary/path2_spec.lua | 6 ++-- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 53b3440a..ad7f43c2 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -67,12 +67,9 @@ --- --- - `find_upwards` returns `nil` if file not found rather than an empty string - -- TODO: could probably do with more `make_relative` tests -- - walk up close to root -- - add "walk_up" in test name --- TODO: shorten: i think `vim.list_contains` is not nvim-0.7 compat (maybe use like a set?) --- TODO: verify unix tests pass -- TODO: add windows test for path2_spec only? local bit = require "plenary.bit" @@ -388,20 +385,21 @@ path.root = (function() end)() ---@param parts string[] ----@param _flavor plenary._Path +---@param flavor plenary._Path ---@return string drv ---@return string root ---@return string[] -local function parse_parts(parts, _flavor) - local drv, root, rel, parsed = "", "", "", {} +local function parse_parts(parts, flavor) + local rel + local drv, root, parsed = "", "", {} if #parts == 0 then return drv, root, parsed end - local sep = _flavor.sep - local p = _flavor:join(unpack(parts)) - drv, root, rel = _flavor:split_root(p) + local sep = flavor.sep + local p = flavor:join(unpack(parts)) + drv, root, rel = flavor:split_root(p) if root == "" and drv:sub(1, 1) == sep and drv:sub(-1) ~= sep then local drv_parts = vim.split(drv, sep) @@ -423,6 +421,8 @@ local function parse_parts(parts, _flavor) return drv, root, parsed end +local FILE_MODE = 438 -- o666 (aka -rw-rw-rw-) + ---@class plenary.Path2 ---@field path plenary.path2 ---@field private _flavor plenary._Path @@ -546,7 +546,9 @@ function Path:new(...) elseif type(arg) ~= "string" and not self.is_path(arg) then error( string.format( - "Invalid type passed to 'Path:new'. Expects any number of 'string' or 'Path' objects. Got type '%s', shape '%s'", + "Invalid type passed to 'Path:new'. " + .. "Expects any number of 'string' or 'Path' objects. " + .. "Got type '%s', shape '%s'", type(arg), vim.inspect(arg) ) @@ -582,13 +584,13 @@ end ---@param relparts string[]? ---@return string function Path:_filename(drv, root, relparts) - drv = vim.F.if_nil(drv, self.drv) + drv = vim.F.if_nil(drv, self.drv) -- luacheck: ignore drv = self.drv ~= "" and self.drv:gsub(self._flavor.sep, self.sep) or "" if self._flavor.has_drv and drv == "" then root = "" else - root = vim.F.if_nil(root, self.root) + root = vim.F.if_nil(root, self.root) -- luacheck: ignore root = self.root ~= "" and self.sep:rep(#self.root) or "" end @@ -658,7 +660,7 @@ function Path:lstat() return res end ----@return integer +---@return integer # numeric mode in octal values function Path:permission() local stat = self:stat() local perm = bit.band(stat.mode, 0x1FF) @@ -914,11 +916,15 @@ function Path:shorten(len, excludes) len = vim.F.if_nil(len, 1) excludes = vim.F.if_nil(excludes, { #self.relparts }) - local new_parts = {} + local excl_set = {} + for _, idx in ipairs(excludes) do + excl_set[idx] = true + end + local new_parts = {} for i, part in ipairs(self.relparts) do local neg_i = -(#self.relparts + 1) + i - if #part > len and not vim.list_contains(excludes, i) and not vim.list_contains(excludes, neg_i) then + if #part > len and not excl_set[i] and not excl_set[neg_i] then part = part:sub(1, len) end table.insert(new_parts, part) @@ -928,7 +934,10 @@ function Path:shorten(len, excludes) end ---@class plenary.Path2.mkdirOpts ----@field mode integer? permission to give to the directory, no umask effect will be applied (default: `o777`) +--- permission to give to the directory, this is modified by the process's umask +--- (default: `o777`) +--- (currently not implemented in Windows by libuv) +---@field mode integer? ---@field parents boolean? creates parent directories if true and necessary (default: `false`) ---@field exists_ok boolean? ignores error if true and target directory exists (default: `false`) @@ -942,7 +951,7 @@ function Path:mkdir(opts) exists_ok = { opts.exists_ok, "b", true }, } - opts.mode = vim.F.if_nil(opts.mode, 511) + opts.mode = vim.F.if_nil(opts.mode, 511) -- o777 opts.parents = vim.F.if_nil(opts.parents, false) opts.exists_ok = vim.F.if_nil(opts.exists_ok, false) @@ -998,7 +1007,7 @@ function Path:touch(opts) mode = { opts.mode, "n", true }, parents = { opts.parents, { "n", "b" }, true }, } - opts.mode = vim.F.if_nil(opts.mode, 438) + opts.mode = vim.F.if_nil(opts.mode, FILE_MODE) -- o666 opts.parents = vim.F.if_nil(opts.parents, false) local abs_path = self:absolute() @@ -1101,7 +1110,9 @@ end ---@field exists_ok boolean? whether ok if `opts.destination` exists, if so folders are merged (default: `true`) ---@param opts plenary.Path2.copyOpts ----@return {[plenary.Path2]: {success:boolean, err: string?}} # indicating success of copy; nested tables constitute sub dirs +--- a flat dictionary of destination paths and their copy result. +--- if successful, `{ success = true }`, else `{ success = false, err = "some msg" }` +---@return {[plenary.Path2]: {success:boolean, err: string?}} function Path:copy(opts) vim.validate { destination = { opts.destination, is_path_like }, @@ -1192,7 +1203,7 @@ end function Path:_read_sync() local stat = self:_get_readable_stat() - local fd, err = uv.fs_open(self:absolute(), "r", 438) + local fd, err = uv.fs_open(self:absolute(), "r", FILE_MODE) if fd == nil then error(err) end @@ -1213,7 +1224,7 @@ end ---@private ---@param callback fun(data: string) function Path:_read_async(callback) - uv.fs_open(self:absolute(), "r", 438, function(err_open, fd) + uv.fs_open(self:absolute(), "r", FILE_MODE, function(err_open, fd) if err_open then error(err_open) end @@ -1263,7 +1274,7 @@ function Path:head(lines) lines = vim.F.if_nil(lines, 10) local chunk_size = 256 - local fd, err = uv.fs_open(self:absolute(), "r", 438) + local fd, err = uv.fs_open(self:absolute(), "r", FILE_MODE) if fd == nil then error(err) end @@ -1318,7 +1329,7 @@ function Path:tail(lines) lines = vim.F.if_nil(lines, 10) local chunk_size = 256 - local fd, err = uv.fs_open(self:absolute(), "r", 438) + local fd, err = uv.fs_open(self:absolute(), "r", FILE_MODE) if fd == nil then error(err) end @@ -1378,7 +1389,7 @@ function Path:readbyterange(offset, length) } local stat = self:_get_readable_stat() - local fd, err = uv.fs_open(self:absolute(), "r", 438) + local fd, err = uv.fs_open(self:absolute(), "r", FILE_MODE) if fd == nil then error(err) end @@ -1427,7 +1438,7 @@ function Path:write(data, flags, mode) mode = { mode, "n", true }, } - mode = vim.F.if_nil(mode, 438) + mode = vim.F.if_nil(mode, FILE_MODE) local fd, err = uv.fs_open(self:absolute(), flags, mode) if fd == nil then error(err) diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 598fc3e0..0215c624 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -431,7 +431,7 @@ describe("Path2", function() p:mkdir() assert.is_true(p:exists()) assert.is_true(p:is_dir()) - assert_permission(0777, p:permission()) + assert_permission(755, p:permission()) -- umask dependent, probably bad test p:rmdir() assert.is_false(p:exists()) @@ -468,9 +468,9 @@ describe("Path2", function() it_cross_plat("can set different modes", function() local p = Path:new "_dir_not_exist" assert.has_no_error(function() - p:mkdir { mode = 0755 } + p:mkdir { mode = 292 } -- o444 end) - assert_permission(0755, p:permission()) + assert_permission(444, p:permission()) p:rmdir() assert.is_false(p:exists()) From 2c530fe970b886da5b134b37644f5e9bfcce1f46 Mon Sep 17 00:00:00 2001 From: James Trew Date: Mon, 2 Sep 2024 15:20:31 -0400 Subject: [PATCH 30/43] fix formatting --- tests/plenary/path2_spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 0215c624..6f3c1506 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -468,7 +468,7 @@ describe("Path2", function() it_cross_plat("can set different modes", function() local p = Path:new "_dir_not_exist" assert.has_no_error(function() - p:mkdir { mode = 292 } -- o444 + p:mkdir { mode = 292 } -- o444 end) assert_permission(444, p:permission()) From 539d86aed963f8c1214402f088e8abb70e76e967 Mon Sep 17 00:00:00 2001 From: James Trew Date: Mon, 2 Sep 2024 15:26:16 -0400 Subject: [PATCH 31/43] more `make_relative` tests --- lua/plenary/path2.lua | 3 --- tests/plenary/path2_spec.lua | 9 ++++++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index ad7f43c2..84b811dc 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -67,9 +67,6 @@ --- --- - `find_upwards` returns `nil` if file not found rather than an empty string --- TODO: could probably do with more `make_relative` tests --- - walk up close to root --- - add "walk_up" in test name -- TODO: add windows test for path2_spec only? local bit = require "plenary.bit" diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 6f3c1506..32e5269e 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -341,12 +341,19 @@ describe("Path2", function() end) end) - it_cross_plat("can walk upwards out of current subpath", function() + it_cross_plat("can walk_up out of current subpath", function() local p = Path:new { "foo", "bar", "baz" } local cwd = Path:new { "foo", "foo_inner" } local expect = Path:new { "..", "bar", "baz" } assert.are.same(expect.filename, p:make_relative(cwd, true)) end) + + it_cross_plat("can walk_up to root", function() + local p = Path:new { root(), "foo", "bar", "baz" } + local cwd = Path:new { root(), "def" } + local expect = Path:new { "..", "foo", "bar", "baz" } + assert.are.same(expect.filename, p:make_relative(cwd, true)) + end) end) describe(":shorten", function() From b17c61fb59690b181c69aea241ed3cfccc9e8cb9 Mon Sep 17 00:00:00 2001 From: James Trew Date: Mon, 2 Sep 2024 15:37:04 -0400 Subject: [PATCH 32/43] add some windows tests to ci --- .github/workflows/default.yml | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 302ab2ba..100c47aa 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -21,10 +21,10 @@ jobs: - os: ubuntu-22.04 rev: v0.10.0/nvim-linux64.tar.gz steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: date +%F > todays-date - name: Restore cache for today's nightly. - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: _neovim key: ${{ runner.os }}-${{ matrix.rev }}-${{ hashFiles('todays-date') }} @@ -42,12 +42,31 @@ jobs: nvim --version make test + run_tests_windows: + name: unit tests (Windows) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-2022] + rev: [nightly, v0.7.2, v0.8.3, v0.9.5, v0.10.0] + steps: + - uses: actions/checkout@v4 + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: ${{ matrix.rev }} + - name: Run tests + run: | + nvim --version + nvim --headless --noplugin -u scripts/minimal.vim -c "PlenaryBustedFile tests/plenary/path2_spec.lua" + stylua: name: stylua runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 - - uses: JohnnyMorganz/stylua-action@v2 + - uses: actions/checkout@v4 + - uses: JohnnyMorganz/stylua-action@v4 with: token: ${{ secrets.GITHUB_TOKEN }} version: latest @@ -58,7 +77,7 @@ jobs: name: Luacheck runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Prepare run: | From e377608ba74e7276c6f923ed17ebdde95bf338ea Mon Sep 17 00:00:00 2001 From: James Trew Date: Mon, 2 Sep 2024 20:40:54 -0400 Subject: [PATCH 33/43] debug ci tests --- lua/plenary/path2.lua | 7 ++-- tests/plenary/path2_spec.lua | 77 ++++++++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 84b811dc..c8444442 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -222,7 +222,7 @@ function _WindowsPath:expand(parts, sep) local new_parts = {} for _, part in ipairs(parts) do part = part:gsub(pattern, function(m) - local var_name = m:sub(2):sub(1, -2) + local var_name = m:sub(2, -2) ---@diagnostic disable-next-line: missing-parameter local var = uv.os_getenv(var_name) @@ -1376,8 +1376,8 @@ function Path:tail(lines) return (table.concat(data):gsub("[\r\n]$", "")) end ----@param offset integer ----@param length integer +---@param offset integer byte offset from which the file is read, supports negative index +---@param length integer number of bytes to read from the offset ---@return string function Path:readbyterange(offset, length) vim.validate { @@ -1403,7 +1403,6 @@ function Path:readbyterange(offset, length) local data = "" local read_chunk while #data < length do - -- local read_chunk = assert(uv.fs_read(fd, length - #data, offset)) read_chunk, err = uv.fs_read(fd, length - #data, offset) if read_chunk == nil then error(err) diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 32e5269e..2d053c2d 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -932,14 +932,14 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:]] - assert.are.same(should, data) + assert.are.same(should, data, diff_str(should, data)) end) it_cross_plat("should read the first line of file", function() local p = Path:new "LICENSE" local data = p:head(1) local should = [[MIT License]] - assert.are.same(should, data) + assert.are.same(should, data, diff_str(should, data)) end) it_cross_plat("should max read whole file", function() @@ -966,7 +966,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.]] - assert.are.same(should, data) + assert.are.same(should, data, diff_str(should, data)) end) it_cross_plat("handles unix lf line endings", function() @@ -1018,14 +1018,14 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.]] - assert.are.same(should, data) + assert.are.same(should, data, diff_str(should, data)) end) it_cross_plat("should read the last line of file", function() local p = Path:new "LICENSE" local data = p:tail(1) local should = [[SOFTWARE.]] - assert.are.same(should, data) + assert.are.same(should, data, diff_str(should, data)) end) it_cross_plat("should max read whole file", function() @@ -1052,7 +1052,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.]] - assert.are.same(should, data) + assert.are.same(should, data, diff_str(should, data)) end) it_cross_plat("handles unix lf line endings", function() @@ -1098,18 +1098,72 @@ SOFTWARE.]] end) describe("readbyterange", function() + after_each(function() + uv.fs_unlink "foobar.txt" + end) + it_cross_plat("should read bytes at given offset", function() local p = Path:new "LICENSE" local data = p:readbyterange(13, 10) local should = "Copyright " - assert.are.same(should, data) + assert.are.same(should, data, diff_str(should, data)) end) it_cross_plat("supports negative offset", function() local p = Path:new "LICENSE" local data = p:readbyterange(-10, 10) local should = "SOFTWARE.\n" - assert.are.same(should, data) + assert.are.same(should, data, diff_str(should, data)) + end) + + it_cross_plat("handles unix lf line endings", function() + local p = Path:new "foobar.txt" + p:touch() + + local txt = "foo\nbar\nbaz" + p:write(txt, "w") + local data = p:readbyterange(3, 5) + local expect = "\nbar\n" + assert.are.same(expect, data, diff_str(expect, data)) + end) + + it_cross_plat("handles windows crlf line endings", function() + local p = Path:new "foobar.txt" + p:touch() + + local txt = "foo\r\nbar\r\nbaz" + p:write(txt, "w") + local data = p:readbyterange(3, 5) + local expect = "\r\nbar" + assert.are.same(expect, data, diff_str(expect, data)) + end) + + it_cross_plat("handles mac cr line endings", function() + local p = Path:new "foobar.txt" + p:touch() + + local txt = "foo\rbar\rbaz" + p:write(txt, "w") + local data = p:readbyterange(3, 5) + local expect = "\rbar\r" + assert.are.same(expect, data, diff_str(expect, data)) + end) + + it_cross_plat("offset larger than size", function() + local p = Path:new "foobar.txt" + p:touch() + + local txt = "hello" + p:write(txt, "w") + local data = p:readbyterange(10, 3) + assert.are.same("", data) + end) + + it_cross_plat("no offset", function() + local p = Path:new "LICENSE" + local data = p:readbyterange(0, 11) + local should = "MIT License" + assert.are.same(should, data, diff_str(should, data)) end) end) @@ -1129,13 +1183,14 @@ SOFTWARE.]] end) it_cross_plat("doesn't find file", function() - local p = Path:new "." + local p = Path:new(path.root()) local res = p:find_upwards "aisohtenaishoetnaishoetnasihoetnashitoen" assert.is_nil(res) end) end) describe("expand", function() + uv.os_setenv("FOOVAR", "foo") uv.os_setenv("BARVAR", "bar") describe("unix", function() @@ -1144,7 +1199,7 @@ SOFTWARE.]] end it("match valid env var", function() - local p = Path:new "foo/$BARVAR/baz" + local p = Path:new "$FOOVAR/$BARVAR/baz" assert.are.same("foo/bar/baz", p:expand()) end) @@ -1160,7 +1215,7 @@ SOFTWARE.]] end it_win("match valid env var", function() - local p = Path:new "foo/%BARVAR%/baz" + local p = Path:new "%foovar%/%BARVAR%/baz" local expect = Path:new "foo/bar/baz" assert.are.same(expect.filename, p:expand()) end) From 6a9654b8d3a7b1ffcd6586117480cb87a50af3e3 Mon Sep 17 00:00:00 2001 From: James Trew Date: Mon, 2 Sep 2024 20:55:50 -0400 Subject: [PATCH 34/43] should fix ci tests --- tests/plenary/path2_spec.lua | 52 +++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 2d053c2d..1944d79d 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -46,6 +46,38 @@ local function plat_path(p) return p:gsub("/", "\\") end + +-- set up mock file with consistent eol regardless of system (git autocrlf settings) +-- simplifies reading tests +local licence_lines = { + "MIT License", + "", + "Copyright (c) 2020 TJ DeVries", + "", + "Permission is hereby granted, free of charge, to any person obtaining a copy", + 'of this software and associated documentation files (the "Software"), to deal', + "in the Software without restriction, including without limitation the rights", + "to use, copy, modify, merge, publish, distribute, sublicense, and/or sell", + "copies of the Software, and to permit persons to whom the Software is", + "furnished to do so, subject to the following conditions:", + "", + "The above copyright notice and this permission notice shall be included in all", + "copies or substantial portions of the Software.", + "", + 'THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR', + "IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,", + "FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE", + "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER", + "LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,", + "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE", + "SOFTWARE.", + "", +} + +local license_str = table.concat(licence_lines, "\n") +local tmp_license = Path:new "TMP_LICENSE" +tmp_license:write(license_str, "w") + describe("Path2", function() describe("filename", function() local function get_paths() @@ -919,7 +951,7 @@ describe("Path2", function() end) it_cross_plat("should read head of file", function() - local p = Path:new "LICENSE" + local p = Path:new "TMP_LICENSE" local data = p:head() local should = [[MIT License @@ -936,14 +968,14 @@ furnished to do so, subject to the following conditions:]] end) it_cross_plat("should read the first line of file", function() - local p = Path:new "LICENSE" + local p = Path:new "TMP_LICENSE" local data = p:head(1) local should = [[MIT License]] assert.are.same(should, data, diff_str(should, data)) end) it_cross_plat("should max read whole file", function() - local p = Path:new "LICENSE" + local p = Path:new "TMP_LICENSE" local data = p:head(1000) local should = [[MIT License @@ -1006,7 +1038,7 @@ SOFTWARE.]] end) it_cross_plat("should read tail of file", function() - local p = Path:new "LICENSE" + local p = Path:new "TMP_LICENSE" local data = p:tail() local should = [[The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. @@ -1022,14 +1054,14 @@ SOFTWARE.]] end) it_cross_plat("should read the last line of file", function() - local p = Path:new "LICENSE" + local p = Path:new "TMP_LICENSE" local data = p:tail(1) local should = [[SOFTWARE.]] assert.are.same(should, data, diff_str(should, data)) end) it_cross_plat("should max read whole file", function() - local p = Path:new "LICENSE" + local p = Path:new "TMP_LICENSE" local data = p:tail(1000) local should = [[MIT License @@ -1103,14 +1135,14 @@ SOFTWARE.]] end) it_cross_plat("should read bytes at given offset", function() - local p = Path:new "LICENSE" + local p = Path:new "TMP_LICENSE" local data = p:readbyterange(13, 10) local should = "Copyright " assert.are.same(should, data, diff_str(should, data)) end) it_cross_plat("supports negative offset", function() - local p = Path:new "LICENSE" + local p = Path:new "TMP_LICENSE" local data = p:readbyterange(-10, 10) local should = "SOFTWARE.\n" assert.are.same(should, data, diff_str(should, data)) @@ -1160,7 +1192,7 @@ SOFTWARE.]] end) it_cross_plat("no offset", function() - local p = Path:new "LICENSE" + local p = Path:new "TMP_LICENSE" local data = p:readbyterange(0, 11) local should = "MIT License" assert.are.same(should, data, diff_str(should, data)) @@ -1240,3 +1272,5 @@ SOFTWARE.]] end) end) end) + +tmp_license:rm() From a99b7c20a383da4656c0ee1413d0e35c75306a38 Mon Sep 17 00:00:00 2001 From: James Trew Date: Mon, 2 Sep 2024 20:59:11 -0400 Subject: [PATCH 35/43] fix another test --- lua/plenary/path2.lua | 2 ++ tests/plenary/path2_spec.lua | 6 ------ 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index c8444442..19c56e4a 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -8,6 +8,8 @@ --- - `Path.new` no longer supported (think it's more confusing that helpful --- and not really used as far as I can tell) --- +--- - `Path.new` drops `sep` table param (eg. `Path:new {"foo\\bar/baz", sep = "/"}`) +--- --- - drop `__concat` metamethod? it was untested and had some todo comment, --- not sure how functional it is --- diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 1944d79d..dabc2415 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -46,7 +46,6 @@ local function plat_path(p) return p:gsub("/", "\\") end - -- set up mock file with consistent eol regardless of system (git autocrlf settings) -- simplifies reading tests local licence_lines = { @@ -159,11 +158,6 @@ describe("Path2", function() return paths end - it("custom sep", function() - local p = Path:new { "foo\\bar/baz", sep = "/" } - assert.are.same(p.filename, "foo/bar/baz") - end) - describe("noshellslash", function() set_shellslash(false) test_filename(get_windows_paths()) From 8c789350db97a8033fd8d1c813f3ab25aecb1394 Mon Sep 17 00:00:00 2001 From: James Trew Date: Wed, 4 Sep 2024 20:46:11 -0400 Subject: [PATCH 36/43] clean up notes --- lua/plenary/path2.lua | 74 +------------------------------------------ 1 file changed, 1 insertion(+), 73 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 19c56e4a..decbceea 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -1,78 +1,6 @@ ---- NOTES: ---- Rework on plenary.Path with a focus on better cross-platform support ---- including 'shellslash' support. ---- Effort to improve performance made (notably `:absolue` ~2x faster). ---- ---- ---- BREAKING CHANGES: ---- - `Path.new` no longer supported (think it's more confusing that helpful ---- and not really used as far as I can tell) ---- ---- - `Path.new` drops `sep` table param (eg. `Path:new {"foo\\bar/baz", sep = "/"}`) ---- ---- - drop `__concat` metamethod? it was untested and had some todo comment, ---- not sure how functional it is ---- ---- - `Path` objects are now "read-only", I don't think people were ever doing ---- things like `path.filename = 'foo'` but now explicitly adding some barrier ---- to this. Allows us to compute `filename` from "metadata" parsed once on ---- instantiation. ---- ---- - FIX: `Path:make_relative` throws error if you try to make a path relative ---- to another path that is not in the same subpath. ---- ---- eg. `Path:new("foo/bar_baz"):make_relative("foo/bar")` => errors as you ---- can't get to "foo/bar_baz" from "foo/bar" without going up in directory. ---- This would previously return "foo/bar_baz" which is wrong. ---- ---- Adds an option to walk up path to compensate. ---- ---- eg. `Path:new("foo/bar_baz"):make_relative("foo/bar", true)` => returns ---- "../bar_baz" ---- ---- - error handling is generally more loud, ie. emit errors from libuv rather ---- than swallowing it ---- ---- - remove `Path:normalize`. It doesn't make any sense. eg. this test case ---- ```lua ---- it("can normalize ~ when file is within home directory (trailing slash)", function() ---- local home = "/home/test/" ---- local p = Path:new { home, "./test_file" } ---- p.path.home = home ---- p._cwd = "/tmp/lua" ---- assert.are.same("~/test_file", p:normalize()) ---- end) ---- ``` ---- if the idea is to make `/home/test/test_file` relative to `/tmp/lua`, the result ---- should be `../../home/test/test_file`, only then can you substitue the ---- home directory for `~`. ---- So should really be `../../~/test_file`. But using `~` in a relative path ---- like that looks weird to me. And as this function first makes paths ---- relative, you will never get a leading `~` (since `~` literally ---- represents the absolute path of the home directory). ---- To top it off, something like `../../~/test_file` is impossible on Windows. ---- `C:/Users/test/test_file` relative to `C:/Windows/temp` is ---- `../../Users/test/test_file` and there's no home directory absolute path ---- in this. ---- ---- - `rename` returns new path rather than mutating path ---- ---- - `copy` ---- - drops interactive mode ---- - return value table is pre-flattened ---- - return value table value is `{success: boolean, err: string?}` rather than just `boolean` ---- ---- - drops `check_self` mechanism (ie. doing `Path.read("some/file/path")`) ---- seems unnecessary... just do `Path:new("some/file/path"):read()` ---- ---- - renamed `iter` into `iter_lines` for more clarity ---- ---- - `find_upwards` returns `nil` if file not found rather than an empty string - --- TODO: add windows test for path2_spec only? - local bit = require "plenary.bit" local uv = vim.loop + local iswin = uv.os_uname().sysname == "Windows_NT" local hasshellslash = vim.fn.exists "+shellslash" == 1 From 31b7ab265dce6fb95848b947a94b175f094bddec Mon Sep 17 00:00:00 2001 From: James Trew Date: Thu, 5 Sep 2024 21:33:49 -0400 Subject: [PATCH 37/43] add normalize and clean up --- lua/plenary/path2.lua | 113 ++++++++++++++++++++++++----------- tests/plenary/path2_spec.lua | 107 ++++++++++++++++++++------------- 2 files changed, 143 insertions(+), 77 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index decbceea..49551b40 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -13,13 +13,14 @@ local hasshellslash = vim.fn.exists "+shellslash" == 1 ---@field split_root fun(self: plenary._Path, part:string): string, string, string ---@field join fun(self: plenary._Path, path: string, ...: string): string ---@field expand fun(self: plenary._Path, parts: string[], sep: string?): string[] +---@field is_relative fun(self: plenary._Path, path: plenary.Path2, to: plenary.Path2): boolean ---@class plenary._WindowsPath : plenary._Path local _WindowsPath = { sep = "\\", altsep = "/", has_drv = true, - case_sensitive = true, + case_sensitive = false, } setmetatable(_WindowsPath, { __index = _WindowsPath }) @@ -165,12 +166,28 @@ function _WindowsPath:expand(parts, sep) return new_parts end +---@param path plenary.Path2 +---@param to plenary.Path2 +---@return boolean +function _WindowsPath:is_relative(path, to) + if path.anchor:lower() ~= to.anchor:lower() then + return false + end + + for i, to_part in ipairs(to.relparts) do + if to_part:lower() ~= path.relparts[i]:lower() then + return false + end + end + return true +end + ---@class plenary._PosixPath : plenary._Path local _PosixPath = { sep = "/", altsep = "", has_drv = false, - case_sensitive = false, + case_sensitive = true, } setmetatable(_PosixPath, { __index = _PosixPath }) @@ -242,6 +259,22 @@ function _PosixPath:expand(parts) return new_parts end +---@param path plenary.Path2 +---@param to plenary.Path2 +---@return boolean +function _PosixPath:is_relative(path, to) + if path.root ~= to.root then + return false + end + + for i, to_part in ipairs(to.relparts) do + if to_part ~= path.relparts[i] then + return false + end + end + return true +end + local S_IF = { -- S_IFDIR = 0o040000 # directory DIR = 0x4000, @@ -437,7 +470,14 @@ Path.__eq = function(self, other) end ---@cast other plenary.Path2 - return self:absolute() == other:absolute() + if self._flavor ~= other._flavor then + return false + end + + if self._flavor.case_sensitive then + return self.filename == other.filename + end + return self.filename:lower() == other.filename:lower() end local _readonly_mt = { @@ -544,10 +584,7 @@ local function is_path_like(x) end local function is_path_like_opt(x) - if x == nil then - return true - end - return is_path_like(x) + return x == nil or is_path_like(x) end ---@return boolean @@ -749,41 +786,21 @@ function Path:is_relative(to) return true end - -- NOTE: could probably be optimized by letting _WindowsPath/_WindowsPath - -- handle this. - - local to_abs = to:absolute() - for parent in self:iter_parents() do - if to_abs == parent then - return true - end - end - - return false + return self._flavor:is_relative(self, to) end ---- makes a path relative to another (by default the cwd). ---- if path is already a relative path, it will first be turned absolute using ---- the cwd then made relative to the `to` path. ----@param to string|plenary.Path2? absolute path to make relative to (default: cwd) ----@param walk_up boolean? walk up to the provided path using '..' (default: `false`) ----@return string -function Path:make_relative(to, walk_up) +---@return plenary.Path2 +function Path:_make_relative(to, walk_up) vim.validate { to = { to, is_path_like_opt }, walk_up = { walk_up, "b", true }, } - -- NOTE: could probably take some shortcuts and avoid some `Path:new` calls - -- by allowing _WindowsPath/_PosixPath handle this individually. - -- As always, Windows root complicates things, so generating a new Path often - -- easier/less error prone than manual string manipulate but at the cost of - -- perf. walk_up = vim.F.if_nil(walk_up, false) if to == nil then if not self:is_absolute() then - return "." + return Path:new "." end to = Path:new(self.cwd) @@ -793,11 +810,11 @@ function Path:make_relative(to, walk_up) local abs = self:absolute() if abs == to:absolute() then - return "." + return Path:new "." end if self:is_relative(to) then - return Path:new((abs:sub(#to:absolute() + 1):gsub("^" .. self.sep, ""))).filename + return Path:new((abs:sub(#to:absolute() + 1):gsub("^" .. self.sep, ""))) end if not walk_up then @@ -820,7 +837,35 @@ function Path:make_relative(to, walk_up) local res_path = abs:sub(#common_path + 1):gsub("^" .. self.sep, "") table.insert(steps, res_path) - return Path:new(steps).filename + return Path:new(steps) +end + +--- makes a path relative to another (by default the cwd). +--- if path is already a relative path, it will first be turned absolute using +--- the cwd then made relative to the `to` path. +---@param to string|plenary.Path2? path to make relative to (default: cwd) +---@param walk_up boolean? walk up to the provided path using '..' (default: `false`) +---@return string +function Path:make_relative(to, walk_up) + return self:_make_relative(to, walk_up).filename +end + +--- Normalize path, resolving any intermediate ".." +--- eg. `a//b`, `a/./b`, `a/foo/../b` will all become `a/b` +--- Can optionally convert a path to relative to another. +---@param relative_to string|plenary.Path2|nil path to make relative to, if nil path isn't made relative +---@param walk_up boolean? walk up to the make relative path (if provided) using '..' (default: `false`) +---@return string +function Path:normalize(relative_to, walk_up) + local p + if relative_to == nil then + p = self + else + p = self:_make_relative(relative_to, walk_up) + end + + local relparts = resolve_dots(p.relparts) + return p:_filename(nil, nil, relparts) end --- Shorten path parts. diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index dabc2415..c6a0a97d 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -46,6 +46,16 @@ local function plat_path(p) return p:gsub("/", "\\") end +local function root() + if not iswin then + return "/" + end + if hasshellslash and vim.o.shellslash then + return "C:/" + end + return "C:\\" +end + -- set up mock file with consistent eol regardless of system (git autocrlf settings) -- simplifies reading tests local licence_lines = { @@ -301,16 +311,6 @@ describe("Path2", function() end) describe(":make_relative", function() - local root = function() - if not iswin then - return "/" - end - if hasshellslash and vim.o.shellslash then - return "C:/" - end - return "C:\\" - end - it_cross_plat("can take absolute paths and make them relative to the cwd", function() local p = Path:new { "lua", "plenary", "path.lua" } local absolute = vim.fn.getcwd() .. path.sep .. p.filename @@ -380,6 +380,57 @@ describe("Path2", function() local expect = Path:new { "..", "foo", "bar", "baz" } assert.are.same(expect.filename, p:make_relative(cwd, true)) end) + + it_win("handles drive letters case insensitively", function() + local p = Path:new { "C:/", "foo", "bar", "baz" } + local cwd = Path:new { "c:/", "foo" } + local expect = Path:new { "bar", "baz" } + assert.are.same(expect.filename, p:make_relative(cwd)) + end) + end) + + describe("normalize", function() + it_cross_plat("handles empty path", function() + local p = Path:new "" + assert.are.same(".", p:normalize()) + end) + + it_cross_plat("removes middle ..", function() + local p = Path:new "lua/../lua/say.lua" + local expect = Path:new { "lua", "say.lua" } + assert.are.same(expect.filename, p:normalize()) + end) + + it_cross_plat("walk up relative path", function() + local p = Path:new "async/../../lua/say.lua" + local expect = Path:new { "..", "lua", "say.lua" } + assert.are.same(expect.filename, p:normalize()) + end) + + it_cross_plat("handles absolute path", function() + local p = Path:new { root(), "a", "..", "a", "b" } + local expect = Path:new { root(), "a", "b" } + assert.are.same(expect.filename, p:normalize()) + end) + + it_cross_plat("makes relative", function() + local p = Path:new { path.home, "a", "..", "", "a", "b" } + local expect = Path:new { "a", "b" } + assert.are.same(expect.filename, p:normalize(path.home)) + end) + + it_cross_plat("make relative walk_up", function() + local p = Path:new { path.home, "a", "..", "", "a", "b" } + local cwd = Path:new { path.home, "c" } + local expect = Path:new { "..", "a", "b" } + assert.are.same(expect.filename, p:normalize(cwd, true)) + end) + + it_win("windows drive relative paths", function() + local p = Path:new { "C:", "a", "..", "", "a", "b" } + local expect = Path:new { "C:", "a", "b" } + assert.are.same(expect.filename, p:normalize()) + end) end) describe(":shorten", function() @@ -441,13 +492,6 @@ describe("Path2", function() end) end) - local function assert_permission(expect, actual) - if iswin then - return - end - assert.equal(expect, actual) - end - describe("mkdir / rmdir", function() after_each(function() uv.fs_rmdir "_dir_not_exist" @@ -464,7 +508,6 @@ describe("Path2", function() p:mkdir() assert.is_true(p:exists()) assert.is_true(p:is_dir()) - assert_permission(755, p:permission()) -- umask dependent, probably bad test p:rmdir() assert.is_false(p:exists()) @@ -497,17 +540,6 @@ describe("Path2", function() assert.is_false(p:exists()) assert.is_false(Path:new("impossible"):exists()) end) - - it_cross_plat("can set different modes", function() - local p = Path:new "_dir_not_exist" - assert.has_no_error(function() - p:mkdir { mode = 292 } -- o444 - end) - assert_permission(444, p:permission()) - - p:rmdir() - assert.is_false(p:exists()) - end) end) describe("touch/rm", function() @@ -764,17 +796,6 @@ describe("Path2", function() table.insert(trg_dirs, trg_dir:joinpath(dir)) end - -- vim.tbl_flatten doesn't work here as copy doesn't return a list - local function flatten(ret, t) - for _, v in pairs(t) do - if type(v) == "table" then - flatten(ret, v) - else - table.insert(ret, v) - end - end - end - before_each(function() -- generate {file}_{level}.lua on every directory level in src -- src @@ -1198,14 +1219,14 @@ SOFTWARE.]] local p = Path:new "lua/plenary" local res = assert(p:find_upwards "busted.lua") local expect = Path:new "lua/plenary/busted.lua" - assert.are.same(expect, res) + assert.are.same(expect.filename, res.filename) end) it_cross_plat("finds file in parent dir", function() local p = Path:new "lua/plenary" local res = assert(p:find_upwards "say.lua") - local expect = Path:new "lua/say.lua" - assert.are.same(expect, res) + local expect = vim.fn.fnamemodify("lua/say.lua", ":p") + assert.are.same(expect, res.filename) end) it_cross_plat("doesn't find file", function() From 4c7081f04018cfb21163efc0c8447aed067175a1 Mon Sep 17 00:00:00 2001 From: James Trew Date: Thu, 5 Sep 2024 21:36:30 -0400 Subject: [PATCH 38/43] dont test windows stuff on linux --- tests/plenary/path2_spec.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index c6a0a97d..4044e367 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -14,6 +14,10 @@ local function set_shellslash(bool) end local function it_win(name, test_fn) + if not iswin then + return + end + if not hasshellslash then it(name, test_fn) else @@ -1250,7 +1254,7 @@ SOFTWARE.]] assert.are.same("foo/bar/baz", p:expand()) end) - it_win("ignore invalid env var", function() + it("ignore invalid env var", function() local p = Path:new "foo/$NOT_A_REAL_ENV_VAR/baz" assert.are.same(p.filename, p:expand()) end) From ca13fb54da99480e9bfb8039f980e08c34af99c9 Mon Sep 17 00:00:00 2001 From: James Trew Date: Thu, 5 Sep 2024 21:59:49 -0400 Subject: [PATCH 39/43] normalize ignore bad make_relative --- lua/plenary/path2.lua | 4 +++- tests/plenary/path2_spec.lua | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 49551b40..38f632ea 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -861,7 +861,9 @@ function Path:normalize(relative_to, walk_up) if relative_to == nil then p = self else - p = self:_make_relative(relative_to, walk_up) + local ok + ok, p = pcall(Path._make_relative, self, relative_to, walk_up) + p = ok and assert(p) or self end local relparts = resolve_dots(p.relparts) diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 4044e367..db665bc6 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -435,6 +435,11 @@ describe("Path2", function() local expect = Path:new { "C:", "a", "b" } assert.are.same(expect.filename, p:normalize()) end) + + it_cross_plat("ignores bad make_relative", function() + local p = Path:new "foobar" + assert.are.same(p.filename, p:normalize(path.home)) + end) end) describe(":shorten", function() From 871827fb69b3293d9bcef7a8782a4ec949b0f615 Mon Sep 17 00:00:00 2001 From: James Trew Date: Fri, 6 Sep 2024 20:53:38 -0400 Subject: [PATCH 40/43] unset test env vars --- tests/plenary/path2_spec.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index db665bc6..25cd76b5 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -1294,6 +1294,9 @@ SOFTWARE.]] local expect = home / "hello" assert.are.same(expect.filename, p:expand()) end) + + uv.os_unsetenv("FOOVAR") + uv.os_unsetenv("BARVAR") end) end) From 1211b5c9a1d7ded86270e78938f30ae6827ccbdf Mon Sep 17 00:00:00 2001 From: James Trew Date: Fri, 6 Sep 2024 20:54:19 -0400 Subject: [PATCH 41/43] format --- tests/plenary/path2_spec.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 25cd76b5..4bdcb0bb 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -1295,8 +1295,8 @@ SOFTWARE.]] assert.are.same(expect.filename, p:expand()) end) - uv.os_unsetenv("FOOVAR") - uv.os_unsetenv("BARVAR") + uv.os_unsetenv "FOOVAR" + uv.os_unsetenv "BARVAR" end) end) From 87277a00d341f5560dc1f10e5079ebb60f04c056 Mon Sep 17 00:00:00 2001 From: James Trew Date: Sun, 8 Sep 2024 23:42:38 -0400 Subject: [PATCH 42/43] rework env var expansion --- lua/plenary/path2.lua | 142 +++++++++++++++++++++++++---------- tests/plenary/path2_spec.lua | 75 ++++++++++++------ 2 files changed, 155 insertions(+), 62 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 38f632ea..11140c2f 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -136,31 +136,70 @@ end ---@param parts string[] ---@param sep string ----@return string[] new_path +---@return string[] new_parts function _WindowsPath:expand(parts, sep) - -- Variables have a percent sign on both sides: %ThisIsAVariable% - -- The variable name can include spaces, punctuation and mixed case: - -- %_Another Ex.ample% - -- But they aren't case sensitive - -- - -- A variable name may include any of the following characters: - -- A-Z, a-z, 0-9, # $ ' ( ) * + , - . ? @ [ ] _ { } ~ - -- The first character of the name must not be numeric. - - -- this would be MUCH cleaner to implement with LPEG but backwards compatibility... - local pattern = "%%[A-Za-z#$'()*+,%-.?@[%]_{}~][A-Za-z0-9#$'()*+,%-.?@[%]_{}~]*%%" - local new_parts = {} + + local function add_expand(sub_parts, var, part, start, end_) + ---@diagnostic disable-next-line: missing-parameter + local val = uv.os_getenv(var) + if val then + table.insert(sub_parts, (val:gsub("\\", sep))) + else + table.insert(sub_parts, part:sub(start, end_)) + end + end + for _, part in ipairs(parts) do - part = part:gsub(pattern, function(m) - local var_name = m:sub(2, -2) + local sub_parts = {} + local i = 1 - ---@diagnostic disable-next-line: missing-parameter - local var = uv.os_getenv(var_name) - return var and (var:gsub("\\", sep)) or m - end) + while i <= #part do + local ch = part:sub(i, i) + if ch == "'" then -- no expansion inside single quotes + local end_ = part:find("'", i + 1, true) + if end_ then + table.insert(sub_parts, part:sub(i, end_)) + i = end_ + else + table.insert(sub_parts, ch) + end + elseif ch == "%" then + local end_ = part:find("%", i + 1, true) + if end_ then + local var = part:sub(i + 1, end_ - 1) + add_expand(sub_parts, var, part, i, end_) + i = end_ + else + table.insert(sub_parts, ch) + end + elseif ch == "$" then + local nextch = part:sub(i + 1, i + 1) + if nextch == "$" then + i = i + 1 + table.insert(sub_parts, ch) + elseif nextch == "{" then + local end_ = part:find("}", i + 2, true) + if end_ then + local var = part:sub(i + 2, end_ - 1) + add_expand(sub_parts, var, part, i, end_) + i = end_ + else + table.insert(sub_parts, ch) + end + else + local end_ = part:find("[^%w_]", i + 1, false) or #part + 1 + local var = part:sub(i + 1, end_ - 1) + add_expand(sub_parts, var, part, i, end_ - 1) + i = end_ - 1 + end + else + table.insert(sub_parts, ch) + end + i = i + 1 + end - table.insert(new_parts, part) + table.insert(new_parts, table.concat(sub_parts)) end return new_parts @@ -232,28 +271,47 @@ function _PosixPath:join(path, ...) end ---@param parts string[] ----@return string[] new_path +---@return string[] new_parts function _PosixPath:expand(parts) - -- Environment variable names used by the utilities in the Shell and - -- Utilities volume of IEEE Std 1003.1-2001 consist solely of uppercase - -- letters, digits, and the '_' (underscore) from the characters defined in - -- Portable Character Set and do not begin with a digit. Other characters may - -- be permitted by an implementation; applications shall tolerate the - -- presence of such names. - - local pattern = "%$[A-Z_][A-Z0-9_]*" + local function add_expand(sub_parts, var, part, start, end_) + ---@diagnostic disable-next-line: missing-parameter + local val = uv.os_getenv(var) + if val then + table.insert(sub_parts, val) + else + table.insert(sub_parts, part:sub(start, end_)) + end + end local new_parts = {} for _, part in ipairs(parts) do - part = part:gsub(pattern, function(m) - local var_name = m:sub(2) - - ---@diagnostic disable-next-line: missing-parameter - local var = uv.os_getenv(var_name) - return var or m - end) + local i = 1 + local sub_parts = {} + while i <= #part do + local ch = part:sub(i, i) + if ch == "$" then + if part:sub(i + 1, i + 1) == "{" then + local end_ = part:find("}", i + 2, true) + if end_ then + local var = part:sub(i + 2, end_ - 1) + add_expand(sub_parts, var, part, i, end_) + i = end_ + else + table.insert(sub_parts, ch) + end + else + local end_ = part:find("[^%w_]", i + 1, false) or #part + 1 + local var = part:sub(i + 1, end_ - 1) + add_expand(sub_parts, var, part, i, end_ - 1) + i = end_ - 1 + end + else + table.insert(sub_parts, ch) + end + i = i + 1 + end - table.insert(new_parts, part) + table.insert(new_parts, table.concat(sub_parts)) end return new_parts @@ -714,15 +772,17 @@ function Path:absolute() end --- get the environment variable expanded filename +--- also expand ~/ but NOT ~user/ constructs ---@return string function Path:expand() local relparts = self._flavor:expand(self.relparts, self.sep) local filename = self:_filename(nil, nil, relparts) - filename = filename:gsub("^~([^" .. self.sep .. "]+)" .. self.sep, function(m) - return Path:new(self.path.home):parent().filename .. self.sep .. m .. self.sep - end) - return (filename:gsub("^~", self.path.home)) + if filename:sub(1, 2) == "~" .. self.sep then + filename = self.path.home .. filename:sub(2) + end + + return filename end ---@param ... plenary.Path2Args diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 4bdcb0bb..95c8b475 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -1249,20 +1249,32 @@ SOFTWARE.]] uv.os_setenv("FOOVAR", "foo") uv.os_setenv("BARVAR", "bar") - describe("unix", function() - if iswin then - return - end + it_cross_plat("match simple valid $ env vars", function() + assert.are.same("foo", Path:new("$FOOVAR"):expand()) + assert.are.same("foo$", Path:new("$FOOVAR$"):expand()) + assert.are.same(Path:new("foo/bar/baz").filename, Path:new("$FOOVAR/$BARVAR/baz"):expand()) + assert.are.same(Path:new("foo/bar baz").filename, Path:new("$FOOVAR/$BARVAR baz"):expand()) + assert.are.same(Path:new("foo/$BARVARbaz").filename, Path:new("$FOOVAR/$BARVARbaz"):expand()) + end) - it("match valid env var", function() - local p = Path:new "$FOOVAR/$BARVAR/baz" - assert.are.same("foo/bar/baz", p:expand()) - end) + it_cross_plat("match simple valid $ env vars with braces", function() + assert.are.same(Path:new("foo/bar/baz").filename, Path:new("${FOOVAR}/${BARVAR}/baz"):expand()) + assert.are.same(Path:new("foo/bar baz").filename, Path:new("${FOOVAR}/${BARVAR} baz"):expand()) + end) - it("ignore invalid env var", function() - local p = Path:new "foo/$NOT_A_REAL_ENV_VAR/baz" - assert.are.same(p.filename, p:expand()) - end) + it_cross_plat("ignore unset $ env var", function() + local p = Path:new "foo/$NOT_A_REAL_ENV_VAR/baz" + assert.are.same(p.filename, p:expand()) + end) + + it_cross_plat("ignore empty $", function() + local p = Path:new "foo/$/bar$baz$" + assert.are.same(p.filename, p:expand()) + end) + + it_cross_plat("ignore empty ${}", function() + local p = Path:new "foo/${}/bar${}" + assert.are.same(p.filename, p:expand()) end) describe("windows", function() @@ -1270,16 +1282,39 @@ SOFTWARE.]] return end - it_win("match valid env var", function() - local p = Path:new "%foovar%/%BARVAR%/baz" - local expect = Path:new "foo/bar/baz" - assert.are.same(expect.filename, p:expand()) + uv.os_setenv("{foovar", "foo1") + uv.os_setenv("{foovar}", "foo2") + + it_win("match valid %% env var", function() + assert.are.same(Path:new("foo/bar/baz").filename, Path:new("%foovar%/%BARVAR%/baz"):expand()) + assert.are.same(Path:new("foo1/bar/baz").filename, Path:new("%{foovar%/%BARVAR%/baz"):expand()) + assert.are.same(Path:new("foo2/bar/baz").filename, Path:new("%{foovar}%/%BARVAR%/baz"):expand()) + assert.are.same(Path:new("foo/bar baz").filename, Path:new("%foovar%/%BARVAR% baz"):expand()) + end) + + it_win("empty %%", function() + local p = Path:new "foo/%%/baz%%" + assert.are.same(p.filename, p:expand()) end) - it_win("ignore invalid env var", function() + it_win("match special char env var with ${}", function() + assert.are.same(Path:new("foo1/bar/baz").filename, Path:new("${{foovar}/%BARVAR%/baz"):expand()) + assert.are.same(Path:new("foo1}/bar/baz").filename, Path:new("${{foovar}}/%BARVAR%/baz"):expand()) + end) + + it_win("ignore unset %% env var", function() local p = Path:new "foo/%NOT_A_REAL_ENV_VAR%/baz" assert.are.same(p.filename, p:expand()) end) + + it_win("ignore quoted vars", function() + local paths = { "'%foovar%'", "'${foovar}'", "'$foovar'" } + for _, p in ipairs(paths) do + ---@diagnostic disable-next-line: cast-local-type + p = Path:new(p) + assert.are.same(p.filename, p:expand()) + end + end) end) it_cross_plat("matches ~", function() @@ -1288,11 +1323,9 @@ SOFTWARE.]] assert.are.same(expect.filename, p:expand()) end) - it_cross_plat("matches ~user", function() + it_cross_plat("does not matches ~user", function() local p = Path:new "~otheruser/hello" - local home = Path:new(path.home):parent() / "otheruser" - local expect = home / "hello" - assert.are.same(expect.filename, p:expand()) + assert.are.same(p.filename, p:expand()) end) uv.os_unsetenv "FOOVAR" From 42275a43f10ef8b5205bfdca0bcee1684c1750a5 Mon Sep 17 00:00:00 2001 From: James Trew Date: Mon, 9 Sep 2024 23:16:10 -0400 Subject: [PATCH 43/43] api param tweaks --- lua/plenary/path2.lua | 72 ++++++++++++++++++------------------ tests/plenary/path2_spec.lua | 39 ++++++++++--------- 2 files changed, 55 insertions(+), 56 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 11140c2f..13441a80 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -849,14 +849,20 @@ function Path:is_relative(to) return self._flavor:is_relative(self, to) end +---@class plenary.Path2.make_relative.Opts +---@field walk_up boolean? walk up to the provided path using '..' (default: `false`) + +---@param to string|plenary.Path2? +---@param opts plenary.Path2.make_relative.Opts? ---@return plenary.Path2 -function Path:_make_relative(to, walk_up) +function Path:_make_relative(to, opts) + opts = opts or {} vim.validate { to = { to, is_path_like_opt }, - walk_up = { walk_up, "b", true }, + walk_up = { opts.walk_up, "b", true }, } - walk_up = vim.F.if_nil(walk_up, false) + opts.walk_up = vim.F.if_nil(opts.walk_up, false) if to == nil then if not self:is_absolute() then @@ -877,7 +883,7 @@ function Path:_make_relative(to, walk_up) return Path:new((abs:sub(#to:absolute() + 1):gsub("^" .. self.sep, ""))) end - if not walk_up then + if not opts.walk_up then error(string.format("'%s' is not in the subpath of '%s'", self, to)) end @@ -904,25 +910,25 @@ end --- if path is already a relative path, it will first be turned absolute using --- the cwd then made relative to the `to` path. ---@param to string|plenary.Path2? path to make relative to (default: cwd) ----@param walk_up boolean? walk up to the provided path using '..' (default: `false`) +---@param opts plenary.Path2.make_relative.Opts? ---@return string -function Path:make_relative(to, walk_up) - return self:_make_relative(to, walk_up).filename +function Path:make_relative(to, opts) + return self:_make_relative(to, opts).filename end --- Normalize path, resolving any intermediate ".." --- eg. `a//b`, `a/./b`, `a/foo/../b` will all become `a/b` --- Can optionally convert a path to relative to another. ----@param relative_to string|plenary.Path2|nil path to make relative to, if nil path isn't made relative ----@param walk_up boolean? walk up to the make relative path (if provided) using '..' (default: `false`) +---@param relative_to string|plenary.Path2? path to make relative to, if nil path isn't made relative +---@param opts plenary.Path2.make_relative.Opts? ---@return string -function Path:normalize(relative_to, walk_up) +function Path:normalize(relative_to, opts) local p if relative_to == nil then p = self else local ok - ok, p = pcall(Path._make_relative, self, relative_to, walk_up) + ok, p = pcall(Path._make_relative, self, relative_to, opts) p = ok and assert(p) or self end @@ -967,7 +973,7 @@ function Path:shorten(len, excludes) return self:_filename(nil, nil, new_parts) end ----@class plenary.Path2.mkdirOpts +---@class plenary.Path2.mkdir.Opts --- permission to give to the directory, this is modified by the process's umask --- (default: `o777`) --- (currently not implemented in Windows by libuv) @@ -976,7 +982,7 @@ end ---@field exists_ok boolean? ignores error if true and target directory exists (default: `false`) --- Create directory ----@param opts plenary.Path2.mkdirOpts? +---@param opts plenary.Path2.mkdir.Opts? function Path:mkdir(opts) opts = opts or {} vim.validate { @@ -1026,7 +1032,7 @@ function Path:rmdir() end end ----@class plenary.Path2.touchOpts +---@class plenary.Path2.touch.Opts ---@field mode integer? permissions to give to the file if created (default: `o666`) --- create parent directories if true and necessary. can optionally take a mode value --- for the mkdir function (default: `false`) @@ -1034,7 +1040,7 @@ end --- 'touch' file. --- If it doesn't exist, creates it including optionally, the parent directories ----@param opts plenary.Path2.touchOpts? +---@param opts plenary.Path2.touch.Opts? function Path:touch(opts) opts = opts or {} vim.validate { @@ -1069,11 +1075,11 @@ function Path:touch(opts) end end ----@class plenary.Path2.rmOpts +---@class plenary.Path2.rm.Opts ---@field recursive boolean? remove directories and their content recursively (defaul: `false`) --- rm file or optional recursively remove directories and their content recursively ----@param opts plenary.Path2.rmOpts? +---@param opts plenary.Path2.rm.Opts? function Path:rm(opts) opts = opts or {} vim.validate { recursive = { opts.recursive, "b", true } } @@ -1109,19 +1115,16 @@ function Path:rm(opts) self:rmdir() end ----@class plenary.Path2.renameOpts ----@field new_name string|plenary.Path2 destination path - ----@param opts plenary.Path2.renameOpts +---@param new_name string|plenary.Path2 destination path ---@return plenary.Path2 -function Path:rename(opts) - vim.validate { new_name = { opts.new_name, is_path_like } } +function Path:rename(new_name) + vim.validate { new_name = { new_name, is_path_like } } - if not opts.new_name or opts.new_name == "" then + if not new_name or new_name == "" then error "Please provide the new name!" end - local new_path = self:parent() / opts.new_name ---@type plenary.Path2 + local new_path = self:parent() / new_name ---@type plenary.Path2 if new_path:exists() then error "File or directory already exists!" @@ -1134,8 +1137,7 @@ function Path:rename(opts) return new_path end ----@class plenary.Path2.copyOpts ----@field destination string|plenary.Path2 target file path to copy to +---@class plenary.Path2.copy.Opts ---@field recursive boolean? whether to copy folders recursively (default: `false`) ---@field override boolean? whether to override files (default: `true`) ---@field respect_gitignore boolean? skip folders ignored by all detected `gitignore`s (default: `false`) @@ -1143,13 +1145,15 @@ end ---@field parents boolean? whether to create possibly non-existing parent dirs of `opts.destination` (default: `false`) ---@field exists_ok boolean? whether ok if `opts.destination` exists, if so folders are merged (default: `true`) ----@param opts plenary.Path2.copyOpts +---@param destination string|plenary.Path2 target file path to copy to +---@param opts plenary.Path2.copy.Opts? --- a flat dictionary of destination paths and their copy result. --- if successful, `{ success = true }`, else `{ success = false, err = "some msg" }` ---@return {[plenary.Path2]: {success:boolean, err: string?}} -function Path:copy(opts) +function Path:copy(destination, opts) + opts = opts or {} vim.validate { - destination = { opts.destination, is_path_like }, + destination = { destination, is_path_like }, recursive = { opts.recursive, "b", true }, override = { opts.override, "b", true }, } @@ -1157,7 +1161,7 @@ function Path:copy(opts) opts.recursive = vim.F.if_nil(opts.recursive, false) opts.override = vim.F.if_nil(opts.override, true) - local dest = self:parent() / opts.destination ---@type plenary.Path2 + local dest = self:parent() / destination ---@type plenary.Path2 local success = {} ---@type {[plenary.Path2]: {success: boolean, err: string?}} @@ -1196,11 +1200,7 @@ function Path:copy(opts) for _, entry in ipairs(data) do local entry_path = Path:new(entry) local new_dest = dest / entry_path.name - -- clear destination as it might be Path table otherwise failing w/ extend - opts.destination = nil - local new_opts = vim.tbl_deep_extend("force", opts, { destination = new_dest }) - -- nil: not overriden if `override = false` - local res = entry_path:copy(new_opts) + local res = entry_path:copy(new_dest, opts) success = vim.tbl_deep_extend("force", success, res) end return success diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 95c8b475..faecf4f1 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -375,14 +375,14 @@ describe("Path2", function() local p = Path:new { "foo", "bar", "baz" } local cwd = Path:new { "foo", "foo_inner" } local expect = Path:new { "..", "bar", "baz" } - assert.are.same(expect.filename, p:make_relative(cwd, true)) + assert.are.same(expect.filename, p:make_relative(cwd, { walk_up = true })) end) it_cross_plat("can walk_up to root", function() local p = Path:new { root(), "foo", "bar", "baz" } local cwd = Path:new { root(), "def" } local expect = Path:new { "..", "foo", "bar", "baz" } - assert.are.same(expect.filename, p:make_relative(cwd, true)) + assert.are.same(expect.filename, p:make_relative(cwd, { walk_up = true })) end) it_win("handles drive letters case insensitively", function() @@ -427,7 +427,7 @@ describe("Path2", function() local p = Path:new { path.home, "a", "..", "", "a", "b" } local cwd = Path:new { path.home, "c" } local expect = Path:new { "..", "a", "b" } - assert.are.same(expect.filename, p:normalize(cwd, true)) + assert.are.same(expect.filename, p:normalize(cwd, { walk_up = true })) end) it_win("windows drive relative paths", function() @@ -641,7 +641,7 @@ describe("Path2", function() local new_p assert.no_error(function() - new_p = p:rename { new_name = "not_a_random_filename.lua" } + new_p = p:rename "not_a_random_filename.lua" end) assert.not_nil(new_p) assert.are.same("not_a_random_filename.lua", new_p.name) @@ -655,7 +655,7 @@ describe("Path2", function() assert.is_true(p:exists()) assert.has_error(function() - p:rename { new_name = "" } + p:rename "" end) assert.has_error(function() ---@diagnostic disable-next-line: missing-fields @@ -674,7 +674,7 @@ describe("Path2", function() local new_p assert.no_error(function() - new_p = p:rename { new_name = "../some_random_filename.lua" } + new_p = p:rename "../some_random_filename.lua" end) assert.not_nil(new_p) assert.are.same(Path:new("../some_random_filename.lua"):absolute(), new_p:absolute()) @@ -691,7 +691,7 @@ describe("Path2", function() assert.is_true(p2:exists()) assert.has_error(function() - p1:rename { new_name = "not_a_random_filename.lua" } + p1:rename "not_a_random_filename.lua" end) assert.are.same(p1.filename, "a_random_filename.lua") end) @@ -706,7 +706,7 @@ describe("Path2", function() local new_p assert.no_error(function() - new_p = p1:rename { new_name = p2 } + new_p = p1:rename(p2) end) assert.not_nil(new_p) assert.are.same("not_a_random_filename.lua", new_p.name) @@ -730,7 +730,7 @@ describe("Path2", function() assert.is_true(p1:exists()) assert.no_error(function() - p1:copy { destination = "not_a_random_filename.rs" } + p1:copy "not_a_random_filename.rs" end) assert.is_true(p1:exists()) assert.are.same(p1.filename, "a_random_filename.rs") @@ -744,7 +744,7 @@ describe("Path2", function() assert.is_true(p1:exists()) assert.no_error(function() - p1:copy { destination = p2 } + p1:copy(p2) end) assert.is_true(p1:exists()) assert.is_true(p2:exists()) @@ -758,7 +758,7 @@ describe("Path2", function() assert.is_true(p:exists()) assert.no_error(function() - p:copy { destination = "../some_random_filename.rs" } + p:copy "../some_random_filename.rs" end) assert.is_true(p:exists()) end) @@ -771,9 +771,8 @@ describe("Path2", function() assert.is_true(p1:exists()) assert.is_true(p2:exists()) - assert(pcall(p1.copy, p1, { destination = "not_a_random_filename.rs", override = false })) assert.no_error(function() - p1:copy { destination = "not_a_random_filename.rs", override = false } + p1:copy("not_a_random_filename.rs", { override = false }) end) assert.are.same(p1.filename, "a_random_filename.rs") assert.are.same(p2.filename, "not_a_random_filename.rs") @@ -786,7 +785,7 @@ describe("Path2", function() local trg_dir = Path:new "trg" assert.has_error(function() - src_dir:copy { destination = trg_dir, recursive = false } + src_dir:copy(trg_dir, { recursive = false }) end) end) @@ -834,7 +833,7 @@ describe("Path2", function() it_cross_plat("hidden=true, override=true", function() local success assert.no_error(function() - success = src_dir:copy { destination = trg_dir, recursive = true, override = true, hidden = true } + success = src_dir:copy(trg_dir, { recursive = true, override = true, hidden = true }) end) assert.not_nil(success) @@ -847,12 +846,12 @@ describe("Path2", function() it_cross_plat("hidden=true, override=false", function() -- setup assert.no_error(function() - src_dir:copy { destination = trg_dir, recursive = true, override = true, hidden = true } + src_dir:copy(trg_dir, { recursive = true, override = true, hidden = true }) end) local success assert.no_error(function() - success = src_dir:copy { destination = trg_dir, recursive = true, override = false, hidden = true } + success = src_dir:copy(trg_dir, { recursive = true, override = false, hidden = true }) end) assert.not_nil(success) @@ -867,7 +866,7 @@ describe("Path2", function() it_cross_plat("hidden=false, override=true", function() local success assert.no_error(function() - success = src_dir:copy { destination = trg_dir, recursive = true, override = true, hidden = false } + success = src_dir:copy(trg_dir, { recursive = true, override = true, hidden = false }) end) assert.not_nil(success) @@ -880,12 +879,12 @@ describe("Path2", function() it_cross_plat("hidden=false, override=false", function() -- setup assert.no_error(function() - src_dir:copy { destination = trg_dir, recursive = true, override = true, hidden = true } + src_dir:copy(trg_dir, { recursive = true, override = true, hidden = true }) end) local success assert.no_error(function() - success = src_dir:copy { destination = trg_dir, recursive = true, override = false, hidden = false } + success = src_dir:copy(trg_dir, { recursive = true, override = false, hidden = false }) end) assert.not_nil(success)