From 9dc29611802fced2dca009a21a16f10a0aa66b98 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:51:29 -0500 Subject: [PATCH] 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. --- lua/watchexec/config.lua | 161 +++++++++++++++++ lua/watchexec/window.lua | 245 +++++++++++++++++++++++++ tests/watchexec/config_spec.lua | 126 +++++++++++++ tests/watchexec/window_spec.lua | 306 ++++++++++++++++++++++++++++++++ 4 files changed, 838 insertions(+) create mode 100644 lua/watchexec/config.lua create mode 100644 lua/watchexec/window.lua create mode 100644 tests/watchexec/config_spec.lua create mode 100644 tests/watchexec/window_spec.lua diff --git a/lua/watchexec/config.lua b/lua/watchexec/config.lua new file mode 100644 index 0000000..44e6d78 --- /dev/null +++ b/lua/watchexec/config.lua @@ -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 diff --git a/lua/watchexec/window.lua b/lua/watchexec/window.lua new file mode 100644 index 0000000..7989b02 --- /dev/null +++ b/lua/watchexec/window.lua @@ -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 ( 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", "", 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 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 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 diff --git a/tests/watchexec/config_spec.lua b/tests/watchexec/config_spec.lua new file mode 100644 index 0000000..b208d7a --- /dev/null +++ b/tests/watchexec/config_spec.lua @@ -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) diff --git a/tests/watchexec/window_spec.lua b/tests/watchexec/window_spec.lua new file mode 100644 index 0000000..965f86a --- /dev/null +++ b/tests/watchexec/window_spec.lua @@ -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 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 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 == "" 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 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)