feat: add configuration system and output window

Introduce config module for managing user options, defaults, and watchexec
binary discovery. Add window module for creating and managing the output
window (float or split) with append, clear, resize, and keymap support.
This commit is contained in:
Stevan Freeborn
2026-07-06 19:51:29 -05:00
parent ef4e0aeebe
commit 9dc2961180
4 changed files with 838 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
---@class watchexec.FloatOpts
---@field relative? string
---@field width? number
---@field height? number
---@field row? number
---@field col? number
---@class watchexec.WindowOpts
---@field type? "float"|"split"
---@field split? "below"|"above"|"left"|"right"
---@field size? integer
---@field float? watchexec.FloatOpts
---@field border? string|string[]
---@class watchexec.WatchexecOpts
---@field bin? string
---@field args? string[]
---@class watchexec.IndicatorPatterns
---@field success? string
---@field running? string
---@class watchexec.IndicatorOpts
---@field enabled? boolean
---@field position? "bottom-left"|"bottom-right"|"top-left"|"top-right"
---@field success_hl? string
---@field failure_hl? string
---@field width? integer
---@field height? integer
---@field patterns? watchexec.IndicatorPatterns
---@class watchexec.Config
---@field watchexec? watchexec.WatchexecOpts
---@field window? watchexec.WindowOpts
---@field indicator? watchexec.IndicatorOpts
---@field auto_scroll? boolean
---@field max_lines? integer
local M = {}
---@type watchexec.Config
local defaults = {
watchexec = {
bin = "watchexec",
args = {},
},
window = {
type = "float",
split = "below",
size = 12,
float = {
relative = "editor",
width = 0.8,
height = 0.6,
row = 0.5,
col = 0.5,
},
border = "single",
},
indicator = {
enabled = true,
position = "bottom-right",
success_hl = "WatchexecSuccess",
failure_hl = "WatchexecFailure",
width = 2,
height = 1,
patterns = {
success = "%[Command was successful%]",
running = "%[Running",
},
},
auto_scroll = true,
max_lines = 5000,
}
---@type watchexec.Config
local config = vim.deepcopy(defaults)
---Search for the watchexec binary in PATH and candidate locations.
---@return string|nil
local function find_binary()
local bin = config.watchexec.bin
if bin and vim.fn.executable(bin) == 1 then
return bin
end
local home = vim.fn.expand("~")
if vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1 then
local candidates = {
home .. "\\cargo\\bin\\watchexec.exe",
vim.fn.expand("$USERPROFILE") .. "\\.cargo\\bin\\watchexec.exe",
"C:\\tools\\watchexec\\watchexec.exe",
}
for _, p in ipairs(candidates) do
if vim.fn.executable(p) == 1 then
return p
end
end
else
local candidates = {
home .. "/.cargo/bin/watchexec",
home .. "/.local/bin/watchexec",
"/opt/homebrew/bin/watchexec",
"/usr/local/bin/watchexec",
}
for _, p in ipairs(candidates) do
if vim.fn.executable(p) == 1 then
return p
end
end
if vim.fn.executable("wsl.exe") == 1 then
local result = vim.fn.system({ "wsl.exe", "which", "watchexec" })
if vim.v.shell_error == 0 then
result = vim.trim(result)
if #result > 0 then
return "wsl.exe --exec " .. result
end
end
end
end
return nil
end
---Merge user options into the current config and resolve the binary path.
---@param opts? watchexec.Config
function M.setup(opts)
if not opts then
return
end
config = vim.tbl_deep_extend("force", config, opts)
config.watchexec.bin = find_binary()
if not config.watchexec.bin then
vim.notify(
"watchexec.nvim: could not find watchexec binary. Set opts.watchexec.bin in your config.",
vim.log.levels.WARN
)
end
end
---Return the current configuration table.
---@return watchexec.Config
function M.get()
return config
end
---Reset configuration back to defaults.
function M.reset()
config = vim.deepcopy(defaults)
end
return M
+245
View File
@@ -0,0 +1,245 @@
---@brief [[
--- watchexec.nvim window module.
--- Manages the output buffer and window (float or split), including
--- creation, display, text appending, and keymap-driven close.
---@brief ]]
local config = require("watchexec.config")
local M = {}
---@class watchexec.WindowState
---@field buf integer|nil
---@field win integer|nil
---@field visible boolean
---@type watchexec.WindowState
local state = {
buf = nil,
win = nil,
visible = false,
}
---Create or reuse the output buffer.
---Sets buffer-local options and keymaps (<Esc> and q) to close the window.
---@return integer buf
function M.create_buf()
local existing = state.buf
if existing and vim.api.nvim_buf_is_valid(existing) then
return existing
end
local buf = vim.api.nvim_create_buf(false, true)
state.buf = buf
vim.api.nvim_set_option_value("bufhidden", "hide", { buf = buf })
vim.api.nvim_set_option_value("filetype", "watchexec-output", { buf = buf })
vim.api.nvim_set_option_value("modifiable", false, { buf = buf })
pcall(vim.api.nvim_buf_set_name, buf, "watchexec://output")
vim.keymap.set("n", "<Esc>", function()
M.close()
end, { buffer = buf, nowait = true, desc = "Close watchexec window" })
vim.keymap.set("n", "q", function()
M.close()
end, { buffer = buf, nowait = true, desc = "Close watchexec window" })
return buf
end
---Open the output window.
---Creates a float or split window per configuration, or reuses an existing one.
function M.open()
local cfg = config.get()
local buf = M.create_buf()
local win = state.win
if win and vim.api.nvim_win_is_valid(win) then
vim.api.nvim_win_set_buf(win, buf)
vim.api.nvim_set_current_win(win)
state.visible = true
return
end
if cfg.window.type == "float" then
---@type watchexec.FloatOpts
local float = cfg.window.float
local width = float.width <= 1 and math.floor(vim.o.columns * float.width) or float.width
local height = float.height <= 1 and math.floor(vim.o.lines * float.height) or float.height
local row = float.row <= 1 and math.floor((vim.o.lines - height) * float.row) or float.row
local col = float.col <= 1 and math.floor((vim.o.columns - width) * float.col) or float.col
state.win = vim.api.nvim_open_win(buf, true, {
relative = float.relative or "editor",
width = width,
height = height,
row = row,
col = col,
style = "minimal",
border = cfg.window.border or "single",
})
else
local split = cfg.window.split
local size = cfg.window.size
local is_vertical = split == "left" or split == "right"
local dir = (split == "below" or split == "right") and "belowright" or "aboveleft"
local cmd = dir .. " " .. (is_vertical and size .. "vnew" or size .. "new")
vim.cmd(cmd)
local split_win = vim.api.nvim_get_current_win()
state.win = split_win
vim.api.nvim_win_set_buf(split_win, buf)
end
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
if #lines == 1 and lines[1] == "" then
vim.api.nvim_set_option_value("modifiable", true, { buf = buf })
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { " No job running. Use :WatchexecRun <command> to start one.", "" })
vim.api.nvim_set_option_value("modifiable", false, { buf = buf })
end
state.visible = true
require("watchexec.indicator").refresh()
end
function M.close()
local win = state.win
if win and vim.api.nvim_win_is_valid(win) then
vim.api.nvim_win_close(win, true)
end
state.win = nil
state.visible = false
require("watchexec.indicator").refresh()
end
function M.toggle()
if state.visible then
M.close()
else
M.open()
end
end
---Close the window and delete the buffer entirely.
function M.cleanup()
M.close()
local buf = state.buf
if buf and vim.api.nvim_buf_is_valid(buf) then
vim.api.nvim_buf_delete(buf, { force = true })
end
state.buf = nil
end
---Clear the output buffer and reset to the waiting placeholder.
function M.clear()
local buf = state.buf
if not buf or not vim.api.nvim_buf_is_valid(buf) then
return
end
vim.api.nvim_set_option_value("modifiable", true, { buf = buf })
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { " No job running. Use :WatchexecRun <command> to start one.", "" })
vim.api.nvim_set_option_value("modifiable", false, { buf = buf })
end
---Append text to the output buffer.
---On first append, replaces the "waiting for output" placeholder.
---Truncates the buffer when max_lines is exceeded.
---Auto-scrolls to the bottom when enabled.
---@param text string
function M.append(text)
local buf = state.buf
if not buf or not vim.api.nvim_buf_is_valid(buf) then
return
end
local cfg = config.get()
vim.api.nvim_set_option_value("modifiable", true, { buf = buf })
local current = vim.api.nvim_buf_line_count(buf)
local lines = vim.split(text, "\n", { plain = true })
local first_line = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or ""
if first_line:match("^ No job running") then
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
else
vim.api.nvim_buf_set_lines(buf, current, -1, false, lines)
end
if cfg.max_lines and vim.api.nvim_buf_line_count(buf) > cfg.max_lines then
local overflow = vim.api.nvim_buf_line_count(buf) - cfg.max_lines
vim.api.nvim_buf_set_lines(buf, 0, overflow, false, {})
end
vim.api.nvim_set_option_value("modifiable", false, { buf = buf })
local scroll_win = state.win
if cfg.auto_scroll and scroll_win and vim.api.nvim_win_is_valid(scroll_win) then
local line_count = vim.api.nvim_buf_line_count(buf)
vim.api.nvim_win_set_cursor(scroll_win, { line_count, 0 })
end
end
---Recalculate float window dimensions after terminal resize.
---No-op for split windows or when no window is displayed.
function M.resize_float()
local win = state.win
if not win or not vim.api.nvim_win_is_valid(win) then
return
end
local cfg = config.get()
if cfg.window.type ~= "float" then
return
end
---@type watchexec.FloatOpts
local float = cfg.window.float
local width = float.width <= 1 and math.floor(vim.o.columns * float.width) or float.width
local height = float.height <= 1 and math.floor(vim.o.lines * float.height) or float.height
local row = float.row <= 1 and math.floor((vim.o.lines - height) * float.row) or float.row
local col = float.col <= 1 and math.floor((vim.o.columns - width) * float.col) or float.col
vim.api.nvim_win_set_config(win, {
relative = float.relative or "editor",
width = width,
height = height,
row = row,
col = col,
})
end
---Check whether the output window is currently displayed.
---@return boolean
function M.is_visible()
return state.visible
end
---Return the output buffer handle, or nil if not yet created.
---@return integer|nil
function M.get_buf()
return state.buf
end
---Return the output window handle, or nil if not yet created.
---@return integer|nil
function M.get_win()
return state.win
end
return M
+126
View File
@@ -0,0 +1,126 @@
local config = require("watchexec.config")
local stub = require("luassert.stub")
describe("watchexec config", function()
after_each(function()
config.reset()
end)
describe("defaults", function()
it("returns default values before setup", function()
local cfg = config.get()
assert.equals("watchexec", cfg.watchexec.bin)
assert.same({}, cfg.watchexec.args)
assert.equals("float", cfg.window.type)
assert.equals("below", cfg.window.split)
assert.equals(12, cfg.window.size)
assert.is_true(cfg.auto_scroll)
assert.equals(5000, cfg.max_lines)
end)
end)
describe("setup()", function()
it("merges user options over defaults", function()
config.setup({
auto_scroll = false,
watchexec = { args = { "-e", "py" } },
})
local cfg = config.get()
assert.is_false(cfg.auto_scroll)
assert.same({ "-e", "py" }, cfg.watchexec.args)
assert.equals("watchexec", cfg.watchexec.bin)
end)
it("returns without changes when opts is nil", function()
config.setup(nil)
local cfg = config.get()
assert.equals("watchexec", cfg.watchexec.bin)
end)
it("finds watchexec binary via PATH", function()
local exec_stub = stub(vim.fn, "executable", function(name)
if name == "watchexec" then
return 1
end
return 0
end)
config.setup({})
local cfg = config.get()
assert.equals("watchexec", cfg.watchexec.bin)
exec_stub:revert()
end)
it("falls back to candidate paths when not in PATH", function()
local exec_stub = stub(vim.fn, "executable", function(name)
return 0
end)
local expand_stub = stub(vim.fn, "expand", function(name)
if name == "~" then
return "/home/user"
end
return ""
end)
local has_stub = stub(vim.fn, "has", function(name)
return 0
end)
config.setup({})
local cfg = config.get()
assert.is_nil(cfg.watchexec.bin)
exec_stub:revert()
expand_stub:revert()
has_stub:revert()
end)
it("notifies when binary is not found", function()
local exec_stub = stub(vim.fn, "executable", function()
return 0
end)
local has_stub = stub(vim.fn, "has", function()
return 0
end)
local expand_stub = stub(vim.fn, "expand", function()
return "/home/user"
end)
local notify_stub = stub(vim, "notify")
config.setup({})
assert
.stub(notify_stub)
.was_called_with("watchexec.nvim: could not find watchexec binary. Set opts.watchexec.bin in your config.", vim.log.levels.WARN)
exec_stub:revert()
has_stub:revert()
expand_stub:revert()
notify_stub:revert()
end)
end)
describe("get()", function()
it("returns the current config table", function()
config.setup({ max_lines = 100 })
local cfg = config.get()
assert.equals(100, cfg.max_lines)
end)
end)
end)
+306
View File
@@ -0,0 +1,306 @@
local config = require("watchexec.config")
local window = require("watchexec.window")
local indicator = require("watchexec.indicator")
local stub = require("luassert.stub")
describe("watchexec window", function()
before_each(function()
config.reset()
config.setup({})
window.cleanup()
stub(indicator, "refresh")
stub(indicator, "reset")
end)
after_each(function()
window.cleanup()
config.reset()
indicator.refresh:revert()
indicator.reset:revert()
end)
describe("open()", function()
it("opens a float window by default", function()
window.open()
local win = window.get_win()
local win_config = vim.api.nvim_win_get_config(win)
assert.equals("editor", win_config.relative)
assert.is_true(vim.api.nvim_win_is_valid(win))
assert.is_true(window.is_visible())
end)
it("opens a float window when configured", function()
config.setup({
window = { type = "float" },
})
window.open()
local win = window.get_win()
assert.is_true(vim.api.nvim_win_is_valid(win))
local win_config = vim.api.nvim_win_get_config(win)
assert.equals("editor", win_config.relative)
end)
it("reuses existing window if still valid", function()
window.open()
window.close()
window.open()
local second_win = window.get_win()
assert.is_true(vim.api.nvim_win_is_valid(second_win))
end)
end)
describe("close()", function()
it("closes the open window", function()
window.open()
local wins_before = #vim.api.nvim_list_wins()
window.close()
local wins_after = #vim.api.nvim_list_wins()
assert.equals(wins_before - 1, wins_after)
assert.is_nil(window.get_win())
assert.is_false(window.is_visible())
end)
it("does nothing if no window is open", function()
assert.is_nil(window.get_win())
window.close()
assert.is_nil(window.get_win())
end)
end)
describe("toggle()", function()
it("opens the window if closed", function()
window.toggle()
assert.not_nil(window.get_win())
end)
it("closes the window if open", function()
window.open()
window.toggle()
assert.is_nil(window.get_win())
end)
end)
describe("cleanup()", function()
it("closes the window and deletes the buffer", function()
window.open()
local buf = window.get_buf()
window.cleanup()
assert.is_false(vim.api.nvim_buf_is_valid(buf))
assert.is_nil(window.get_buf())
assert.is_nil(window.get_win())
end)
end)
describe("is_visible()", function()
it("returns true after open", function()
window.open()
assert.is_true(window.is_visible())
end)
it("returns false after close", function()
window.open()
window.close()
assert.is_false(window.is_visible())
end)
end)
describe("resize_float()", function()
it("recalculates float dimensions after terminal resize", function()
config.setup({
window = { type = "float" },
})
window.open()
local win = window.get_win()
local before = vim.api.nvim_win_get_config(win)
local orig_cols = vim.o.columns
local orig_lines = vim.o.lines
vim.o.columns = orig_cols + 20
vim.o.lines = orig_lines + 10
window.resize_float()
local after = vim.api.nvim_win_get_config(win)
vim.o.columns = orig_cols
vim.o.lines = orig_lines
assert.is_not.equals(before.width, after.width)
assert.is_not.equals(before.height, after.height)
end)
it("does nothing when no window is open", function()
window.resize_float()
end)
it("does nothing for split windows", function()
window.open()
window.resize_float()
end)
end)
describe("get_buf() / get_win()", function()
it("returns nil before open", function()
assert.is_nil(window.get_buf())
assert.is_nil(window.get_win())
end)
it("returns values after open", function()
window.open()
assert.not_nil(window.get_buf())
assert.not_nil(window.get_win())
end)
end)
describe("append()", function()
it("appends text to the buffer", function()
window.open()
local buf = window.get_buf()
window.append("line one")
window.append("line two")
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
assert.equals("line one", lines[1])
assert.equals("line two", lines[2])
end)
it("does nothing when buffer is invalid", function()
window.append("should not error")
end)
it("replaces the waiting placeholder on first append", function()
window.open()
local buf = window.get_buf()
vim.api.nvim_set_option_value("modifiable", true, { buf = buf })
vim.api.nvim_buf_set_lines(
buf,
0,
-1,
false,
{ " No job running. Use :WatchexecRun <command> to start one.", "" }
)
vim.api.nvim_set_option_value("modifiable", false, { buf = buf })
window.append("first output")
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
assert.equals("first output", lines[1])
end)
it("truncates to max_lines when exceeded", function()
config.setup({
max_lines = 3,
})
window.open()
local buf = window.get_buf()
window.append("a")
window.append("b")
window.append("c")
window.append("d")
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
assert.equals(3, #lines)
assert.equals("b", lines[1])
assert.equals("c", lines[2])
assert.equals("d", lines[3])
end)
it("scrolls to bottom when auto_scroll is enabled", function()
config.setup({ auto_scroll = true })
window.open()
window.append("line one")
window.append("line two")
local win = window.get_win()
local cursor = vim.api.nvim_win_get_cursor(win)
local buf = window.get_buf()
local line_count = vim.api.nvim_buf_line_count(buf)
assert.equals(line_count, cursor[1])
end)
end)
describe("keymaps", function()
it("maps <Esc> to close the window", function()
window.open()
local buf = window.get_buf()
local maps = vim.api.nvim_buf_get_keymap(buf, "n")
local found = false
for _, m in ipairs(maps) do
if m.lhs == "<Esc>" then
found = true
break
end
end
assert.is_true(found)
end)
it("maps q to close the window", function()
window.open()
local buf = window.get_buf()
local maps = vim.api.nvim_buf_get_keymap(buf, "n")
local found = false
for _, m in ipairs(maps) do
if m.lhs == "q" then
found = true
break
end
end
assert.is_true(found)
end)
it("calls window.close() via the <Esc> keymap", function()
window.open()
assert.is_true(window.is_visible())
vim.cmd("normal " .. "\027")
assert.is_false(window.is_visible())
end)
it("calls window.close() via the q keymap", function()
window.open()
assert.is_true(window.is_visible())
vim.cmd("normal q")
assert.is_false(window.is_visible())
end)
end)
end)