Merge pull request #7 from NotAShelf/massive-refactor
Some checks failed
Style & Lint / lint (5.1) (push) Has been cancelled
Style & Lint / style (0.19.1) (push) Has been cancelled

direnv.nvim: complete plugin overhaul
This commit is contained in:
raf 2025-03-21 14:08:32 +00:00 committed by GitHub
commit 8e8c2a1d6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 677 additions and 125 deletions

3
.gitignore vendored
View file

@ -1,3 +1,6 @@
# Testing Configuration
test.lua
# Compiled Lua sources
luac.out

186
README.md
View file

@ -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 = "<Leader>da", deny = "<Leader>dd", reload = "<Leader>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 = "<Leader>da",
deny = "<Leader>dd",
reload = "<Leader>dr",
edit = "<Leader>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 = "<Leader>ea", -- Custom keybinding example
},
})
-- You can also call functions directly
vim.keymap.set('n', '<Leader>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.

View file

@ -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 = "<Leader>da",
deny = "<Leader>dd",
reload = "<Leader>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 = "<Leader>da",
deny = "<Leader>dd",
reload = "<Leader>dr",
edit = "<Leader>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