From 66731e8bb462ba59226dccffd4d23e1d124e3fc4 Mon Sep 17 00:00:00 2001 From: Micah Halter Date: Thu, 25 Jan 2024 13:27:00 -0500 Subject: [PATCH] feat: add experimental rooter --- lua/astrocore/config.lua | 41 ++++++++ lua/astrocore/init.lua | 37 +++++++ lua/astrocore/rooter.lua | 219 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 297 insertions(+) create mode 100644 lua/astrocore/rooter.lua diff --git a/lua/astrocore/config.lua b/lua/astrocore/config.lua index 993d76c..c393c22 100644 --- a/lua/astrocore/config.lua +++ b/lua/astrocore/config.lua @@ -19,6 +19,19 @@ ---@class AstroCoreAutocmd: vim.api.keyset.create_autocmd ---@field event string|string[] Event(s) that will trigger the handler +---@alias AstroCoreRooterSpec string|string[]|fun(bufnr: integer): (string|string[]) + +---@class AstroCoreRooterIgnore +---@field dirs string[]? a list of patterns that match directories to exclude from root detection +---@field servers string[]? a list of language servers to exclude from root detection + +---@class AstroCoreRooterOpts +---@field detector AstroCoreRooterSpec[]? a list of specifications for the rooter detection +---@field ignore AstroCoreRooterIgnore? configure things to ignore from root detection +---@field scope "global"|"tab"|"win"? what scope to change the working directory +---@field autochdir boolean? whether or not to change working directory automatically +---@field notify boolean? whether or not to notify on working directory change + ---@class AstroCoreGitWorktree ---@field toplevel string the top level directory ---@field gitdir string the location of the git directory @@ -188,6 +201,22 @@ ---} ---``` ---@field git_worktrees AstroCoreGitWorktree[]? +---Enable git integration for detached worktrees +---Example: +-- +---```lua +---rooter = { +--- autochdir = true, +--- detector = { "lsp", { ".git" } }, +--- ignore = { +--- dirs = {}, +--- servers = {}, +--- } +--- notify = false, +--- scope = "global", +---} +---``` +---@field rooter AstroCoreRooterOpts|false? ---Configuration table of session options for AstroNvim's session management powered by Resession ---Example: -- @@ -220,6 +249,18 @@ local M = { notifications = true, }, git_worktrees = nil, + -- enable by default once tested + -- rooter = { + -- detector = { "lsp", { ".git" } }, + -- ignore = { + -- dirs = {}, + -- servers = {}, + -- }, + -- scope = "global", + -- autochdir = true, + -- notify = false, + -- }, + rooter = false, sessions = { autosave = { last = true, cwd = true }, ignore = { diff --git a/lua/astrocore/init.lua b/lua/astrocore/init.lua index 227ba20..ea696a6 100644 --- a/lua/astrocore/init.lua +++ b/lua/astrocore/init.lua @@ -373,6 +373,43 @@ function M.setup(opts) end end end + + if M.config.rooter then + local root_config = M.config.rooter --[[@as AstroCoreRooterOpts]] + vim.api.nvim_create_user_command( + "AstroRootInfo", + function() require("astrocore.rooter").info() end, + { desc = "Display rooter information" } + ) + vim.api.nvim_create_user_command( + "AstroRoot", + function() require("astrocore.rooter").root() end, + { desc = "Run root detection" } + ) + + local group = vim.api.nvim_create_augroup("rooter", { clear = true }) -- clear the augroup no matter what + if root_config.autochdir then + vim.api.nvim_create_autocmd({ "VimEnter", "BufEnter" }, { + nested = true, + group = group, + desc = "Root detection when entering a buffer", + callback = function(ars) require("astrocore.rooter").root(ars.buf) end, + }) + if vim.tbl_contains(root_config.detector or {}, "lsp") then + vim.api.nvim_create_autocmd("LspAttach", { + nested = true, + group = group, + desc = "Root detection on LSP attach", + callback = function(args) + local server = assert(vim.lsp.get_client_by_id(args.data.client_id)).name + if not vim.tbl_contains(vim.tbl_get(root_config, "ignore", "servers") or {}, server) then + require("astrocore.rooter").root(args.buf) + end + end, + }) + end + end + end end return M diff --git a/lua/astrocore/rooter.lua b/lua/astrocore/rooter.lua new file mode 100644 index 0000000..f4f4144 --- /dev/null +++ b/lua/astrocore/rooter.lua @@ -0,0 +1,219 @@ +---AstroNvim Rooter +--- +---Utilities necessary for automatic root detectoin +--- +---This module can be loaded with `local rooter = require "astrocore.rooter"` +--- +---copyright 2023 +---license GNU General Public License v3.0 +---@class astrocore.rooter +local M = {} + +---@type AstroCoreRooterOpts +local config = require("astrocore").config.rooter + +---@class AstroCoreRooterRoot +---@field paths string[] +---@field spec AstroCoreRooterSpec + +M.disabled = false + +M.detectors = {} + +--- Detect workspace folders from active language servers +---@param bufnr integer the buffer to detect language servers on +---@return string[] paths the detected workspace folders +function M.detectors.lsp(bufnr) + local bufpath = M.bufpath(bufnr) + if not bufpath then return {} end + local roots = {} ---@type string[] + -- TODO: remove when dropping support for Neovim v0.9 + for _, client in ipairs((vim.lsp.get_clients or vim.lsp.get_active_clients) { buffer = bufnr }) do + if not vim.tbl_contains(vim.tbl_get(config, "ignore", "servers") or {}, client.name) then + vim.tbl_map(function(ws) table.insert(roots, vim.uri_to_fname(ws.uri)) end, client.config.workspace_folders or {}) + end + end + return vim.tbl_filter(function(path) + path = M.normpath(path) + return path and bufpath:find(path, 1, true) == 1 + end, roots) +end + +--- Detect parent folders matching patterns +---@param bufnr integer the buffer to detect parent dirs for +---@param patterns string|string[] the pattern(s) to detect +---@return string[] paths the detected folders +function M.detectors.pattern(bufnr, patterns) + if type(patterns) == "string" then patterns = { patterns } end + local path = M.bufpath(bufnr) or vim.loop.cwd() + local pattern = vim.fs.find(patterns, { path = path, upward = true })[1] + return pattern and { vim.fs.dirname(pattern) } or {} +end + +--- Get the real path of a buffer +---@param bufnr integer the buffer +---@return string? path the real path +function M.bufpath(bufnr) return M.realpath(vim.api.nvim_buf_get_name(bufnr)) end + +--- Resolve a given path +---@param path string? the path to resolve +---@return string? the resolved path +function M.realpath(path) + if not path or path == "" then return nil end + return M.normpath((vim.uv or vim.loop).fs_realpath(path) or path) +end + +--- Normalize path +---@param path string +---@return string +function M.normpath(path) + if path:sub(1, 1) == "~" then + local home = assert(vim.loop.os_homedir()) + if home:sub(-1) == "\\" or home:sub(-1) == "/" then home = home:sub(1, -2) end + path = home .. path:sub(2) + end + path = path:gsub("\\", "/"):gsub("/+", "/") + return path:sub(-1) == "/" and path:sub(1, -2) or path +end + +--- Resolve the root detection function for a given spec +---@param spec AstroCoreRooterSpec the root detector specification +---@return function +function M.resolve(spec) + if M.detectors[spec] then + return M.detectors[spec] + elseif type(spec) == "function" then + return spec + end + return function(bufnr) return M.detectors.pattern(bufnr, spec) end +end + +--- Detect roots in a given buffer +---@param bufnr? integer the buffer to detect +---@param all? boolean whether to return all roots or just one +---@return AstroCoreRooterRoot[] detected roots +function M.detect(bufnr, all) + if not bufnr or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end + + local ret = {} + for _, spec in ipairs(config.detector or {}) do + local paths = M.resolve(spec)(bufnr) + if not paths then + paths = {} + elseif type(paths) ~= "table" then + paths = { paths } + end + local roots = {} ---@type string[] + for _, p in ipairs(paths) do + local pp = M.realpath(p) + if pp and not vim.tbl_contains(roots, pp) then roots[#roots + 1] = pp end + end + table.sort(roots, function(a, b) return #a > #b end) + if #roots > 0 then + table.insert(ret, { spec = spec, paths = roots }) + if not all then break end + end + end + return ret +end + +--- Get information information about the current root +---@param silent integer? whether or not to notify with verbose details +---@return string the currently detected root +function M.info(silent) + local roots = M.detect(0, true) + if not silent then + local first = true + local lines = {} + for _, root in ipairs(roots) do + for _, path in ipairs(root.paths) do + local surround = first and "**" or "" + table.insert( + lines, + ("%s`%s` *(%s*)%s"):format( + surround, + path, + type(root.spec) == "table" and table.concat(root.spec --[=[@as string[]]=], ", ") or root.spec, + surround + ) + ) + first = false + end + end + vim.list_extend(lines, { + "", + "```lua", + "detector = " .. vim.inspect(config.detector), + "```", + }) + require("astrocore").notify(table.concat(lines, "\n"), vim.log.levels.INFO, { title = "AstroNvim Rooter" }) + end + return roots[1] and roots[1].paths[1] or vim.loop.cwd() +end + +--- Set the current directory to a given root +---@param root AstroCoreRooterRoot the root to set the pwd to +---@return boolean success whether or not the pwd was successfully set +function M.set_pwd(root) + local path = root.paths[1] + if path ~= nil then + if vim.fn.has "win32" > 0 then path = path:gsub("\\", "/") end + if vim.fn.getcwd() ~= path then + if config.scope == "global" then + vim.api.nvim_set_current_dir(path) + elseif config.scope == "tab" then + vim.cmd("tcd " .. path) + elseif config.scope == "win" then + vim.cmd("lcd " .. path) + else + vim.api.nvim_err_writeln(("Unable to parse scope: %s"):format(config.scope)) + end + + if config.notify then vim.notify("Set CWD to " .. path .. " using " .. vim.inspect(root.spec)) end + end + return true + end + + return false +end + +--- Check if a path is excluded +---@param path string the path +---@return boolean excluded whether or not the path is excluded +function M.is_excluded(path) + for _, path_pattern in ipairs(vim.tbl_get(config, "ignore", "dirs") or {}) do + if path:match(M.normpath(path_pattern)) then return true end + end + return false +end + +--- Check if a path exists +---@param path string the path +---@return boolean exists whether or not the path exists +function M.exists(path) return vim.fn.empty(vim.fn.glob(path)) == 0 end + +--- Run the root detection and set the current working directory if a new root is detected +---@param bufnr integer? the buffer to detect +function M.root(bufnr) + -- add `autochdir` protection + local autochdir = vim.opt.autochdir:get() + if not M.disabled and autochdir then + require("astrocore").notify("AstroNvim's rooter does not support running with `autochdir` set", vim.log.levels.WARN) + M.disabled = true + elseif M.disabled and not autochdir then + M.disabled = false + end + + if M.disabled or vim.v.vim_did_enter == 0 then return end + + if not bufnr or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end + if not require("astrocore.buffer").is_valid(bufnr) then return end + + local path = M.bufpath(bufnr) + if path and (not M.exists(path) or M.is_excluded(path)) then return end + + local root = M.detect(bufnr)[1] + if root then M.set_pwd(root) end +end + +return M