diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 34ce434f6..4c7b9414d 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -27,7 +27,8 @@ jobs: - name: install dependencies run: | - luarocks install --only-deps ./neorg-scm-1.rockspec + luarocks init + luarocks install --only-deps ./*.rockspec - name: Type Check Code Base uses: mrcjkb/lua-typecheck-action@v0.2.1 diff --git a/lua/neorg/core/utils.lua b/lua/neorg/core/utils.lua index fbd66096e..c830302fb 100644 --- a/lua/neorg/core/utils.lua +++ b/lua/neorg/core/utils.lua @@ -175,10 +175,11 @@ function utils.notify(msg, log_level) end --- Opens up an array of files and runs a callback for each opened file. ---- @param files string[] An array of files to open. +--- @param files (string|PathlibPath)[] An array of files to open. --- @param callback fun(buffer: integer, filename: string) The callback to invoke for each file. function utils.read_files(files, callback) for _, file in ipairs(files) do + file = tostring(file) local bufnr = vim.uri_to_bufnr(vim.uri_from_fname(file)) local should_delete = not vim.api.nvim_buf_is_loaded(bufnr) diff --git a/lua/neorg/modules/core/completion/module.lua b/lua/neorg/modules/core/completion/module.lua index b724c4ebe..42a9c6733 100644 --- a/lua/neorg/modules/core/completion/module.lua +++ b/lua/neorg/modules/core/completion/module.lua @@ -50,7 +50,7 @@ module.private = { engine = nil, --- Get a list of all norg files in current workspace. Returns { workspace_path, norg_files } - --- @return table? + --- @return { [1]: PathlibPath, [2]: PathlibPath[]|nil }|nil get_norg_files = function() ---@type core.dirman local dirman = neorg.modules.get_module("core.dirman") @@ -182,14 +182,16 @@ module.private = { end local closing_chars = module.private.get_closing_chars(context, true) - for _, file in pairs(files[2]) do - assert(type(file) == "string") + for _, filepath in pairs(files[2]) do + local file = tostring(filepath) local bufnr = dirman.get_file_bufnr(file) if vim.api.nvim_get_current_buf() ~= bufnr then - -- using -6 to go to the end (-1) and remove '.norg' 5 more chars - local link = "{:$" .. file:sub(#files[1] + 1, -6) .. closing_chars - table.insert(res, link) + local rel = filepath:relative_to(files[1], false) + if rel and rel:len() > 0 then + local link = "{:$/" .. rel:with_suffix(""):tostring() .. closing_chars + table.insert(res, link) + end end end diff --git a/lua/neorg/modules/core/dirman/module.lua b/lua/neorg/modules/core/dirman/module.lua index f3f9a3331..0de3cbd28 100644 --- a/lua/neorg/modules/core/dirman/module.lua +++ b/lua/neorg/modules/core/dirman/module.lua @@ -49,8 +49,10 @@ dirman.create_file("my_file", "my_ws", { ``` --]] +local Path = require("pathlib") + local neorg = require("neorg.core") -local config, log, modules, utils = neorg.config, neorg.log, neorg.modules, neorg.utils +local log, modules, utils = neorg.log, neorg.modules, neorg.utils local module = modules.create("core.dirman") @@ -64,7 +66,8 @@ end module.load = function() -- Go through every workspace and expand special symbols like ~ for name, workspace_location in pairs(module.config.public.workspaces) do - module.config.public.workspaces[name] = vim.fn.expand(vim.fn.fnameescape(workspace_location)) ---@diagnostic disable-line -- TODO: type error workaround + -- module.config.public.workspaces[name] = vim.fn.expand(vim.fn.fnameescape(workspace_location)) ---@diagnostic disable-line -- TODO: type error workaround + module.config.public.workspaces[name] = Path(workspace_location):resolve():to_absolute() end modules.await("core.keybinds", function(keybinds) @@ -110,8 +113,9 @@ module.config.public = { -- -- There is always an inbuilt workspace called `default`, whose location is -- set to the Neovim current working directory on boot. + ---@type table workspaces = { - default = vim.fn.getcwd(), + default = Path.cwd(), }, -- The name for the index file. -- @@ -131,7 +135,8 @@ module.config.public = { } module.private = { - current_workspace = { "default", vim.fn.getcwd() }, + ---@type { [1]: string, [2]: PathlibPath } + current_workspace = { "default", Path.cwd() }, } ---@class core.dirman @@ -168,7 +173,7 @@ module.public = { end -- Create the workspace directory if not already present - vim.fn.mkdir(workspace, "p") + workspace:mkdir(Path.const.o755, true) -- Cache the current workspace local current_ws = vim.deepcopy(module.private.current_workspace) @@ -196,13 +201,14 @@ module.public = { --- Dynamically defines a new workspace if the name isn't already occupied and broadcasts the workspace_added event ---@return boolean True if the workspace is added successfully, false otherwise ---@param workspace_name string #The unique name of the new workspace - ---@param workspace_path string #A full path to the workspace root + ---@param workspace_path string|PathlibPath #A full path to the workspace root add_workspace = function(workspace_name, workspace_path) -- If the module already exists then bail if module.config.public.workspaces[workspace_name] then return false end + workspace_path = Path(workspace_path):resolve():to_absolute() -- Set the new workspace and its path accordingly module.config.public.workspaces[workspace_name] = workspace_path -- Broadcast the workspace_added event with the newly added workspace as the content @@ -220,38 +226,27 @@ module.public = { --- If the file we opened is within a workspace directory, returns the name of the workspace, else returns nil get_workspace_match = function() -- Cache the current working directory - module.config.public.workspaces.default = vim.fn.getcwd() + module.config.public.workspaces.default = Path.cwd() - -- Grab the working directory of the current open file - local realcwd = vim.fn.expand("%:p:h") + local file = Path(vim.fn.expand("%:p")) - -- Store the length of the last match - local last_length = 0 + -- Name of matching workspace. Falls back to "default" + local ws_name = "default" - -- The final result - local result = "" + -- Store the depth of the longest match + local longest_match = 0 -- Find a matching workspace for workspace, location in pairs(module.config.public.workspaces) do if workspace ~= "default" then - -- Expand all special symbols like ~ etc. and escape special characters - local expanded = string.gsub(vim.pesc(vim.fn.expand(location)), "%p", "%%%1") ---@diagnostic disable-line -- TODO: type error workaround - - -- If the workspace location is a parent directory of our current realcwd - -- or if the ws location is the same then set it as the real workspace - -- We check this last_length here because if a match is longer - -- than the previous one then we can say it is a much more precise - -- match and hence should be prioritized - if realcwd:find(expanded) and #expanded > last_length then ---@diagnostic disable-line -- TODO: type error workaround - -- Set the result to the workspace name - result = workspace - -- Set the last_length variable to the new length - last_length = #expanded + if file:is_relative_to(location) and location:depth() > longest_match then + ws_name = workspace + longest_match = location:depth() end end end - return result:len() ~= 0 and result or "default" + return ws_name end, --- Uses the `get_workspace_match()` function to determine the root of the workspace based on the --- current working directory, then changes into that workspace @@ -290,7 +285,7 @@ module.public = { ---@field metadata? core.esupports.metagen.metadata metadata fields, if provided inserts metadata - an empty table uses default values --- Takes in a path (can include directories) and creates a .norg file from that path - ---@param path string a path to place the .norg file in + ---@param path string|PathlibPath a path to place the .norg file in ---@param workspace? string workspace name ---@param opts? core.dirman.create_file_opts additional options create_file = function(path, workspace, opts) @@ -310,46 +305,29 @@ module.public = { return end - -- Split the path at every / - local split = vim.split(vim.trim(path), config.pathsep, true) ---@diagnostic disable-line -- TODO: type error workaround + local destination = (fullpath / path):with_suffix(".norg") - -- If the last element is empty (i.e. if the string provided ends with '/') then trim it - if split[#split]:len() == 0 then - split = vim.list_slice(split, 0, #split - 1) - end + -- Generate parents just in case + destination:parent_assert():mkdir(Path.const.o755 + 4 * math.pow(8, 4), true) -- 40755(oct) - -- Go through each directory (excluding the actual file name) and create each directory individually - for _, element in ipairs(vim.list_slice(split, 0, #split - 1)) do - vim.loop.fs_mkdir(fullpath .. config.pathsep .. element, 16877) - fullpath = fullpath .. config.pathsep .. element - end - - -- If the provided filepath ends in .norg then don't append the filetype automatically - local fname = fullpath .. config.pathsep .. split[#split] - if not vim.endswith(path, ".norg") then - fname = fname .. ".norg" - end + -- Touch file + destination:touch(Path.permission("rw-rw-rw-"), false) - -- Create the file - local fd = vim.loop.fs_open(fname, opts.force and "w" or "a", 438) - if fd then - vim.loop.fs_close(fd) - end - - local bufnr = module.public.get_file_bufnr(fname) + -- Broadcast file creation event + local bufnr = module.public.get_file_bufnr(destination:tostring()) modules.broadcast_event( assert(modules.create_event(module, "core.dirman.events.file_created", { buffer = bufnr, opts = opts })) ) if not opts.no_open then -- Begin editing that newly created file - vim.cmd("e " .. fname .. "| w") + vim.cmd("e " .. destination:cmd_string() .. "| w") end end, --- Takes in a workspace name and a path for a file and opens it ---@param workspace_name string #The name of the workspace to use - ---@param path string #A path to open the file (e.g directory/filename.norg) + ---@param path string|PathlibPath #A path to open the file (e.g directory/filename.norg) open_file = function(workspace_name, path) local workspace = module.public.get_workspace(workspace_name) @@ -357,7 +335,7 @@ module.public = { return end - vim.cmd("e " .. workspace .. config.pathsep .. path .. " | w") + vim.cmd("e " .. (workspace / path):cmd_string() .. " | w") end, --- Reads the neorg_last_workspace.txt file and loads the cached workspace from there set_last_workspace = function() @@ -383,34 +361,27 @@ module.public = { -- If we were successful in switching to that workspace then begin editing that workspace's index file if module.public.set_workspace(last_workspace) then - vim.cmd("e " .. workspace_path .. config.pathsep .. module.config.public.index) + vim.cmd("e " .. (workspace_path / module.public.get_index()):cmd_string()) utils.notify("Last Workspace -> " .. workspace_path) end end, --- Checks for file existence by supplying a full path in `filepath` - ---@param filepath string + ---@param filepath string|PathlibPath file_exists = function(filepath) - local f = io.open(filepath, "r") - - if f ~= nil then - f:close() - return true - else - return false - end + return Path(filepath):exists() end, --- Get the bufnr for a `filepath` (full path) - ---@param filepath string + ---@param filepath string|PathlibPath get_file_bufnr = function(filepath) if module.public.file_exists(filepath) then - local uri = vim.uri_from_fname(filepath) + local uri = vim.uri_from_fname(tostring(filepath)) return vim.uri_to_bufnr(uri) end end, --- Returns a list of all files relative path from a `workspace_name` ---@param workspace_name string - ---@return table? + ---@return PathlibPath[]|nil get_norg_files = function(workspace_name) local res = {} local workspace = module.public.get_workspace(workspace_name) @@ -419,11 +390,9 @@ module.public = { return end - local scanned_dir = vim.fs.dir(workspace, { depth = 20 }) - - for name, type in scanned_dir do - if type == "file" and vim.endswith(name, ".norg") then - table.insert(res, workspace .. config.pathsep .. name) + for path in workspace:fs_iterdir(true, 20) do + if path:is_file(true) and path:suffix() == ".norg" then + table.insert(res, path) end end @@ -446,16 +415,15 @@ module.public = { -- If we're switching to a workspace that isn't the default workspace then enter the index file if workspace ~= "default" then - vim.cmd("e " .. ws_match .. config.pathsep .. module.config.public.index) + vim.cmd("e " .. (ws_match / module.public.get_index()):cmd_string()) end end, --- Touches a file in workspace - --- TODO: make the touch file recursive - ---@param path string + ---@param path string|PathlibPath ---@param workspace string touch_file = function(path, workspace) vim.validate({ - path = { path, "string" }, + path = { path, "string", "table" }, workspace = { workspace, "string" }, }) @@ -465,15 +433,7 @@ module.public = { return false end - local file = io.open(ws_match .. config.pathsep .. path, "w") - - if not file then - return false - end - - file:write("") - file:close() - return true + return (ws_match / path):touch(Path.const.o644, true) end, get_index = function() return module.config.public.index @@ -511,9 +471,9 @@ module.on_event = function(event) if event.type == "core.neorgcmd.events.dirman.index" then local current_ws = module.public.get_current_workspace() - local index_path = table.concat({ current_ws[2], "/", module.config.public.index }) + local index_path = current_ws[2] / module.public.get_index() - if vim.fn.filereadable(index_path) == 0 then + if vim.fn.filereadable(index_path:tostring("/")) == 0 then if current_ws[1] == "default" then utils.notify(table.concat({ "Index file cannot be created in 'default' workspace to avoid confusion.", @@ -521,11 +481,11 @@ module.on_event = function(event) }, " ")) return end - if not module.public.touch_file(module.config.public.index, module.public.get_current_workspace()[1]) then + if not index_path:touch(Path.const.o644, true) then utils.notify( table.concat({ "Unable to create '", - module.config.public.index, + module.public.get_index(), "' in the current workspace - are your filesystem permissions set correctly?", }), vim.log.levels.WARN @@ -534,7 +494,7 @@ module.on_event = function(event) end end - vim.cmd.edit(index_path) + vim.cmd.edit(index_path:cmd_string()) return end diff --git a/lua/neorg/modules/core/dirman/utils/module.lua b/lua/neorg/modules/core/dirman/utils/module.lua index 5a01491e9..2749a0313 100644 --- a/lua/neorg/modules/core/dirman/utils/module.lua +++ b/lua/neorg/modules/core/dirman/utils/module.lua @@ -8,6 +8,8 @@ Currently the only exposed API function is `expand_path`, which takes a path lik converts `$name` into the full path of the workspace called `name`. --]] +local Path = require("pathlib") + local neorg = require("neorg.core") local log, modules = neorg.log, neorg.modules @@ -16,43 +18,66 @@ local module = neorg.modules.create("core.dirman.utils") ---@class core.dirman.utils module.public = { ---Resolve `$/path/to/file` and return the real path - ---@param path string # path - ---@param raw_path boolean? # If true, returns resolved path, else, return with appended ".norg" - ---@return string? # Resolved path. If path does not start with `$` or not absolute, adds relative from current file. - expand_path = function(path, raw_path) + ---@param path string|PathlibPath # path + ---@param raw_path boolean? # If true, returns resolved path, otherwise, returns resolved path and append ".norg" + ---@return PathlibPath? # Resolved path. If path does not start with `$` or not absolute, adds relative from current file. + expand_pathlib = function(path, raw_path) + local filepath = Path(path) -- Expand special chars like `$` - local custom_workspace_path = path:match("^%$([^/\\]*)[/\\]") - + local custom_workspace_path = filepath:match("^%$([^/\\]*)[/\\]") if custom_workspace_path then + ---@type core.dirman local dirman = modules.get_module("core.dirman") - if not dirman then - log.error( - "Unable to jump to link with custom workspace: `core.dirman` is not loaded. Please load the module in order to get workspace support." - ) + log.error(table.concat({ + "Unable to jump to link with custom workspace: `core.dirman` is not loaded.", + "Please load the module in order to get workspace support.", + }, " ")) return end - -- If the user has given an empty workspace name (i.e. `$/myfile`) if custom_workspace_path:len() == 0 then - path = dirman.get_current_workspace()[2] .. "/" .. path:sub(3) + filepath = dirman.get_current_workspace()[2] / filepath:relative_to(Path("$")) else -- If the user provided a workspace name (i.e. `$my-workspace/myfile`) - local workspace_path = dirman.get_workspace(custom_workspace_path) - - if not workspace_path then - log.warn("Unable to expand path: workspace does not exist") + local workspace = dirman.get_workspace(custom_workspace_path) + if not workspace then + local msg = "Unable to expand path: workspace '%s' does not exist" + log.warn(string.format(msg, custom_workspace_path)) return end - - path = workspace_path .. "/" .. path:sub(custom_workspace_path:len() + 3) + filepath = workspace / filepath:relative_to(Path("$" .. custom_workspace_path)) end + elseif filepath:is_relative() then + local this_file = Path(vim.fn.expand("%:p")):absolute() + filepath = this_file:parent_assert() / filepath else - -- If the path isn't absolute (doesn't begin with `/` nor `~`) then prepend the current file's - -- filehead in front of the path - path = (vim.tbl_contains({ "/", "~" }, path:sub(1, 1)) and "" or (vim.fn.expand("%:p:h") .. "/")) .. path + filepath = filepath:absolute() end - - return path .. (raw_path and "" or ".norg") + -- requested to expand norg file + if not raw_path then + if type(path) == "string" and (path:sub(#path) == "/" or path:sub(#path) == "\\") then + -- if path ends with `/`, it is an invalid request! + log.error(table.concat({ + "Norg file location cannot point to a directory.", + string.format("Current link points to '%s'", path), + "which ends with a `/`.", + }, " ")) + return + end + filepath = filepath:with_suffix(".norg") + end + return filepath + end, + ---Resolve `$/path/to/file` and return the real path + -- NOTE: Use `expand_pathlib` which returns a PathlibPath object instead. + --- + ---\@deprecate Use `expand_pathlib` which returns a PathlibPath object instead. TODO: deprecate this <2024-03-27> + ---@param path string|PathlibPath # path + ---@param raw_path boolean? # If true, returns resolved path, otherwise, returns resolved path and append ".norg" + ---@return string? # Resolved path. If path does not start with `$` or not absolute, adds relative from current file. + expand_path = function(path, raw_path) + local res = module.public.expand_pathlib(path, raw_path) + return res and res:tostring() or nil end, } diff --git a/lua/neorg/modules/core/summary/module.lua b/lua/neorg/modules/core/summary/module.lua index 307ce8929..793f14b4f 100644 --- a/lua/neorg/modules/core/summary/module.lua +++ b/lua/neorg/modules/core/summary/module.lua @@ -150,7 +150,7 @@ module.load = function() if not norgname then norgname = filename end - norgname = string.sub(norgname, string.len(ws_root) + 1) + norgname = string.sub(norgname, ws_root:len() + 1) -- normalise categories into a list. Could be vim.NIL, a number, a string or a list ... if not metadata.categories or metadata.categories == vim.NIL then @@ -237,7 +237,7 @@ module.load = function() local category = path_tokens[#path_tokens - 1] or "Uncategorised" local norgname = filename:match("(.+)%.norg$") or filename -- strip extension for link destinations - norgname = string.sub(norgname, string.len(ws_root) + 1) + norgname = string.sub(norgname, ws_root:len() + 1) if not metadata.title then metadata.title = get_first_heading_title(bufnr) or vim.fs.basename(norgname) @@ -290,7 +290,7 @@ module.config.public = { -- - "default" - read the metadata to categorize and annotate files. Files -- without metadata will use the top level heading as the title. If no headings are present, the filename will be used. -- - "by_path" - Similar to "default" but uses the capitalized name of the folder containing a *.norg file as category. - -- ---@type string|fun(files: string[], ws_root: string, heading_level: number?, include_categories: string[]?): string[]? + ---@type string|fun(files: PathlibPath[], ws_root: PathlibPath, heading_level: number?, include_categories: string[]?): string[]? strategy = "default", }