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)