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: | diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua new file mode 100644 index 00000000..13441a80 --- /dev/null +++ b/lua/plenary/path2.lua @@ -0,0 +1,1578 @@ +local bit = require "plenary.bit" +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 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 +---@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 = false, +} + +setmetatable(_WindowsPath, { __index = _WindowsPath }) + +---@param p string +---@return string +function _WindowsPath:convert_altsep(p) + return (p:gsub(self.altsep, self.sep)) +end + +--- 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(p) + p = self:convert_altsep(p) + + local unc_prefix = "\\\\?\\UNC\\" + 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 = p:sub(1, 8):upper() == unc_prefix and 8 or 2 + local index = p:find(self.sep, start) + if index == nil then + return p, "", "" -- paths only has drive info + end + + local index2 = p:find(self.sep, index + 1) + if index2 == nil then + return p, "", "" -- still paths only has drive info + end + return p:sub(1, index2 - 1), self.sep, p:sub(index2 + 1) + else + -- Relative path with root, eg. \Windows + return "", p:sub(1, 1), p:sub(2) + end + elseif p:sub(2, 2) == ":" then + if p:sub(3, 3) == self.sep then + -- absolute path with drive, eg. C:\Windows + return p:sub(1, 2), self.sep, p:sub(3) + else + -- relative path with drive, eg. C:Windows + return p:sub(1, 2), "", p:sub(3) + end + else + -- relative path, eg. Windows + 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 + +---@param parts string[] +---@param sep string +---@return string[] new_parts +function _WindowsPath:expand(parts, sep) + 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 + local sub_parts = {} + local i = 1 + + 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, table.concat(sub_parts)) + end + + 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 = true, +} +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, 1) == self.sep then + return "", self.sep, part:sub(2) + end + 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 #parts > 1 and parts[#parts]:sub(-1) == self.sep then + table.insert(parts, p) + else + table.insert(parts, self.sep .. p) + end + end + return table.concat(parts) +end + +---@param parts string[] +---@return string[] new_parts +function _PosixPath:expand(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) + else + table.insert(sub_parts, part:sub(start, end_)) + end + end + + local new_parts = {} + for _, part in ipairs(parts) do + 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, table.concat(sub_parts)) + end + + 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, + -- S_IFREG = 0o100000 # regular file + REG = 0x8000, +} + +---@class plenary.path2 +---@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) +--- +--- 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({ + S_IF = S_IF, +}, { + __index = function(t, k) + local raw = rawget(t, k) + if raw then + 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 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, +}) + +path.root = (function() + if not iswin then + return function() + return "/" + end + else + return function(base) + base = base or path.home + local drv, root, _ = _WindowsPath:split_root(base) + return ((drv .. root):gsub("\\", path.sep)) + end + end +end)() + +---@param parts string[] +---@param flavor plenary._Path +---@return string drv +---@return string root +---@return string[] +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) + + 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 + + for part in vim.gsplit(rel, sep) do + if part ~= "" and part ~= "." then + table.insert(parsed, part) + end + end + + 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 +---@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 +---@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 } + +---@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 == "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 + end + + if k == "filename" then + t.filename = t:_filename() + return t.filename + end + + if k == "sep" then + return path.sep + end + + if k == "cwd" then + t.cwd = vim.fn.getcwd() + return t.cwd + 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") + + return self:joinpath(other) +end + +---@param self plenary.Path2 +---@return string +Path.__tostring = function(self) + return self.filename +end + +---@param self plenary.Path2 +---@param other string|plenary.Path2 +---@return boolean +Path.__eq = function(self, other) + assert(Path.is_path(self)) + + 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 + + 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 = { + __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 +---@return plenary.Path2 +function Path:new(...) + local args = { ... } + + if #args == 1 then + 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 + end + + local raw_parts = {} + for _, a in ipairs(args) do + if self.is_path(a) then + vim.list_extend(raw_parts, a._raw_parts) + else + if a ~= "" then + table.insert(raw_parts, a) + end + end + end + + local _flavor = iswin and _WindowsPath or _PosixPath + + local proxy = { _flavor = _flavor, _raw_parts = raw_parts } + setmetatable(proxy, Path) + + local obj = { __inner = proxy } + setmetatable(obj, _readonly_mt) + + return obj +end + +---@private +---@param drv string? +---@param root string? +---@param relparts string[]? +---@return string +function Path:_filename(drv, root, relparts) + 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) -- luacheck: ignore + root = self.root ~= "" and self.sep:rep(#self.root) or "" + end + + relparts = vim.F.if_nil(relparts, self.relparts) + local relpath = table.concat(relparts, self.sep) + local res = drv .. root .. relpath + + if res ~= "" then + return res + end + return "." +end + +---@param x any +---@return boolean +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) + return x == nil or is_path_like(x) +end + +---@return boolean +function Path:is_absolute() + if self.root == "" then + return false + end + + 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 = uv.fs_stat(self:absolute()) + if res == nil then + error(err) + end + 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. +---@return uv.aliases.fs_stat_table +function Path:lstat() + local res, err = uv.fs_lstat(self:absolute()) + if res == nil then + error(err) + end + return res +end + +---@return integer # numeric mode in octal values +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()) + return stat ~= nil and not vim.tbl_isempty(stat) +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 + +--- 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 relparts string[] path parts +---@return string[] +local function resolve_dots(relparts) + local new_parts = {} + for _, part in ipairs(relparts) 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 + + return new_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 +--- +--- DOES NOT expand environment variables and home/user constructs (`~` and `~user`). +--- Use `expand` for this. +--- +--- respects 'shellslash' on Windows +---@return string +function Path:absolute() + if self._absolute then + return self._absolute + end + + local relparts = resolve_dots(self.relparts) + if self:is_absolute() then + self._absolute = self:_filename(nil, nil, relparts) + 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 + local p = uv.fs_realpath(self:_filename()) or Path:new({ self.cwd, self }):absolute() + if self.path.isshellslash then + self._absolute = p:gsub("\\", self.sep) + else + self._absolute = p + end + end + return self._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) + + if filename:sub(1, 2) == "~" .. self.sep then + filename = self.path.home .. filename:sub(2) + end + + return filename +end + +---@param ... plenary.Path2Args +---@return plenary.Path2 +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 = {} + for p in self:iter_parents() do + table.insert(res, p) + end + return res +end + +---@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) + vim.validate { to = { to, is_path_like } } + + if not Path.is_path(to) then + to = Path:new(to) + end + ---@cast to plenary.Path2 + + if to == self then + return true + end + + 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, opts) + opts = opts or {} + vim.validate { + to = { to, is_path_like_opt }, + walk_up = { opts.walk_up, "b", true }, + } + + opts.walk_up = vim.F.if_nil(opts.walk_up, false) + + if to == nil then + if not self:is_absolute() then + return Path:new "." + end + + to = Path:new(self.cwd) + elseif type(to) == "string" then + to = Path:new(to) ---@cast to plenary.Path2 + end + + local abs = self:absolute() + if abs == to:absolute() then + return Path:new "." + end + + if self:is_relative(to) then + return Path:new((abs:sub(#to:absolute() + 1):gsub("^" .. self.sep, ""))) + end + + if not opts.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, "..") + if abs:sub(1, #parent) == parent then + common_path = parent + break + end + end + + if not common_path then + error(string.format("'%s' and '%s' have different anchors", self, to)) + end + + local res_path = abs:sub(#common_path + 1):gsub("^" .. self.sep, "") + table.insert(steps, res_path) + 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 opts plenary.Path2.make_relative.Opts? +---@return string +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? 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, opts) + local p + if relative_to == nil then + p = self + else + local ok + ok, p = pcall(Path._make_relative, self, relative_to, opts) + p = ok and assert(p) or self + end + + local relparts = resolve_dots(p.relparts) + return p:_filename(nil, nil, relparts) +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) + 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 }) + + 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 excl_set[i] and not excl_set[neg_i] then + part = part:sub(1, len) + end + table.insert(new_parts, part) + end + + return self:_filename(nil, nil, new_parts) +end + +---@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) +---@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`) + +--- Create directory +---@param opts plenary.Path2.mkdir.Opts? +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) -- o777 + 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 + end + if err_code == "EEXIST" then + return + 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 + 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.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`) +---@field parents boolean|integer? + +--- 'touch' file. +--- If it doesn't exist, creates it including optionally, the parent directories +---@param opts plenary.Path2.touch.Opts? +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, FILE_MODE) -- o666 + 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 + end + + if not not opts.parents then + local mode = type(opts.parents) == "number" and opts.parents or nil ---@cast mode number? + self:parent():mkdir { mode = mode, parents = true, exists_ok = true } + end + + local fd, err = uv.fs_open(self:absolute(), "w", opts.mode) + if fd == nil then + error(err) + end + + local ok + ok, err = uv.fs_close(fd) + if not ok then + error(err) + end +end + +---@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.rm.Opts? +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 + local ok, err, code = uv.fs_unlink(self:absolute()) + if ok or code == "ENOENT" 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 + 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, code = uv.fs_rmdir((p / dir):absolute()) + if err and code ~= "ENOENT" then + error(err) + end + end + end + + self:rmdir() +end + +---@param new_name string|plenary.Path2 destination path +---@return plenary.Path2 +function Path:rename(new_name) + vim.validate { new_name = { new_name, is_path_like } } + + if not new_name or new_name == "" then + error "Please provide the new name!" + end + + local new_path = self:parent() / 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 + +---@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`) +---@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 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(destination, opts) + opts = opts or {} + vim.validate { + destination = { 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) + + local dest = self:parent() / 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 + + 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) + 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 + local res = entry_path:copy(new_dest, 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 +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 + + if callback == nil then + return self:_read_sync() + end + 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", FILE_MODE) + if fd == nil then + error(err) + end + + local data + data, err = uv.fs_read(fd, stat.size, 0) + 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) +function Path:_read_async(callback) + uv.fs_open(self:absolute(), "r", FILE_MODE, 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 + uv.fs_close(fd, function(err_close) + if err_close then + error(err_close) + end + callback(data) + end) + 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 + +--- 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) + vim.validate { lines = { lines, "n", true } } + + 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", FILE_MODE) + 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` + 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 + + 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("[\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) + local chunk_size = 256 + + local fd, err = uv.fs_open(self:absolute(), "r", FILE_MODE) + if fd == nil then + error(err) + end + + local data = {} + local read_chunk ---@type string? + local index, count = stat.size, -1 + 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 == 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 + + 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("[\r\n]$", "")) +end + +---@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 { + offset = { offset, "n" }, + length = { length, "n" }, + } + + local stat = self:_get_readable_stat() + local fd, err = uv.fs_open(self:absolute(), "r", FILE_MODE) + 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 + 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") +---@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, FILE_MODE) + 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 + +--- 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) + 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 + +--- 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 new file mode 100644 index 00000000..faecf4f1 --- /dev/null +++ b/tests/plenary/path2_spec.lua @@ -0,0 +1,1335 @@ +local Path = require "plenary.path2" +local path = Path.path +local compat = require "plenary.compat" +local uv = vim.loop +local iswin = uv.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_win(name, test_fn) + if not iswin then + return + end + + 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_win(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 + +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 = { + "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() + local readme_path = vim.fn.fnamemodify("README.md", ":p") + local license_path = vim.fn.fnamemodify("LICENSE", ":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" }, + { { "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 + { ".", "." }, + } + + 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, p.relparts) + 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]] }, + { [[\\.\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()) + + if nossl then + paths = vim.tbl_map(function(tc) + return { tc[1], (tc[2]:gsub("/", "\\")) } + end, paths) + end + + return paths + 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 drive = Path:new(vim.fn.getcwd()).drv + 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 }, + { drive .. [[lua/../README.md]], readme_path, false }, + { { drive, "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.is_true(Path:new("README.md"):exists()) + end) + + it_cross_plat("returns false for files that do not exist", function() + assert.is_false(Path:new("asdf.md"):exists()) + end) + end) + + describe(".is_dir()", function() + it_cross_plat("should find directories that exist", function() + assert.is_true(Path:new("lua"):is_dir()) + end) + + it_cross_plat("should return false when the directory does not exist", function() + assert.is_false(Path:new("asdf"):is_dir()) + end) + + it_cross_plat("should not show files as directories", function() + assert.is_false(Path:new("README.md"):is_dir()) + end) + end) + + describe(".is_file()", function() + it_cross_plat("should not allow directories", function() + assert.is_true(not Path:new("lua"):is_file()) + end) + + it_cross_plat("should return false when the file does not exist", function() + assert.is_true(not Path:new("asdf"):is_file()) + end) + + it_cross_plat("should show files as file", function() + assert.is_true(Path:new("README.md"):is_file()) + end) + end) + + describe(":make_relative", function() + 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(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.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(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(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 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 relative = Path:new(p.filename):make_relative(p.filename) + assert.are.same(".", 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_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, { 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, { walk_up = 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, { walk_up = 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) + + 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() + 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("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" + + p:rmdir() + assert.is_false(p:exists()) + + p:mkdir() + assert.is_true(p:exists()) + assert.is_true(p:is_dir()) + + 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) + end) + + describe("touch/rm", 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.not_error(function() + p:touch() + end) + assert.is_true(p:exists()) + + assert.not_error(function() + p:rm() + end) + assert.is_true(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 lines = p:readlines() + + assert.no_error(function() + p:touch() + end) + + assert.is_true(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.has_error(function() + p:touch { parents = false } + end) + assert.is_false(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.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) + + 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 "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 "" + 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 "../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 "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(p2) + end) + assert.not_nil(new_p) + assert.are.same("not_a_random_filename.lua", new_p.name) + 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 "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(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 "../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.no_error(function() + 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") + 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(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 + + 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(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(trg_dir, { recursive = true, override = true, hidden = true }) + end) + + local success + assert.no_error(function() + success = src_dir:copy(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(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(trg_dir, { recursive = true, override = true, hidden = true }) + end) + + local success + assert.no_error(function() + success = src_dir:copy(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()) + 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) + + 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) + + 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 "TMP_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, diff_str(should, data)) + end) + + it_cross_plat("should read the first line of file", function() + 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 "TMP_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, 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: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 "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. + +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, diff_str(should, data)) + end) + + it_cross_plat("should read the last line of file", function() + 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 "TMP_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, 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: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) + + 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 "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 "TMP_LICENSE" + local data = p:readbyterange(-10, 10) + local should = "SOFTWARE.\n" + 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 "TMP_LICENSE" + local data = p:readbyterange(0, 11) + local should = "MIT License" + assert.are.same(should, data, diff_str(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.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 = vim.fn.fnamemodify("lua/say.lua", ":p") + assert.are.same(expect, res.filename) + end) + + it_cross_plat("doesn't find file", function() + 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") + + 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_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_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() + if not iswin then + return + end + + 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("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() + local p = Path:new "~/hello" + local expect = Path:new { path.home, "hello" } + assert.are.same(expect.filename, p:expand()) + end) + + it_cross_plat("does not matches ~user", function() + local p = Path:new "~otheruser/hello" + assert.are.same(p.filename, p:expand()) + end) + + uv.os_unsetenv "FOOVAR" + uv.os_unsetenv "BARVAR" + end) +end) + +tmp_license:rm()