From 66ebab06d0955a7736103d8f545437aab13d8d9c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 19 Mar 2025 13:26:54 +0300 Subject: [PATCH 1/3] direnv.nvim: complete plugin overhaul I should have worked more with atomic commits and less with whatever the hell this is supposed to be, but hindsight is 20/20. tl;dr is that this refactor improves the plugin with better UX, slightly better performance, and better compatibility with Neovim 0.10, which I have overlooked last time. Changes are, from memory, as follows: - Caching system to prevent excessive direnv status checks - Statusline integration to show direnv status in real-time - Proper async handling for all operations - Added (subpar) .envrc file editor with creation prompt - Added autocmd hooks for other plugins (User `DirenvLoaded`, fixes #5) - Better, comprehensive notification system with proper scheduling - Intuitive handling of allowed/pending/blocked states - Added command to check direnv status - Improved contextual commands and keyboard mappings (despite "best" practices) For those who have direnv.nvim already set up (despite the repo being archived for months), I tried to retain full backwards compat. New functionality and error fixes were built on top. --- lua/direnv.lua | 613 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 505 insertions(+), 108 deletions(-) diff --git a/lua/direnv.lua b/lua/direnv.lua index 7a82469..4d32d90 100644 --- a/lua/direnv.lua +++ b/lua/direnv.lua @@ -1,9 +1,40 @@ local M = {} +--- @class DirenvConfig +--- @field bin string Path to direnv executable +--- @field autoload_direnv boolean Automatically load direnv when opening files +--- @field statusline table Configuration for statusline integration +--- @field statusline.enabled boolean Enable statusline integration +--- @field statusline.icon string Icon to show in statusline +--- @field keybindings table Keybindings configuration +--- @field keybindings.allow string Keybinding to allow direnv +--- @field keybindings.deny string Keybinding to deny direnv +--- @field keybindings.reload string Keybinding to reload direnv +--- @field keybindings.edit string Keybinding to edit .envrc +--- @field notifications table Notification settings +--- @field notifications.level integer Log level for notifications +--- @field notifications.silent_autoload boolean Don't show notifications during autoload + +local cache = { + status = nil, + path = nil, + last_check = 0, + ttl = 5000, -- milliseconds before cache invalidation, then we can think about naming things +} + +local notification_queue = {} + +--- Check if an executable is available in PATH +--- @param executable_name string Name of the executable +--- @return boolean is_available local function check_executable(executable_name) if vim.fn.executable(executable_name) ~= 1 then vim.notify( - "Executable '" .. executable_name .. "' not found", + "Executable '" + .. executable_name + .. "' not found. Please install " + .. executable_name + .. " first.", vim.log.levels.ERROR ) return false @@ -11,6 +42,25 @@ local function check_executable(executable_name) return true end +--- Get current working directory safely +--- @return string|nil cwd Current working directory or nil on error +local function get_cwd() + local cwd_result, err = vim.uv.cwd() + if err then + vim.schedule(function() + vim.notify( + "Failed to get current directory: " .. err, + vim.log.levels.ERROR + ) + end) + return nil + end + return cwd_result +end + +--- Setup keymaps for the plugin +--- @param keymaps table List of keymap definitions +--- @param mode string|table Vim mode for the keymap local function setup_keymaps(keymaps, mode) for _, map in ipairs(keymaps) do local options = vim.tbl_extend( @@ -22,158 +72,505 @@ local function setup_keymaps(keymaps, mode) end end -M.setup = function(user_config) - local config = vim.tbl_deep_extend("force", { - bin = "direnv", - autoload_direnv = false, - keybindings = { - allow = "da", - deny = "dd", - reload = "dr", - }, - }, user_config or {}) +--- Safe notify function that works in both sync and async contexts +--- @param msg string Message to display +--- @param level? integer Log level +--- @param opts? table Additional notification options +local function notify(msg, level, opts) + -- Ensure level is an integer + level = level + or (M.config and M.config.notifications.level or vim.log.levels.INFO) + opts = opts or {} + opts = vim.tbl_extend("force", { title = "direnv.nvim" }, opts) - if not check_executable(config.bin) then - return - end - - vim.api.nvim_create_user_command("Direnv", function(opts) - local cmds = { - ["allow"] = M.allow_direnv, - ["deny"] = M.deny_direnv, - ["reload"] = M.check_direnv, - } - local cmd = cmds[string.lower(opts.fargs[1])] - if cmd then - cmd() - end - end, { - nargs = 1, - complete = function() - return { "allow", "deny", "reload" } - end, - }) - - setup_keymaps({ - { - config.keybindings.allow, - function() - M.allow_direnv() - end, - { desc = "Allow direnv" }, - }, - { - config.keybindings.deny, - function() - M.deny_direnv() - end, - { desc = "Deny direnv" }, - }, - { - config.keybindings.reload, - function() - M.check_direnv() - end, - { desc = "Reload direnv" }, - }, - }, "n") - - -- If user has enabled autoloading, and current directory has an .envrc - -- then load it. This has performance implications as it will check for - -- a filepath on each BufEnter event. - if config.autoload_direnv and vim.fn.glob("**/.envrc") ~= "" then - local group_id = vim.api.nvim_create_augroup("DirenvNvim", {}) - - vim.api.nvim_create_autocmd({ "DirChanged" }, { - pattern = "global", - group = group_id, - callback = function() - M.check_direnv() - end, + if vim.in_fast_event() then + table.insert(notification_queue, { + msg = msg, + level = level, + opts = opts, }) + + vim.schedule(function() + while #notification_queue > 0 do + local item = table.remove(notification_queue, 1) + vim.notify(item.msg, item.level, item.opts) + end + end) + else + vim.notify(msg, level, opts) end end -M.allow_direnv = function() - print("Allowing direnv...") - os.execute("direnv allow") +--- Process any pending notifications +local function process_notification_queue() + vim.schedule(function() + while #notification_queue > 0 do + local item = table.remove(notification_queue, 1) + vim.notify(item.msg, item.level, item.opts) + end + end) end -M.deny_direnv = function() - print("Denying direnv...") - os.execute("direnv deny") -end +--- Get current direnv status via JSON API +--- @param callback function Callback function to handle result +M._get_rc_status = function(callback) + local loop = vim.uv or vim.loop + local now = math.floor(loop.hrtime() / 1000000) -- ns -> ms + if cache.status ~= nil and (now - cache.last_check) < cache.ttl then + return callback(cache.status, cache.path) + end + + local cwd = get_cwd() + if not cwd then + return callback(nil, nil) + end -M._get_rc_status = function(_on_exit) local on_exit = function(obj) - local status = vim.json.decode(obj.stdout) + if obj.code ~= 0 then + vim.schedule(function() + notify( + "Failed to get direnv status: " + .. (obj.stderr or "unknown error"), + vim.log.levels.ERROR + ) + end) + return callback(nil, nil) + end + + local ok, status = pcall(vim.json.decode, obj.stdout) + if not ok or not status or not status.state then + return callback(nil, nil) + end if status.state.foundRC == nil then - return _on_exit(nil, nil) + cache.status = nil + cache.path = nil + cache.last_check = now + return callback(nil, nil) end - _on_exit(status.state.foundRC.allowed, status.state.foundRC.path) - end + cache.status = status.state.foundRC.allowed + cache.path = status.state.foundRC.path + cache.last_check = now - return vim.system( - { "direnv", "status", "--json" }, - { text = true, cwd = vim.fn.getcwd(-1, -1) }, - on_exit - ) -end - -M._init = function(path) - vim.schedule(function() - vim.notify("Reloading " .. path) - end) - - local cwd = vim.fs.dirname(path) - - local on_exit = function(obj) - vim.schedule(function() - vim.fn.execute(vim.fn.split(obj.stdout, "\n")) - end) + callback(status.state.foundRC.allowed, status.state.foundRC.path) end vim.system( - { "direnv", "export", "vim" }, + { M.config.bin, "status", "--json" }, { text = true, cwd = cwd }, on_exit ) end +--- Initialize direnv for current directory +--- @param path string Path to .envrc file +M._init = function(path) + local cwd = vim.fs.dirname(path) + local silent = M.config.notifications.silent_autoload + and vim.b.direnv_autoload_triggered + + if not silent then + vim.schedule(function() + notify("Loading environment from " .. path, vim.log.levels.INFO) + end) + end + + local on_exit = function(obj) + if obj.code ~= 0 then + vim.schedule(function() + notify( + "Failed to load direnv: " .. (obj.stderr or "unknown error"), + vim.log.levels.ERROR + ) + end) + return + end + + vim.schedule(function() + local env_commands = vim.split(obj.stdout, "\n") + if #env_commands > 0 then + for _, cmd in ipairs(env_commands) do + if cmd ~= "" then + pcall(function() + vim.cmd(cmd) + end) + end + end + + if not silent then + notify( + "direnv environment loaded successfully", + vim.log.levels.INFO + ) + end + vim.api.nvim_exec_autocmds( + "User", + { pattern = "DirenvLoaded", modeline = false } + ) + end + end) + end + + vim.system( + { M.config.bin, "export", "vim" }, + { text = true, cwd = cwd }, + on_exit + ) +end + +---Allow direnv for current directory +M.allow_direnv = function() + M._get_rc_status(function(_, path) + if not path then + vim.schedule(function() + notify( + "No .envrc file found in current directory", + vim.log.levels.WARN + ) + end) + return + end + + vim.schedule(function() + notify("Allowing direnv for " .. path, vim.log.levels.INFO) + end) + + -- Capture dir before the async call + local cwd = get_cwd() + if not cwd then + return + end + + vim.system( + { M.config.bin, "allow" }, + { text = true, cwd = cwd }, + function(obj) + if obj.code ~= 0 then + vim.schedule(function() + notify( + "Failed to allow direnv: " .. (obj.stderr or ""), + vim.log.levels.ERROR + ) + end) + return + end + + -- Clear cache to ensure we get fresh data + -- and then load the environment + cache.status = nil + M.check_direnv() + + vim.schedule(function() + notify("direnv allowed for " .. path, vim.log.levels.INFO) + end) + end + ) + end) +end + +--- Deny direnv for current directory +M.deny_direnv = function() + M._get_rc_status(function(_, path) + if not path then + vim.schedule(function() + notify( + "No .envrc file found in current directory", + vim.log.levels.WARN + ) + end) + return + end + + vim.schedule(function() + notify("Denying direnv for " .. path, vim.log.levels.INFO) + end) + + local cwd = get_cwd() + if not cwd then + return + end + + vim.system( + { M.config.bin, "deny" }, + { text = true, cwd = cwd }, + function(obj) + if obj.code ~= 0 then + vim.schedule(function() + notify( + "Failed to deny direnv: " .. (obj.stderr or ""), + vim.log.levels.ERROR + ) + end) + return + end + + cache.status = nil + + vim.schedule(function() + notify("direnv denied for " .. path, vim.log.levels.INFO) + end) + end + ) + end) +end + +--- Edit the .envrc file +M.edit_envrc = function() + M._get_rc_status(function(_, path) + if not path then + -- TODO: envrc can be in a different directory, e.g., the parent. + -- We should search for it backwards eventually. + local cwd = get_cwd() + if not cwd then + return + end + + local envrc_path = cwd .. "/.envrc" + vim.schedule(function() + local create_new = vim.fn.confirm( + "No .envrc file found. Create one?", + "&Yes\n&No", + 1 + ) + + if create_new == 1 then + vim.cmd("edit " .. envrc_path) + end + end) + return + end + + vim.schedule(function() + vim.cmd("edit " .. path) + end) + end) +end + +--- Check and load direnv if applicable M.check_direnv = function() local on_exit = function(status, path) if status == nil or path == nil then return end - -- Allowed + -- Status 0 means the .envrc file is allowed if status == 0 then return M._init(path) end - -- Blocked + -- Status 2 means the .envrc file is explicitly blocked if status == 2 then + vim.schedule(function() + notify( + path .. " is explicitly blocked by direnv", + vim.log.levels.WARN + ) + end) return end + -- Status 1 means the .envrc file needs approval vim.schedule(function() - local choice = - vim.fn.confirm(path .. " is blocked.", "&Allow\n&Block\n&Ignore", 3) + local choice = vim.fn.confirm( + path .. " is not allowed by direnv. What would you like to do?", + "&Allow\n&Block\n&Ignore", + 1 + ) if choice == 1 then M.allow_direnv() - M._init(path) - end - - if choice == 2 then - M._init(path) + elseif choice == 2 then + M.deny_direnv() end + -- Ignore means do nothing end) end M._get_rc_status(on_exit) end +--- Get direnv status for statusline integration +--- @return string status_string +M.statusline = function() + if not M.config.statusline.enabled then + return "" + end + + if cache.status == 0 then + return M.config.statusline.icon .. " active" + elseif cache.status == 1 then + return M.config.statusline.icon .. " pending" + elseif cache.status == 2 then + return M.config.statusline.icon .. " blocked" + else + return "" + end +end + +--- Setup the plugin with user configuration +--- @param user_config? table User configuration table +M.setup = function(user_config) + M.config = vim.tbl_deep_extend("force", { + bin = "direnv", + autoload_direnv = false, + statusline = { + enabled = false, + icon = "󱚟", + }, + keybindings = { + allow = "da", + deny = "dd", + reload = "dr", + edit = "de", + }, + notifications = { + level = vim.log.levels.INFO, + silent_autoload = true, + }, + }, user_config or {}) + + if not check_executable(M.config.bin) then + return + end + + -- Create user commands + vim.api.nvim_create_user_command("Direnv", function(opts) + local cmds = { + ["allow"] = M.allow_direnv, + ["deny"] = M.deny_direnv, + ["reload"] = M.check_direnv, + ["edit"] = M.edit_envrc, + ["status"] = function() + M._get_rc_status(function(status, path) + if not path then + vim.schedule(function() + notify( + "No .envrc file found in current directory", + vim.log.levels.INFO + ) + end) + return + end + + local status_text = (status == 0 and "allowed") + or (status == 1 and "pending") + or (status == 2 and "blocked") + or "unknown" + + vim.schedule(function() + notify( + "direnv status: " .. status_text .. " for " .. path, + vim.log.levels.INFO + ) + end) + end) + end, + } + + local cmd = cmds[string.lower(opts.fargs[1])] + if cmd then + cmd() + else + notify( + "Unknown direnv command: " .. opts.fargs[1], + vim.log.levels.ERROR + ) + end + end, { + nargs = 1, + complete = function() + return { "allow", "deny", "reload", "edit", "status" } + end, + }) + + -- Setup keybindings + setup_keymaps({ + { + M.config.keybindings.allow, + function() + M.allow_direnv() + end, + { desc = "Allow direnv" }, + }, + { + M.config.keybindings.deny, + function() + M.deny_direnv() + end, + { desc = "Deny direnv" }, + }, + { + M.config.keybindings.reload, + function() + M.check_direnv() + end, + { desc = "Reload direnv" }, + }, + { + M.config.keybindings.edit, + function() + M.edit_envrc() + end, + { desc = "Edit .envrc file" }, + }, + }, "n") + + -- Check for .envrc files and set up autoload + local group_id = vim.api.nvim_create_augroup("DirenvNvim", { clear = true }) + + if M.config.autoload_direnv then + -- Check on directory change + vim.api.nvim_create_autocmd({ "DirChanged" }, { + group = group_id, + callback = function() + vim.b.direnv_autoload_triggered = true + M.check_direnv() + vim.defer_fn(function() + vim.b.direnv_autoload_triggered = false + end, 1000) + end, + }) + + -- Check on startup if we're in a directory with .envrc + vim.api.nvim_create_autocmd({ "VimEnter" }, { + group = group_id, + callback = function() + vim.b.direnv_autoload_triggered = true + M.check_direnv() + -- Reset the flag after a short delay + vim.defer_fn(function() + vim.b.direnv_autoload_triggered = false + end, 1000) + end, + once = true, + }) + end + + -- Check for .envrc changes + vim.api.nvim_create_autocmd({ "BufWritePost" }, { + pattern = ".envrc", + group = group_id, + callback = function() + cache.status = nil + notify( + ".envrc file changed. Run :Direnv allow to activate changes.", + vim.log.levels.INFO + ) + end, + }) + + -- Expose a command to refresh the statusline value without triggering reload + vim.api.nvim_create_user_command("DirenvStatuslineRefresh", function() + cache.last_check = 0 + M._get_rc_status(function() end) + end, {}) + + M._get_rc_status(function() end) + + process_notification_queue() + + notify("direnv.nvim initialized", vim.log.levels.DEBUG) +end + return M From 4183b8e162c167106713d3dfc357431041a5eae4 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 19 Mar 2025 15:01:39 +0300 Subject: [PATCH 2/3] ignore testing config --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6fd0a37..1bd1157 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Testing Configuration +test.lua + # Compiled Lua sources luac.out From 1215bc887158198edf1b2efe72f351d1c18ef7f4 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 19 Mar 2025 15:06:01 +0300 Subject: [PATCH 3/3] docs: update API reference, features and TODO --- README.md | 186 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 169 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 56741d1..34e7c34 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,191 @@ # direnv.nvim Dead simple Neovim plugin to add automatic Direnv loading, inspired by -`direnv.vim` and written in Lua. +`direnv.vim` and written in Lua for better performance and maintainability. + +## ✨ Features + +- Seamless integration with direnv for managing project environment variables + - Automatic detection of `.envrc` files in your workspace + - Proper handling of allowed, pending, and denied states +- Built-in `.envrc` editor with file creation wizard +- Statusline component showing real-time direnv status +- Event hooks for integration with other plugins +- Comprehensive API for extending functionality + +### 📓 TODO + +There are things direnv.nvim can _not_ yet do. Mainly, we would like to +integrate Treesitter for **syntax highlighting** similar to direnv.vim. +Unfortunately there isn't a TS grammar for Direnv, but we can port syntax.vim +from direnv.vim. + +Additionally, it might be worth adding an option to allow direnv on, e.g., +VimEnter if the user has configured to do so. ## 📦 Installation Install `direnv.nvim` with your favorite plugin manager, or clone it manually. You will need to call the setup function to load the plugin. +### Prerequisites + +- Neovim 0.8.0 or higher +- [direnv](https://direnv.net/) installed and available in your PATH + +### Using lazy.nvim + +```lua +{ + "NotAShelf/direnv.nvim", + config = function() + require("direnv").setup({}) + end, +} +``` + ## 🚀 Usage -direnv.nvim will automatically call `direnv allow` in your current directory if -`direnv` is available in your PATH, and you have auto-loading enabled. +direnv.nvim will manage your .envrc files in Neovim by providing commands to +allow, deny, reload and edit them. When auto-loading is enabled, the plugin will +automatically detect and prompt for allowing `.envrc` files in your current +directory. -## 🔧 Configuration +### Commands + +- `:Direnv allow` - Allow the current directory's .envrc file +- `:Direnv deny` - Deny the current directory's .envrc file +- `:Direnv reload` - Reload direnv for the current directory +- `:Direnv edit` - Edit the `.envrc` file (creates one if it doesn't exist) +- `:Direnv status` - Show the current direnv status + +### Configuration You can pass your config table into the `setup()` function or `opts` if you use `lazy.nvim`. ### Options -- `bin` (optional, type: string): the path to the Direnv binary. May be an - absolute path, or just `direnv` if it's available in your PATH. - Default: - `direnv` -- `autoload_direnv` (optional, type: boolean): whether to call `direnv allow` - when you enter a directory that contains an `.envrc`. - Default: `false` -- `keybindings` (optional, type: table of strings): the table of keybindings to - use. - - Default: - `{allow = "da", deny = "dd", reload = "dr"}` - -#### Example: - ```lua require("direnv").setup({ - autoload_direnv = true, + -- Path to the direnv executable + bin = "direnv", + + -- Whether to automatically load direnv when entering a directory with .envrc + autoload_direnv = false, + + -- Statusline integration + statusline = { + -- Enable statusline component + enabled = false, + -- Icon to display in statusline + icon = "󱚟", + }, + + -- Keyboard mappings + keybindings = { + allow = "da", + deny = "dd", + reload = "dr", + edit = "de", + }, + + -- Notification settings + notifications = { + -- Log level (vim.log.levels.INFO, ERROR, etc.) + level = vim.log.levels.INFO, + -- Don't show notifications during autoload + silent_autoload = true, + }, }) ``` + +### Statusline Integration + +You can add direnv status to your statusline by using the provided function: + +```lua +-- For lualine +require('lualine').setup({ + sections = { + lualine_x = { + function() + return require('direnv').statusline() + end, + 'encoding', + 'fileformat', + 'filetype', + } + } +}) + +-- For a Neovim-native statusline without plugins +vim.o.statusline = '%{%v:lua.require("direnv").statusline()%} ...' +``` + +The statusline function will show: + +- Nothing when disabled or no .envrc is found +- "active" when the .envrc is allowed +- "pending" when the .envrc needs approval +- "blocked" when the .envrc is explicitly denied + +## 🔍 API Reference + +**Public Functions** + +- `direnv.setup(config)` - Initialize the plugin with optional configuration +- `direnv.allow_direnv()` - Allow the current directory's `.envrc` file +- `direnv.deny_direnv()` - Deny the current directory's `.envrc` file +- `direnv.check_direnv()` - Check and reload direnv for the current directory +- `direnv.edit_envrc()` - Edit the `.envrc` file +- `direnv.statusline()` - Get a string for statusline integration + +### Example + +```lua +local direnv = require("direnv") + +direnv.setup({ + autoload_direnv = true, + statusline = { + enabled = true, + }, + keybindings = { + allow = "ea", -- Custom keybinding example + }, +}) + +-- You can also call functions directly +vim.keymap.set('n', 'er', function() + direnv.check_direnv() +end, { desc = "Reload direnv" }) +``` + +### Events + +The plugin triggers a User autocmd event that you can hook into: + +```lua +vim.api.nvim_create_autocmd("User", { + pattern = "DirenvLoaded", + callback = function() + -- Code to run after direnv environment is loaded + print("Direnv environment loaded!") + end, +}) +``` + +## 🫂 Special Thanks + +I extend my thanks to the awesome [Lychee](https://github.com/itslychee), +[mrshmllow](https://github.com/mrshmllow) and +[diniamo](https://github.com/diniamo) for their invaluable assistance in the +creation of this plugin. I would also like to thank +[direnv.vim](https://github.com/direnv/direnv.vim) maintainers for their initial +work. + +## 📜 License + +direnv.nvim is licensed under the [MPL v2.0](./LICENSE). Please see the license +file for more details.