Skip to content

Commit

Permalink
feat: add experimental rooter
Browse files Browse the repository at this point in the history
  • Loading branch information
mehalter committed Jan 25, 2024
1 parent 3d6b456 commit a7d2569
Show file tree
Hide file tree
Showing 3 changed files with 305 additions and 0 deletions.
41 changes: 41 additions & 0 deletions lua/astrocore/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
--
Expand Down Expand Up @@ -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 = {
Expand Down
37 changes: 37 additions & 0 deletions lua/astrocore/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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(args) require("astrocore.rooter").root(args.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
227 changes: 227 additions & 0 deletions lua/astrocore/rooter.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
---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)
local ret = {}
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 ret end

local path = M.bufpath(bufnr)
if path and (not M.exists(path) or M.is_excluded(path)) then return ret end

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
table.insert(lines, "```lua")
if config.detector then table.insert(lines, "detector = " .. vim.inspect(config.detector)) end
if config.ignore then
for _, type in ipairs { "dirs", "servers" } do
local spec = config.ignore[type]
if spec then table.insert(lines, "ignore." .. type .. " = " .. vim.inspect(spec)) end
end
end
for _, key in ipairs { "scope", "autochdir", "notify" } do
local setting = config[key]
if setting then table.insert(lines, key .. " = " .. vim.inspect(setting)) end
end
table.insert(lines, "```")
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

local root = M.detect(bufnr)[1]
if root then M.set_pwd(root) end
end

return M

0 comments on commit a7d2569

Please sign in to comment.