diff --git a/.github/workflows/lua_ls-typecheck.yml b/.github/workflows/lua_ls-typecheck.yml index 76d95ba..aa1541b 100644 --- a/.github/workflows/lua_ls-typecheck.yml +++ b/.github/workflows/lua_ls-typecheck.yml @@ -3,7 +3,8 @@ on: pull_request: ~ push: branches: - - '*' + - 'main' + - 'v*' jobs: build: diff --git a/README.md b/README.md index 858b2ff..724e73b 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,9 @@ earliest.** Status](https://img.shields.io/github/actions/workflow/status/pysan3/pathlib.nvim/lua_ls-typecheck.yml?style=for-the-badge)](https://github.com/pysan3/pathlib.nvim/actions/workflows/lua_ls-typecheck.yml) [![LuaRocks](https://img.shields.io/luarocks/v/pysan3/pathlib.nvim?logo=lua&color=purple&style=for-the-badge)](https://luarocks.org/modules/pysan3/pathlib.nvim) -# TL;DR +# Usage Example + +## Create Path Object ``` lua local Path = require("pathlib.base") @@ -44,6 +46,46 @@ local bar = foo .. "bar.txt" -- create siblings (just like `.//../bar.txt`) assert(tostring(bar) == "folder/bar.txt") ``` +## Create and Manipulate Files / Directories + +``` lua +local luv = vim.loop +local Path = require("pathlib.base") + +local new_file = Path.new("./new/folder/foo.txt") +new_file:parent():mkdir(Path.permission("rwxr-xr-x"), true) -- (permission, recursive) + +-- You don't need above line if you specify recursive = true in `open`; all parents will be created +local fd, err_name, err_msg = new_file:open("w", Path.permission("rw-r--r--"), true) +assert(fd ~= nil, "File creation failed. " .. err_name .. err_msg) +luv.fs_write(fd, "File Content\n") +luv.fs_close(fd) + +local content = new_file:read(0) +assert(content == "File Content\n") + +new_file:copy(new_file .. "bar.txt") +new_file:symlink_to(new_file .. "baz.txt") +``` + +## Scan Directories + +``` lua +-- Continue from above +for path in new_file:parent():iterdir() do + -- path will be [Path("./new/folder/foo.txt"), Path("./new/folder/bar.txt"), Path("./new/folder/baz.txt")] +end + +-- fs_scandir-like usage +new_file:parent():iterdir_async(function(path, fs_type) -- callback on all files + vim.print(tostring(path), fs_type) +end, function(error) -- on error + vim.print("Error: " .. error) +end, function(count) -- on exit + vim.print("Scan Finished. " .. count .. " files found.") +end) +``` + # TODO - API documentation diff --git a/README.norg b/README.norg index 4c72356..df515b1 100644 --- a/README.norg +++ b/README.norg @@ -35,7 +35,8 @@ version: 1.1.1 {https://github.com/pysan3/pathlib.nvim/actions/workflows/lua_ls-typecheck.yml}[!{https://img.shields.io/github/actions/workflow/status/pysan3/pathlib.nvim/lua_ls-typecheck.yml?style=for-the-badge}[Build Status]] {https://luarocks.org/modules/pysan3/pathlib.nvim}[!{https://img.shields.io/luarocks/v/pysan3/pathlib.nvim?logo=lua&color=purple&style=for-the-badge}[LuaRocks]] -* TL;DR +* Usage Example +** Create Path Object @code lua local Path = require("pathlib.base") @@ -51,6 +52,44 @@ version: 1.1.1 assert(tostring(bar) == "folder/bar.txt") @end +** Create and Manipulate Files / Directories + @code lua + local luv = vim.loop + local Path = require("pathlib.base") + + local new_file = Path.new("./new/folder/foo.txt") + new_file:parent():mkdir(Path.permission("rwxr-xr-x"), true) -- (permission, recursive) + + -- You don't need above line if you specify recursive = true in `open`; all parents will be created + local fd, err_name, err_msg = new_file:open("w", Path.permission("rw-r--r--"), true) + assert(fd ~= nil, "File creation failed. " .. err_name .. err_msg) + luv.fs_write(fd, "File Content\n") + luv.fs_close(fd) + + local content = new_file:read(0) + assert(content == "File Content\n") + + new_file:copy(new_file .. "bar.txt") + new_file:symlink_to(new_file .. "baz.txt") + @end + +** Scan Directories + @code lua + -- Continue from above + for path in new_file:parent():iterdir() do + -- path will be [Path("./new/folder/foo.txt"), Path("./new/folder/bar.txt"), Path("./new/folder/baz.txt")] + end + + -- fs_scandir-like usage + new_file:parent():iterdir_async(function(path, fs_type) -- callback on all files + vim.print(tostring(path), fs_type) + end, function(error) -- on error + vim.print("Error: " .. error) + end, function(count) -- on exit + vim.print("Scan Finished. " .. count .. " files found.") + end) + @end + * TODO - API documentation - Windows implementation, test environment. diff --git a/lua/pathlib/base.lua b/lua/pathlib/base.lua index c9a0011..381996c 100644 --- a/lua/pathlib/base.lua +++ b/lua/pathlib/base.lua @@ -20,14 +20,10 @@ setmetatable(Path, { end, }) ----Create a new Path object +---Private init method to create a new Path object ---@param ... string | PathlibPath # List of string and Path objects ----@return PathlibPath -function Path.new(...) - local self = setmetatable({}, Path) - self._raw_paths = utils.lists.str_list.new() - self._drive_name = "" - self.__windows_panic = false +function Path:_init(...) + local run_resolve = false for i, s in ipairs({ ... }) do if utils.tables.is_type_of(s, const.path_module_enum.PathlibPath) then ---@cast s PathlibPath @@ -46,8 +42,9 @@ function Path.new(...) local splits = vim.split(path, "/", { plain = true, trimempty = false }) if #splits == 0 then goto continue + elseif vim.tbl_contains(splits, "..") then -- deal with '../' later in `self:resolve()` + run_resolve = true end - -- elseif -- TODO: deal with `../` self._raw_paths:extend(splits) else error("PathlibPath(new): ValueError: Invalid type as argument: " .. ("%s (%s: %s)"):format(type(s), i, s)) @@ -55,15 +52,64 @@ function Path.new(...) ::continue:: end self:__clean_paths_list() + if run_resolve then + self:resolve() + end +end + +---Create a new Path object +---@param ... string | PathlibPath # List of string and Path objects +---@return PathlibPath +function Path.new(...) + local self = Path.new_empty() + self:_init(...) + return self +end + +function Path.new_empty() + local self = setmetatable({}, Path) + self._raw_paths = utils.lists.str_list.new() + self._drive_name = "" + self.__windows_panic = false return self end +---Create a new Path object as self's child. +---@param ... string +---@return PathlibPath +function Path:new_child(...) + local new = Path.new_all_from(self) + new._raw_paths:extend({ ... }) + return new +end + +---Unpack name and return a new self's child +---@param name string +---@return PathlibPath +function Path:new_child_unpack(name) + local new = Path.new_all_from(self) + for sub in name:gmatch("[/\\]") do + new._raw_paths:append(sub) + end + return new +end + ---Return `vim.fn.getcwd` in Path object ---@return PathlibPath function Path.cwd() return Path(vim.fn.getcwd()) end +---Calculate permission integer from "rwxrwxrwx" notation. +---@param mode_string string +---@return integer +function Path.permission(mode_string) + err.assert_function("Path.permission", function() + return const.check_permission_string(mode_string) + end, "mode_string must be in the form of `rwxrwxrwx` or `-` otherwise.") + return const.permission_from_string(mode_string) +end + function Path:__clean_paths_list() self._raw_paths:filter_internal(nil, 2) if #self._raw_paths > 1 and self._raw_paths[1] == "." then @@ -123,7 +169,7 @@ function Path:__le(other) return (self < other) or (self == other) end ----Concat paths. `Path.cwd() / "foo" / "bar.txt" == "./foo/bar.txt"` +---Concatenate paths. `Path.cwd() / "foo" / "bar.txt" == "./foo/bar.txt"` ---@param other PathlibPath ---@return PathlibPath function Path:__div(other) @@ -134,8 +180,8 @@ function Path:__div(other) return self.new(self, other) end ----Concat paths with the parent of lhs. `Path("./foo/foo.txt") .. "bar.txt" == "./foo/bar.txt"` ----@param other PathlibPath +---Concatenate paths with the parent of lhs. `Path("./foo/foo.txt") .. "bar.txt" == "./foo/bar.txt"` +---@param other PathlibPath | string ---@return PathlibPath -- Path.__concat = function(self, other) function Path:__concat(other) @@ -166,6 +212,25 @@ function Path:__tostring() return path_str end +---Return the group name of the file GID. +function Path:basename() + return fs.basename(tostring(self)) +end + +---Return the group name of the file GID. Same as `str(self) minus self:modify(":r")`. +---@return string # extension of path including the dot (`.`): `.py`, `.lua` etc +function Path:suffix() + local path_str = tostring(self) + local without_ext = vim.fn.fnamemodify(path_str, ":r") + return path_str:sub(without_ext:len() + 1) or "" +end + +---Return the group name of the file GID. Same as `self:modify(":t:r")`. +---@return string # stem of path. (src/version.c -> "version") +function Path:stem() + return vim.fn.fnamemodify(tostring(self), ":t:r") +end + ---Return parent directory of itself. If parent does not exist, returns nil. ---@return PathlibPath? function Path:parent() @@ -205,12 +270,21 @@ function Path:copy_all_from(path) self._raw_paths:filter_internal(nil, 2) end +---Copy all attributes from `path` to self +---@param path PathlibPath +function Path.new_all_from(path) + local self = Path.new_empty() + self.mytype = path.mytype + self._drive_name = path._drive_name + self._raw_paths:extend(path._raw_paths) + return self +end + ---Inherit from `path` and trim `_raw_paths` if specified. ---@param path PathlibPath ---@param trim_num number? # 1 will trim the last entry in `_raw_paths`, 2 will trim 2. function Path.new_from(path, trim_num) - local self = Path.new() - self:copy_all_from(path) + local self = Path.new_all_from(path) if not trim_num or trim_num < 1 then return self end @@ -227,6 +301,15 @@ function Path.stdpath(what) return Path.new(vim.fn.stdpath(what)) end +---Shorthand to `vim.fn.stdpath` and specify child path in later args. +---Mason bin path: `Path.stdpath("data", "mason", "bin")` or `Path.stdpath("data", "mason/bin")` +---@param what string # See `:h stdpath` for information +---@param ... string|PathlibPath # child path after the result of stdpath +---@return PathlibPath +function Path.stdpath_child(what, ...) + return Path.new(vim.fn.stdpath(what), ...) +end + ---Returns whether registered path is absolute ---@return boolean function Path:is_absolute() @@ -263,4 +346,400 @@ function Path:modify(mods) return vim.fn.fnamemodify(tostring(self), mods) end +---Call `fs_stat` with callback. This plugin will not help you here. +---@param follow_symlinks boolean? # Whether to resolve symlinks +---@param callback fun(err: string?, stat: uv.aliases.fs_stat_table?) +function Path:stat_async(follow_symlinks, callback) + err.check_and_raise_typeerror("Path:stat_async", callback, "function") + if follow_symlinks then + luv.fs_stat(tostring(self), callback) + else + luv.fs_lstat(tostring(self), callback) ---@diagnostic disable-line + end +end + +---Return result of `luv.fs_stat`. Use `self:stat_async` to use with callback. +---Returns: `fs_stat_table | (nil, err_name: string, err_msg: string)` +---@param follow_symlinks? boolean # Whether to resolve symlinks +---@return uv.aliases.fs_stat_table|nil stat, string? err_name, string? err_msg +---@nodiscard +function Path:stat(follow_symlinks) + if follow_symlinks then + return luv.fs_stat(tostring(self)) + else + return luv.fs_lstat(tostring(self)) ---@diagnostic disable-line + end +end + +function Path:lstat() + return self:stat(false) +end + +function Path:exists(follow_symlinks) + local stat = self:stat(follow_symlinks) + return stat and true or false +end + +function Path:is_dir(follow_symlinks) + local stat = self:stat(follow_symlinks) + return stat and stat.type == "directory" +end + +function Path:is_file(follow_symlinks) + local stat = self:stat(follow_symlinks) + return stat and stat.type == "file" +end + +function Path:is_symlink() + local stat = self:lstat() + return stat and stat.type == "link" +end + +---Get mode of path object. Use `self:get_type` to get type description in string instead. +---@param follow_symlinks boolean # Whether to resolve symlinks +---@return PathlibModeEnum? +function Path:get_mode(follow_symlinks) + local stat = self:stat(follow_symlinks) + return stat and stat.mode +end + +---Get type description of path object. Use `self:get_mode` to get mode instead. +---@param follow_symlinks boolean # Whether to resolve symlinks +---@return string? +function Path:get_type(follow_symlinks) + local stat = self:stat(follow_symlinks) + return stat and stat.type +end + +---Return whether `other` is the same file or not. +---@param other PathlibPath +---@return boolean +function Path:samefile(other) + local stat = self:stat() + local other_stat = other:stat() + return (stat and other_stat) and (stat.ino == other_stat.ino and stat.dev == stat.dev) or false +end + +function Path:is_mount() + if not self:exists() or not self:is_dir() then + return false + end + local stat = self:stat() + if not stat then + return false + end + local parent_stat = self:parent():stat() + if not parent_stat then + return false + end + if stat.dev ~= parent_stat.dev then + return false + end + return stat.ino and stat.ino == parent_stat.ino +end + +---Make directory. When `recursive` is true, will create parent dirs like shell command `mkdir -p` +---@param mode integer # permission. You may use `Path.permission()` to convert from "rwxrwxrwx" +---@param recursive boolean # if true, creates parent directories as well +function Path:mkdir(mode, recursive) + if recursive then + for parent in self:parents() do + if not parent:exists(true) then + parent:mkdir(mode, true) + else + break + end + end + end + luv.fs_mkdir(tostring(self), mode) +end + +---Make file. When `recursive` is true, will create parent dirs like shell command `mkdir -p` +---@param mode integer # permission. You may use `Path.permission()` to convert from "rwxrwxrwx" +---@param recursive boolean # if true, creates parent directories as well +---@return boolean success, string? err_name, string? err_msg # true if successfully created. +function Path:touch(mode, recursive) + local fd, err_name, err_msg = self:fs_open("w", mode, recursive) + if fd == nil then + return false, err_name, err_msg + else + luv.fs_close(fd) + return true + end +end + +---Copy file to `target` +---@param target PathlibPath # `self` will be copied to `target` +---@return boolean|nil success, string? err_name, string? err_msg # true if successfully created. +function Path:copy(target) + err.assert_function("Path:copy", function() + return utils.tables.is_path_module(target) + end, "target is not a Path object.") + return luv.fs_copyfile(tostring(self), tostring(target)) +end + +---Create a simlink named `self` pointing to `target` +---@param target PathlibPath +---@return boolean|nil success, string? err_name, string? err_msg +function Path:symlink_to(target) + err.assert_function("Path:symlink_to", function() + return utils.tables.is_path_module(target) + end, "target is not a Path object.") + return luv.fs_symlink(tostring(self), tostring(target)) +end + +---Create a hardlink named `self` pointing to `target` +---@param target PathlibPath +---@return boolean|nil success, string? err_name, string? err_msg +function Path:hardlink_to(target) + err.assert_function("Path:hardlink_to", function() + return utils.tables.is_path_module(target) + end, "target is not a Path object.") + return luv.fs_link(tostring(self), tostring(target)) +end + +---Rename `self` to `target`. If `target` exists, fails with false. Ref: `Path:move` +---@param target PathlibPath +---@return boolean|nil success, string? err_name, string? err_msg +function Path:rename(target) + err.assert_function("Path:rename", function() + return utils.tables.is_path_module(target) + end, "target is not a Path object.") + return luv.fs_rename(tostring(self), tostring(target)) +end + +---Move `self` to `target`. Overwrites `target` if exists. Ref: `Path:rename` +---@param target PathlibPath +---@return boolean|nil success, string? err_name, string? err_msg +function Path:move(target) + err.assert_function("Path:move", function() + return utils.tables.is_path_module(target) + end, "target is not a Path object.") + target:unlink() + return luv.fs_rename(tostring(self), tostring(target)) +end + +---@deprecated Use `Path:move` instead. +---@param target PathlibPath +function Path:replace(target) + return self:move(target) +end + +---Resolves path. Eliminates `../` representation. +---Changes internal. (See `Path:resolve_copy` to create new object) +function Path:resolve() + local accum, length = 1, self:len() + for _, value in ipairs(self._raw_paths) do + if value == ".." and accum > 1 then + accum = accum - 1 + else + self._raw_paths[accum] = value + accum = accum + 1 + end + end + for i = accum, length do + self._raw_paths[i] = nil + end +end + +---Resolves path. Eliminates `../` representation and returns a new object. `self` is not changed. +---@return PathlibPath +function Path:resolve_copy() + local accum, length, new = 1, self:len(), self:new_all_from() + for _, value in ipairs(self._raw_paths) do + if value == ".." and accum > 1 then + accum = accum - 1 + else + new._raw_paths[accum] = value + accum = accum + 1 + end + end + for i = accum, length do + new._raw_paths[i] = nil + end + return new +end + +---Get length of `self._raw_paths`. `/foo/bar.txt ==> 3: { "", "foo", "bar.txt" } (root dir counts as 1!!)` +---@return integer +function Path:len() + return #self._raw_paths +end + +---Change the permission of the path to `mode`. +---@param mode integer # permission. You may use `Path.permission()` to convert from "rwxrwxrwx" +---@param follow_symlinks boolean # Whether to resolve symlinks +---@return boolean|nil success, string? err_name, string? err_msg +function Path:chmod(mode, follow_symlinks) + if follow_symlinks then + return luv.fs_chmod(tostring(self:resolve()), mode) + else + return luv.fs_chmod(tostring(self), mode) + end +end + +---Remove this file or link. If the path is a directory, use `Path:rmdir()` instead. +---@return boolean|nil success, string? err_name, string? err_msg +function Path:unlink() + return luv.fs_unlink(tostring(self)) +end + +---Remove this directory. The directory must be empty. +---@return boolean|nil success, string? err_name, string? err_msg +function Path:rmdir() + return luv.fs_rmdir(tostring(self)) +end + +---Call `luv.fs_open`. Use `self:open_async` to use with callback. +---@param flags uv.aliases.fs_access_flags|integer +---@param mode integer # permission. You may use `Path.permission()` to convert from "rwxrwxrwx". +---@param ensure_dir integer|boolean|nil # if not nil, runs `mkdir -p self:parent()` with permission to ensure parent exists. +--- `true` will default to 755. +---@return integer|nil fd, string? err_name, string? err_msg +---@nodiscard +function Path:fs_open(flags, mode, ensure_dir) + if ensure_dir == true then + ensure_dir = const.permission_from_string("rwxr-xr-x") + end + if type(ensure_dir) == "integer" then + self:parent():mkdir(ensure_dir, true) + end + return luv.fs_open(tostring(self), flags, mode) +end + +---Call `luv.fs_open` with callback. Use `self:open` for sync version. +---@param flags uv.aliases.fs_access_flags|integer +---@param mode integer # permission. You may use `Path.permission()` to convert from "rwxrwxrwx". +---@param ensure_dir integer|boolean|nil # if not nil, runs `mkdir -p self:parent()` with permission to ensure parent exists. +--- `true` will default to 755. +---@param callback fun(err: nil|string, fd: integer|nil) +---@return uv_fs_t +function Path:fs_open_async(flags, mode, ensure_dir, callback) + if ensure_dir == true then + ensure_dir = const.permission_from_string("rwxr-xr-x") + end + if type(ensure_dir) == "integer" then + self:parent():mkdir(ensure_dir, true) + end + return luv.fs_open(tostring(self), flags, mode, callback) +end + +---Call `io.read`. Use `self:open_async` and `luv.read` to use with callback. +---@return string|nil data, string? err_msg +---@nodiscard +function Path:io_read() + local file, err_msg = io.open(tostring(self), "r") + if not file then + return nil, err_msg + end + return file:read("*a") +end + +---Call `io.read` with byte read mode. Use `self:open_async` and `luv.read` to use with callback. +---@return string|nil data, string? err_msg +---@nodiscard +function Path:io_read_bytes() + local file, err_msg = io.open(tostring(self), "rb") + if not file then + return nil, err_msg + end + return file:read("*a") +end + +---Call `io.write`. Use `self:open_async` and `luv.write` to use with callback. If failed, returns nil +---@param data string # content +---@return boolean success, string? err_msg +---@nodiscard +function Path:io_write(data) + local file, err_msg = io.open(tostring(self), "w") + if not file then + return false, err_msg + end + local result = file:write(data) + file:flush() + file:close() + return result ---@diagnostic disable-line +end + +---Call `io.write` with byte write mode. Use `self:open_async` and `luv.write` to use with callback. If failed, returns nil +---@param data string # content +---@return boolean success, string? err_msg +---@nodiscard +function Path:io_write_bytes(data) + local file, err_msg = io.open(tostring(self), "w") + if not file then + return false, err_msg + end + local result = file:write(tostring(data)) + file:flush() + file:close() + return result ---@diagnostic disable-line +end + +---Alias to `vim.fs.dir` but returns PathlibPath objects. +---@param opts table|nil Optional keyword arguments: +--- - depth: integer|nil How deep the traverse (default 1) +--- - skip: (fun(dir_name: string): boolean)|nil Predicate +--- to control traversal. Return false to stop searching the current directory. +--- Only useful when depth > 1 +--- +---@return fun(): PathlibPath?, string? # items in {self}. Each iteration yields two values: "path" and "type". +--- "path" is the PathlibPath object. +--- "type" is one of the following: +--- "file", "directory", "link", "fifo", "socket", "char", "block", "unknown". +function Path:iterdir(opts) + local generator = fs.dir(tostring(self), opts) + return function() + local name, fs_type = generator() + if name ~= nil then + return self:new_child(unpack(vim.split(name:gsub("\\", "/"), "/", { plain = true, trimempty = false }))), fs_type + end + end +end + +---Iterate directory with callback receiving PathlibPath objects +---@param callback fun(path: PathlibPath, fs_type: uv.aliases.fs_stat_types): boolean? # function called for each child in directory +--- When `callback` returns `false` the iteration will break out. +---@param on_error? fun(err: string) # function called when `luv.fs_scandir` fails +---@param on_exit? fun(count: integer) # function called after the scan has finished. `count` gives the number of children +function Path:iterdir_async(callback, on_error, on_exit) + luv.fs_scandir(tostring(self), function(e, handler) + if e or not handler then + if on_error and e then + on_error(e) + end + return + end + local counter = 0 + while true do + local name, fs_type = luv.fs_scandir_next(handler) + if not name or not fs_type then + break + end + counter = counter + 1 + if callback(self:new_child_unpack(name), fs_type) == false then + break + end + end + if on_exit then + on_exit(counter) + end + end) +end + +---Run `vim.fn.globpath` on this path. +---@param pattern string # glob pattern expression +---@return fun(): PathlibPath # iterator of results. +function Path:glob(pattern) + local str = tostring(self) + err.assert_function("Path:glob", function() + return not (str:find([[,]])) + end, "Path:glob cannot work on path that contains `,` (comma).") + local result, i = vim.fn.globpath(str, pattern, false, true), 0 ---@diagnostic disable-line + return function() + i = i + 1 + return Path.new(result[i]) + end +end + return Path diff --git a/lua/pathlib/const.lua b/lua/pathlib/const.lua index b8a2509..ed07b41 100644 --- a/lua/pathlib/const.lua +++ b/lua/pathlib/const.lua @@ -1,5 +1,29 @@ local M = {} +M.IS_WINDOWS = vim.fn.has("win32") == 1 or vim.fn.has("win32unix") == 1 + +---@enum PathlibBitOps +M.bitops = { + OR = 1, + XOR = 3, + AND = 4, +} + +---Bitwise operator for uint32 +---@param a integer +---@param b integer +---@param oper PathlibBitOps +---@return integer +M.bitoper = function(a, b, oper) + local s + local r, m = 0, 2 ^ 31 + repeat + s, a, b = a + b + m, a % m, b % m + r, m = r + m * oper % (s - a - b), m / 2 + until m < 1 + return r +end + ---@enum PathlibPathEnum M.path_module_enum = { PathlibPath = "PathlibPath", @@ -7,4 +31,87 @@ M.path_module_enum = { PathlibWindows = "PathlibWindows", } +---Return the portion of the file's mode that can be set by os.chmod() +---@param mode integer +---@return integer +M.fs_imode = function(mode) + return M.bitoper(mode, tonumber("0o7777", 8), M.bitops.AND) +end + +---Return the portion of the file's mode that can be set by os.chmod() +---@param mode integer +---@return integer +M.fs_ifmt = function(mode) + return M.bitoper(mode, tonumber("0o170000", 8), M.bitops.AND) +end + +---@enum PathlibModeEnum +M.fs_mode_enum = { + S_IFDIR = 16384, -- 0o040000 # directory + S_IFCHR = 8192, -- 0o020000 # character device + S_IFBLK = 24576, -- 0o060000 # block device + S_IFREG = 32768, -- 0o100000 # regular file + S_IFIFO = 4096, -- 0o010000 # fifo (named pipe) + S_IFLNK = 40960, -- 0o120000 # symbolic link + S_IFSOCK = 49152, -- 0o140000 # socket file +} + +---@enum PathlibPermissionEnum +M.fs_permission_enum = { + S_ISUID = 2048, -- 0o4000 # set UID bit + S_ISGID = 1024, -- 0o2000 # set GID bit + S_ENFMT = 1024, -- S_ISGID # file locking enforcement + S_ISVTX = 512, -- 0o1000 # sticky bit + S_IREAD = 256, -- 0o0400 # Unix V7 synonym for S_IRUSR + S_IWRITE = 128, -- 0o0200 # Unix V7 synonym for S_IWUSR + S_IEXEC = 64, -- 0o0100 # Unix V7 synonym for S_IXUSR + S_IRWXU = 448, -- 0o0700 # mask for owner permissions + S_IRUSR = 256, -- 0o0400 # read by owner + S_IWUSR = 128, -- 0o0200 # write by owner + S_IXUSR = 64, -- 0o0100 # execute by owner + S_IRWXG = 56, -- 0o0070 # mask for group permissions + S_IRGRP = 32, -- 0o0040 # read by group + S_IWGRP = 16, -- 0o0020 # write by group + S_IXGRP = 8, -- 0o0010 # execute by group + S_IRWXO = 7, -- 0o0007 # mask for others (not in group) permissions + S_IROTH = 4, -- 0o0004 # read by others + S_IWOTH = 2, -- 0o0002 # write by others + S_IXOTH = 1, -- 0o0001 # execute by others +} + +---Check if `mode_string` is a valid representation of permission string. (Eg `rwxrwxrwx`) +---@param mode_string string # "rwxrwxrwx" or '-' where permission not allowed +---@return boolean +M.check_permission_string = function(mode_string) + if type(mode_string) ~= "string" then + return false + end + if #mode_string ~= 9 then + return false + end + local modes = { "r", "w", "x" } + local index = 0 + for value in mode_string:gmatch(".") do + if value ~= "-" and modes[index % 3 + 1] ~= value then + return false + end + index = index + 1 + end + return true +end + +---Return integer of permission representing. Assert `M.check_permission_string` beforehand or this function will not work as expected. +---@param mode_string string +---@return integer +M.permission_from_string = function(mode_string) + local result = 0 + for value in mode_string:gmatch(".") do + result = result * 2 + if value ~= "-" then + result = result + 1 + end + end + return result +end + return M diff --git a/lua/pathlib/init.lua b/lua/pathlib/init.lua index 043c773..bc6241a 100644 --- a/lua/pathlib/init.lua +++ b/lua/pathlib/init.lua @@ -20,9 +20,9 @@ ---< ---@brief ]] -IS_WINDOWS = vim.fn.has("win32") == 1 or vim.fn.has("win32unix") == 1 +local const = require("pathlib.const") -if IS_WINDOWS then +if const.IS_WINDOWS then return require("pathlib.windows") else return require("pathlib.posix") diff --git a/lua/pathlib/posix.lua b/lua/pathlib/posix.lua index e69de29..38d4c28 100644 --- a/lua/pathlib/posix.lua +++ b/lua/pathlib/posix.lua @@ -0,0 +1,18 @@ +local Path = require("pathlib.base") + +---@class PathlibPosixPath +local PosixPath = {} +PosixPath.__index = PosixPath +setmetatable(PosixPath, { + __index = Path, + __call = function(cls, ...) + local self = setmetatable({}, cls) + self:_init(...) + return self + end, +}) + +function PosixPath:_init(...) + Path._init(self, ...) + -- TODO: PosixPath specific init procs +end diff --git a/lua/pathlib/utils/errors.lua b/lua/pathlib/utils/errors.lua index 023aef5..0fa54b4 100644 --- a/lua/pathlib/utils/errors.lua +++ b/lua/pathlib/utils/errors.lua @@ -1,14 +1,34 @@ local M = {} ---Raise ValueError ----@param func_name string # function name where error is raised +---@param annotation string # function name where error is raised ---@param object any # Object with wrong value. -M.value_error = function(func_name, object) +M.value_error = function(annotation, object) local type_msg = type(object) if type(object) == "table" then type_msg = type_msg .. (" (mytype=%s)"):format(object.mytype) end - error(("PathlibPath: ValueError: %s called against unknown type: %s"):format(func_name, type_msg), 2) + error(("PathlibPath: ValueError (%s): called against unknown type: %s"):format(annotation, type_msg), 2) +end + +---Raise TypeError +---@param annotation string # function name where error is raised +---@param object any # Object with wrong value. +M.check_and_raise_typeerror = function(annotation, object, expected_type) + local type_msg = type(object) + if type(object) ~= expected_type then + error(("PathlibPath: TypeError (%s). Expected type %s but got %s"):format(annotation, type_msg, expected_type), 2) + end +end + +---Run assert function and raise error. +---@param annotation string # function name where error is raised +---@param assert_func fun(): boolean # assert function to run +---@param description string # description of the error +M.assert_function = function(annotation, assert_func, description) + if not assert_func() then + error(("PathlibPath: AssertionError (%s). %s"):format(annotation, description), 2) + end end return M diff --git a/spec/posix_manipulate_spec.lua b/spec/posix_manipulate_spec.lua new file mode 100644 index 0000000..b67d2ef --- /dev/null +++ b/spec/posix_manipulate_spec.lua @@ -0,0 +1,83 @@ +local luv = vim.loop +local _ = require("pathlib") +local const = require("pathlib.const") +local file_content = "File Content\n" + +describe("Posix File Manipulation;", function() + if const.IS_WINDOWS then + return + end + + local Path = require("pathlib.base") + local foo = Path.new("./tmp/test_folder/foo.txt") + local parent = foo:parent() + if parent == nil then + return + end + describe("parent", function() + it("()", function() + assert.is_equal("tmp/test_folder", tostring(parent)) + assert.is_not.is_nil(parent) + end) + end) + + describe("mkdir.", function() + parent:mkdir(Path.permission("rwxr-xr-x"), true) + it("exists()", function() + assert.is_true(parent:exists()) + assert.is_not.is_nil(luv.fs_stat("./tmp/test_folder")) + end) + it("is_dir()", function() + assert.is_true(parent:is_dir()) + local stat = luv.fs_stat("./tmp/test_folder") + assert.is_not.is_nil(stat) + ---@cast stat uv.aliases.fs_stat_table + assert.is_equal("directory", stat.type) + end) + end) + + describe("foo:open", function() + local fd, err_name, err_msg = foo:fs_open("w", Path.permission("rw-r--r--"), true) + ---@cast fd integer + it("()", function() + assert.is_not.is_nil(fd) + assert.is_nil(err_name) + assert.is_nil(err_msg) + end) + it("exists()", function() + assert.is_true(foo:is_file()) + local stat = luv.fs_stat("./tmp/test_folder/foo.txt") + assert.is_not.is_nil(stat) + ---@cast stat uv.aliases.fs_stat_table + assert.is_equal("file", stat.type) + end) + it("write()", function() + assert.is_equal(string.len(file_content), luv.fs_write(fd, file_content)) + assert.is_truthy(luv.fs_close(fd)) + end) + it("read ()", function() + assert.is_equal(file_content, foo:io_read()) + end) + end) + + describe("io read / write", function() + it("()", function() + local suc, err_msg = foo:io_write(file_content) + assert.is_true(suc) + assert.is_nil(err_msg) + assert.is_equal(file_content, foo:io_read()) + end) + end) + + describe("iterdir", function() + it("()", function() + foo:copy(foo .. "bar.txt") + foo:symlink_to(foo .. "baz.txt") + local accum = {} + for path in parent:iterdir() do + table.insert(accum, path) + end + assert.is_equal(3, #accum) + end) + end) +end)