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 1/7] 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) From 398d3dd585b6ef7ec16208e7fc3c9e8d43811345 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:51:33 -0500 Subject: [PATCH 2/7] feat: add process runner and status indicator Implement runner module for spawning and managing watchexec child processes via vim.fn.jobstart with stdout/stderr piping and ANSI stripping. Add indicator module for displaying a small non-focusable float that shows the last command outcome (success/failure) when the main window is hidden. --- lua/watchexec/indicator.lua | 193 ++++++++++++++++++++++++ lua/watchexec/runner.lua | 232 +++++++++++++++++++++++++++++ tests/watchexec/indicator_spec.lua | 163 ++++++++++++++++++++ tests/watchexec/runner_spec.lua | 218 +++++++++++++++++++++++++++ 4 files changed, 806 insertions(+) create mode 100644 lua/watchexec/indicator.lua create mode 100644 lua/watchexec/runner.lua create mode 100644 tests/watchexec/indicator_spec.lua create mode 100644 tests/watchexec/runner_spec.lua diff --git a/lua/watchexec/indicator.lua b/lua/watchexec/indicator.lua new file mode 100644 index 0000000..cadabf9 --- /dev/null +++ b/lua/watchexec/indicator.lua @@ -0,0 +1,193 @@ +---@brief [[ +--- watchexec.nvim indicator module. +--- Shows a small non-focusable float indicating the last command outcome +--- while the main output window is hidden and a job is running. +---@brief ]] + +---@diagnostic disable: need-check-nil + +local config = require("watchexec.config") + +local M = {} + +local ns = vim.api.nvim_create_namespace("watchexec-indicator-highlights") + +local state = { + win = nil, + buf = nil, + ---@type "success"|"failure"|nil + last_outcome = nil, + waiting_for_outcome = false, +} + +---@return integer +local function create_buf() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_option_value("bufhidden", "wipe", { buf = buf }) + vim.api.nvim_set_option_value("modifiable", true, { buf = buf }) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { " " }) + vim.api.nvim_set_option_value("modifiable", false, { buf = buf }) + return buf +end + +---@return integer, integer +local function calculate_position() + local cfg = config.get().indicator + local width = cfg.width or 2 + local height = cfg.height or 1 + + if cfg.position == "bottom-left" then + return vim.o.lines - height - 3, 0 + elseif cfg.position == "bottom-right" then + return vim.o.lines - height - 3, vim.o.columns - width + elseif cfg.position == "top-left" then + return 0, 0 + elseif cfg.position == "top-right" then + return 0, vim.o.columns - width + end + + return vim.o.lines - height - 3, 0 +end + +local function close_float() + 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 +end + +local function create_float() + local cfg = config.get().indicator + local cfg_width = cfg.width or 2 + local cfg_height = cfg.height or 1 + local buf = state.buf + + if not buf or not vim.api.nvim_buf_is_valid(buf) then + buf = create_buf() + state.buf = buf + end + + local row, col = calculate_position() + local hl = state.last_outcome == "success" and (cfg.success_hl or "WatchexecSuccess") + or (cfg.failure_hl or "WatchexecFailure") + + vim.api.nvim_set_option_value("modifiable", true, { buf = buf }) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { string.rep(" ", cfg_width) }) + vim.api.nvim_set_option_value("modifiable", false, { buf = buf }) + vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) + vim.api.nvim_buf_set_extmark(buf, ns, 0, 0, { end_col = cfg_width, hl_group = hl, hl_eol = true }) + + state.win = vim.api.nvim_open_win(buf, false, { + relative = "editor", + width = cfg_width, + height = cfg_height, + row = row, + col = col, + style = "minimal", + focusable = false, + noautocmd = true, + }) +end + +---Process output text from the running job. +---Tracks command lifecycle based on configured patterns. +---@param text string +function M.process_output(text) + local cfg = config.get().indicator + local running_pat = cfg.patterns.running + local success_pat = cfg.patterns.success + + for line in text:gmatch("[^\n]+") do + if running_pat and line:find(running_pat) then + if state.waiting_for_outcome then + state.last_outcome = "failure" + end + + state.waiting_for_outcome = true + elseif success_pat and line:find(success_pat) then + state.last_outcome = "success" + state.waiting_for_outcome = false + elseif line:find("^%[Command ") and not (success_pat and line:find(success_pat)) then + state.last_outcome = "failure" + state.waiting_for_outcome = false + end + end +end + +---Notify the indicator that the process has exited. +---If a command was in-flight it is marked as failed. +function M.process_exit() + if state.waiting_for_outcome then + state.last_outcome = "failure" + state.waiting_for_outcome = false + end +end + +---Reset tracked outcome (e.g. when starting a new job). +function M.reset() + state.last_outcome = nil + state.waiting_for_outcome = false +end + +---Show or hide the indicator based on current state. +---Shows when: enabled, window hidden, and outcome known. +function M.refresh() + local cfg = config.get().indicator + local enabled = cfg.enabled ~= false + local window_visible = require("watchexec.window").is_visible() + + if enabled and not window_visible and state.last_outcome then + if state.win and vim.api.nvim_win_is_valid(state.win) then + M.reposition() + else + create_float() + end + else + close_float() + end +end + +---Recalculate the indicator position. +function M.reposition() + local win = state.win + + if not win or not vim.api.nvim_win_is_valid(win) then + return + end + + local cfg = config.get().indicator + local cfg_width = cfg.width or 2 + local cfg_height = cfg.height or 1 + local row, col = calculate_position() + local hl = state.last_outcome == "success" and (cfg.success_hl or "WatchexecSuccess") + or (cfg.failure_hl or "WatchexecFailure") + local buf = state.buf + + if buf and vim.api.nvim_buf_is_valid(buf) then + vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) + vim.api.nvim_buf_set_extmark(buf, ns, 0, 0, { end_col = cfg_width, hl_group = hl, hl_eol = true }) + end + + vim.api.nvim_win_set_config(win, { + relative = "editor", + width = cfg_width, + height = cfg_height, + row = row, + col = col, + }) +end + +---@return integer|nil +function M.get_win() + return state.win +end + +---@return "success"|"failure"|nil +function M.get_last_outcome() + return state.last_outcome +end + +return M diff --git a/lua/watchexec/runner.lua b/lua/watchexec/runner.lua new file mode 100644 index 0000000..749618b --- /dev/null +++ b/lua/watchexec/runner.lua @@ -0,0 +1,232 @@ +---@brief [[ +--- watchexec.nvim runner module. +--- Spawns and manages the watchexec child process via Neovim's job API +--- (`vim.fn.jobstart`), pipes stdout/stderr to the output window, and +--- applies ANSI-free highlights. +---@brief ]] + +local config = require("watchexec.config") + +local M = {} + +local ns = vim.api.nvim_create_namespace("watchexec-runner-highlights") + +---@class watchexec.RunnerState +---@field job_id integer|nil +---@field pid integer|nil +---@field cmd string|nil +---@field stop_requested boolean + +---@type watchexec.RunnerState +local state = { + job_id = nil, + pid = nil, + cmd = nil, + stop_requested = false, +} + +---Strip ANSI escape sequences from a string. +---@param text string +---@return string +local function strip_ansi(text) + local result = + text:gsub("\x1b%[%??[0-9;]*[a-zA-Z]", ""):gsub("\x1b%][0-9;]*.-(\x1b\\|\x07)", ""):gsub("\x1b[()][0-9A-Za-z]", "") + + return result +end + +---Apply diagnostic highlights to keywords found in a line. +---Matches error, warning, success patterns using case-insensitive patterns. +---@param buf integer +---@param line_idx integer +---@param line string +local function apply_highlights(buf, line_idx, line) + for _, p in ipairs({ + { p = "[Ee][Rr][Rr][Oo][Rr]", h = "DiagnosticError" }, + { p = "[Ff][Aa][Ii][Ll][Ee][Dd]?", h = "DiagnosticError" }, + { p = "[Ee][Rr][Rr]!", h = "DiagnosticError" }, + { p = "[Ff][Aa][Tt][Aa][Ll]", h = "DiagnosticError" }, + { p = "[Ww][Aa][Rr][Nn][Ii][Nn][Gg]", h = "DiagnosticWarn" }, + { p = "[Ww][Aa][Rr][Nn]!", h = "DiagnosticWarn" }, + { p = "[Ss][Uu][Cc][Cc][Ee][Ss][Ss]", h = "DiagnosticOk" }, + { p = "[Pp][Aa][Ss][Ss][Ee][Dd]", h = "DiagnosticOk" }, + { p = "%^%d+ .-[Ss]ucceed", h = "DiagnosticOk" }, + { p = "%[ok%]", h = "DiagnosticOk" }, + { p = "[Oo][Kk]!", h = "DiagnosticOk" }, + }) do + + local s, e = line:find(p.p) + + if s then + pcall(vim.api.nvim_buf_set_extmark, buf, ns, line_idx, s - 1, { end_col = e, hl_group = p.h }) + end + end +end + +---Process job output data and append to the window with highlights. +---@param text string +local function process_data(text) + if text == "" then + return + end + + local clean = strip_ansi(text) + + require("watchexec.window").append(clean) + require("watchexec.indicator").process_output(clean) + require("watchexec.indicator").refresh() + + local buf = require("watchexec.window").get_buf() + + if not buf or not vim.api.nvim_buf_is_valid(buf) then + return + end + + local lines = vim.split(clean, "\n", { plain = true }) + local line_count = vim.api.nvim_buf_line_count(buf) + + for i, line in ipairs(lines) do + apply_highlights(buf, line_count - #lines + i - 1, line) + end +end + +---Split a string into arguments by whitespace. +---@param str string +---@return string[] +local function split_args(str) + local args = {} + + for part in str:gmatch("%S+") do + table.insert(args, part) + end + + return args +end + +---Build the command list for jobstart from the binary, config args, and command. +---@param command string +---@return string[] +local function build_cmd(command) + local cfg = config.get() + local binary = cfg.watchexec.bin + local cmd_parts = {} + + if not binary then + return cmd_parts + end + + if binary:match("^wsl%.exe") then + for part in binary:gmatch("%S+") do + table.insert(cmd_parts, part) + end + else + table.insert(cmd_parts, binary) + end + + for _, a in ipairs(cfg.watchexec.args) do + table.insert(cmd_parts, a) + end + + for _, a in ipairs(split_args(command)) do + table.insert(cmd_parts, a) + end + + return cmd_parts +end + +---Start a watchexec process for the given command. +---Builds the argument list and spawns via `vim.fn.jobstart`. +---@param command string Shell command to watch and execute +function M.start(command) + state.stop_requested = false + require("watchexec.indicator").reset() + + local cmd_parts = build_cmd(command) + local binary = cmd_parts[1] + + if not binary then + vim.notify("watchexec.nvim: watchexec binary not found", vim.log.levels.ERROR) + return + end + + state.cmd = command + + local job_id = vim.fn.jobstart(cmd_parts, { + on_stdout = function(_, data, _) + vim.schedule(function() + process_data(table.concat(data, "\n")) + end) + end, + on_stderr = function(_, data, _) + vim.schedule(function() + local text = table.concat(data, "\n") + local clean = strip_ansi(text) + + if clean ~= "" then + process_data(clean) + end + end) + end, + on_exit = function(job_id, code, _) + vim.schedule(function() + if state.job_id ~= job_id then + return + end + + if state.stop_requested then + state.stop_requested = false + require("watchexec.indicator").reset() + else + require("watchexec.indicator").process_exit() + require("watchexec.window").append(string.format("[watchexec] exited: code=%d", code)) + require("watchexec.indicator").refresh() + end + + state.job_id = nil + state.pid = nil + end) + end, + }) + + if not job_id or job_id <= 0 then + vim.notify("watchexec.nvim: failed to spawn " .. binary, vim.log.levels.ERROR) + state.cmd = nil + return + end + + local pid = vim.fn.jobpid(job_id) + + state.job_id = job_id + state.pid = pid +end + +---Stop the currently running watchexec process. +---Uses jobstop and PID-based kill for robustness on Windows. +function M.stop() + if state.job_id then + state.stop_requested = true + pcall(vim.fn.jobstop, state.job_id) + end + + if state.pid then + pcall(vim.uv.kill, state.pid, "term") + end + + state.job_id = nil + state.pid = nil + state.cmd = nil +end + +---Check whether a watchexec process is currently running. +---@return boolean +function M.is_running() + return state.job_id ~= nil +end + +---Return the command string passed to the running process, or nil. +---@return string|nil +function M.get_cmd() + return state.cmd +end + +return M diff --git a/tests/watchexec/indicator_spec.lua b/tests/watchexec/indicator_spec.lua new file mode 100644 index 0000000..7019164 --- /dev/null +++ b/tests/watchexec/indicator_spec.lua @@ -0,0 +1,163 @@ +local indicator = require("watchexec.indicator") +local config = require("watchexec.config") +local stub = require("luassert.stub") + +describe("watchexec indicator", function() + local runner + local window + + before_each(function() + config.reset() + config.setup({ indicator = { enabled = true } }) + indicator.reset() + + runner = require("watchexec.runner") + window = require("watchexec.window") + + stub(runner, "is_running", function() + return false + end) + stub(window, "is_visible", function() + return false + end) + end) + + after_each(function() + runner.is_running:revert() + window.is_visible:revert() + end) + + describe("process_output()", function() + it("marks last_outcome as success on success pattern", function() + indicator.process_output("[Command was successful]") + assert.equals("success", indicator.get_last_outcome()) + end) + + it("sets waiting_for_outcome on running pattern", function() + indicator.process_output("[Running] echo hello") + indicator.refresh() + assert.is_nil(indicator.get_win()) + end) + + it("marks failure when next command starts before success", function() + indicator.process_output("[Running] echo first") + indicator.process_output("[Running] echo second") + + indicator.refresh() + + local win = indicator.get_win() + assert.is_true(vim.api.nvim_win_is_valid(win)) + assert.equals("failure", indicator.get_last_outcome()) + end) + + it("marks failure on Command exited with code pattern", function() + indicator.process_output("[Command exited with code 1]") + assert.equals("failure", indicator.get_last_outcome()) + end) + + it("leaves outcome unchanged for unrelated lines", function() + indicator.process_output("some random output") + assert.is_nil(indicator.get_last_outcome()) + end) + end) + + describe("process_exit()", function() + it("marks failure if a command was in-flight", function() + indicator.process_output("[Running] echo hello") + indicator.process_exit() + assert.equals("failure", indicator.get_last_outcome()) + end) + + it("does nothing if no command was in-flight", function() + indicator.process_exit() + assert.is_nil(indicator.get_last_outcome()) + end) + end) + + describe("refresh()", function() + it("creates indicator when outcome known and window hidden", function() + indicator.process_output("[Command was successful]") + + indicator.refresh() + + local win = indicator.get_win() + assert.is_true(vim.api.nvim_win_is_valid(win)) + end) + + it("does not create indicator when disabled", function() + config.reset() + config.setup({ indicator = { enabled = false } }) + + indicator.process_output("[Command was successful]") + + indicator.refresh() + + assert.is_nil(indicator.get_win()) + end) + + it("does not create indicator when window is visible", function() + indicator.process_output("[Command was successful]") + + window.is_visible:revert() + stub(window, "is_visible", function() + return true + end) + + indicator.refresh() + + assert.is_nil(indicator.get_win()) + end) + + it("does not create indicator when last_outcome is nil", function() + indicator.refresh() + + assert.is_nil(indicator.get_win()) + end) + + it("closes indicator when window becomes visible and reopens when hidden", function() + local visible = false + window.is_visible:revert() + stub(window, "is_visible", function() + return visible + end) + + indicator.process_output("[Command was successful]") + + indicator.refresh() + + assert.is_not_nil(indicator.get_win()) + + visible = true + + indicator.refresh() + + assert.is_nil(indicator.get_win()) + assert.equals("success", indicator.get_last_outcome()) + + visible = false + + indicator.refresh() + + assert.is_not_nil(indicator.get_win()) + end) + + it("creates a non-focusable float window", function() + indicator.process_output("[Command was successful]") + + indicator.refresh() + + local win = indicator.get_win() + local win_config = vim.api.nvim_win_get_config(win) + + assert.is_false(win_config.focusable) + end) + end) + + describe("reset()", function() + it("clears last_outcome and waiting_for_outcome", function() + indicator.process_output("[Running] echo hello") + indicator.reset() + assert.is_nil(indicator.get_last_outcome()) + end) + end) +end) diff --git a/tests/watchexec/runner_spec.lua b/tests/watchexec/runner_spec.lua new file mode 100644 index 0000000..b619b09 --- /dev/null +++ b/tests/watchexec/runner_spec.lua @@ -0,0 +1,218 @@ +local config = require("watchexec.config") +local window = require("watchexec.window") +local runner = require("watchexec.runner") +local stub = require("luassert.stub") + +describe("watchexec runner", function() + local jobstart_stub + local jobpid_stub + local jobstop_stub + local notify_stub + local indicator + + before_each(function() + config.reset() + config.setup({}) + window.cleanup() + + jobstart_stub = stub(vim.fn, "jobstart", function() + return 42 + end) + + jobpid_stub = stub(vim.fn, "jobpid", function() + return 12345 + end) + + jobstop_stub = stub(vim.fn, "jobstop") + notify_stub = stub(vim, "notify") + + indicator = require("watchexec.indicator") + stub(indicator, "process_output") + stub(indicator, "process_exit") + stub(indicator, "reset") + stub(indicator, "refresh") + end) + + after_each(function() + if runner.is_running() then + runner.stop() + end + + jobstart_stub:revert() + jobpid_stub:revert() + jobstop_stub:revert() + + notify_stub:revert() + + indicator.process_output:revert() + indicator.process_exit:revert() + indicator.reset:revert() + indicator.refresh:revert() + + window.cleanup() + + config.reset() + end) + + describe("start()", function() + it("spawns watchexec with binary and command args", function() + window.open() + runner.start("pytest") + + assert.stub(jobstart_stub).was_called(1) + + local cmd = jobstart_stub.calls[1].refs[1] + + assert.same({ "watchexec", "pytest" }, cmd) + end) + + it("includes watchexec.args before command", function() + config.setup({ + watchexec = { args = { "-e", "py" } }, + }) + window.open() + runner.start("-- pytest") + + local cmd = jobstart_stub.calls[1].refs[1] + + assert.same({ "watchexec", "-e", "py", "--", "pytest" }, cmd) + end) + + it("gets pid via jobpid", function() + window.open() + runner.start("test") + + assert.stub(jobpid_stub).was_called_with(42) + end) + + it("notifies error when binary not found", function() + config.get().watchexec.bin = nil + window.open() + runner.start("test") + + assert.stub(notify_stub).was_called_with("watchexec.nvim: watchexec binary not found", vim.log.levels.ERROR) + assert.stub(jobstart_stub).was_called(0) + end) + + it("stores is_running state after spawn", function() + window.open() + runner.start("test") + + assert.is_true(runner.is_running()) + end) + + it("stores cmd after spawn", function() + window.open() + runner.start("pytest -x") + + assert.equals("pytest -x", runner.get_cmd()) + end) + + it("provides callbacks to jobstart", function() + window.open() + runner.start("test") + + local opts = jobstart_stub.calls[1].refs[2] + + assert.is_function(opts.on_stdout) + assert.is_function(opts.on_stderr) + assert.is_function(opts.on_exit) + end) + end) + + describe("stop()", function() + it("calls jobstop", function() + window.open() + runner.start("test") + runner.stop() + + assert.stub(jobstop_stub).was_called_with(42) + end) + + it("calls vim.uv.kill with pid", function() + local uv_kill_stub = stub(vim.uv, "kill") + + window.open() + runner.start("test") + runner.stop() + + assert.stub(uv_kill_stub).was_called_with(12345, "term") + + uv_kill_stub:revert() + end) + + it("clears running state", function() + window.open() + runner.start("test") + runner.stop() + + assert.is_false(runner.is_running()) + end) + + it("does nothing if no process running", function() + runner.stop() + + assert.is_false(runner.is_running()) + end) + end) + + describe("is_running()", function() + it("returns true after start", function() + window.open() + runner.start("test") + + assert.is_true(runner.is_running()) + end) + + it("returns false initially", function() + assert.is_false(runner.is_running()) + end) + end) + + describe("get_cmd()", function() + it("returns the command passed to start", function() + window.open() + runner.start("pytest -x") + + assert.equals("pytest -x", runner.get_cmd()) + end) + + it("returns nil after stop", function() + window.open() + runner.start("test") + runner.stop() + + assert.is_nil(runner.get_cmd()) + end) + end) + + describe("on_exit guard", function() + it("ignores stale on_exit from replaced job", function() + local call_count = 0 + + jobstart_stub:revert() + jobstart_stub = stub(vim.fn, "jobstart", function() + call_count = call_count + 1 + return 42 + call_count + end) + + jobpid_stub:revert() + jobpid_stub = stub(vim.fn, "jobpid", function() + return 10000 + call_count + end) + + window.open() + runner.start("first") + + local first_opts = jobstart_stub.calls[1].refs[2] + + runner.start("second") + + first_opts.on_exit(43, 0, nil) + vim.cmd("sleep 1m") + + assert.is_true(runner.is_running()) + assert.equals("second", runner.get_cmd()) + end) + end) +end) From 1c46c3fcd935e9ae2feb2b512290d6afd14ab1d1 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:51:37 -0500 Subject: [PATCH 3/7] feat: implement public API and plugin commands Rewrite init.lua to expose setup(), run(), stop(), and toggle() that coordinate across config, runner, window, and indicator modules. Replace the placeholder SayHello command in plugin/watchexec.lua with keymaps (wx{t,s,r}), :WatchexecRun/:WatchexecStop/:WatchexecToggle commands, VimLeavePre cleanup, and VimResized auto-resize. --- lua/watchexec/init.lua | 66 ++++++- plugin/watchexec.lua | 59 ++++++- tests/watchexec/commands_spec.lua | 269 +++++++++++++++++++++++++++++ tests/watchexec/health_spec.lua | 9 +- tests/watchexec/watchexec_spec.lua | 36 ++-- 5 files changed, 417 insertions(+), 22 deletions(-) create mode 100644 tests/watchexec/commands_spec.lua diff --git a/lua/watchexec/init.lua b/lua/watchexec/init.lua index da825df..3e8468f 100644 --- a/lua/watchexec/init.lua +++ b/lua/watchexec/init.lua @@ -1,15 +1,67 @@ +---@brief [[ +--- watchexec.nvim integrates the watchexec CLI into Neovim, providing a +--- floating or split window to display file-watching command output. +---@brief ]] + +local config = require("watchexec.config") +local runner = require("watchexec.runner") +local window = require("watchexec.window") +local indicator = require("watchexec.indicator") + local M = {} -M.config = { - greeting = "Hello from my plugin", -} - +---Merge user-provided options into the config and resolve the watchexec binary. +---Call this in your init.lua: `require("watchexec").setup({...})`. +---@param opts? watchexec.Config function M.setup(opts) - M.config = vim.tbl_deep_extend("force", M.config, opts or {}) + config.setup(opts) end -function M.say_hello() - print(M.config.greeting) +---Start a watchexec process for the given command. +---Stops any previously running process, opens the output window if hidden, +---then spawns the child process. +---@param command string Shell command to run under watchexec +function M.run(command) + if runner.is_running() then + runner.stop() + end + + window.clear() + + if not window.is_visible() then + window.open() + end + + runner.start(command) + indicator.refresh() +end + +---Stop the currently running watchexec process. +function M.stop() + runner.stop() + window.clear() + indicator.reset() + indicator.refresh() +end + +---Toggle the watchexec output window. +---If no process is running and window is hidden, opens with a placeholder. +---If no process is running and window is visible, closes it. +---If running and visible, hides the window. +---If running but hidden, shows the window. +function M.toggle() + if not runner.is_running() then + if window.is_visible() then + window.close() + else + window.clear() + window.open() + end + elseif window.is_visible() then + window.toggle() + else + window.open() + end end return M diff --git a/plugin/watchexec.lua b/plugin/watchexec.lua index c852cd0..74e625b 100644 --- a/plugin/watchexec.lua +++ b/plugin/watchexec.lua @@ -1,9 +1,64 @@ +---@type boolean|nil if vim.g.loaded_watchexec then return end vim.g.loaded_watchexec = 1 -vim.api.nvim_create_user_command("SayHello", function() - require("watchexec").say_hello() +vim.keymap.set("n", "wxt", function() + require("watchexec").toggle() +end, { desc = "Toggle watchexec window" }) + +vim.keymap.set("n", "wxs", function() + require("watchexec").stop() +end, { desc = "Stop watchexec" }) + +vim.keymap.set("n", "wxr", function() + local cmd = vim.fn.input("Watchexec: ") + + if cmd and #cmd > 0 then + require("watchexec").run(cmd) + end +end, { desc = "Run watchexec with prompt" }) + +vim.api.nvim_create_user_command("WatchexecRun", function(opts) + require("watchexec").run(opts.args) +end, { nargs = 1, complete = "file" }) + +vim.api.nvim_create_user_command("WatchexecStop", function() + require("watchexec").stop() end, {}) + +vim.api.nvim_create_user_command("WatchexecToggle", function() + require("watchexec").toggle() +end, {}) + +vim.api.nvim_set_hl(0, "WatchexecSuccess", { bg = "#00ff00", default = true }) +vim.api.nvim_set_hl(0, "WatchexecFailure", { bg = "#ff0000", default = true }) + +vim.api.nvim_create_augroup("watchexec_nvim", { clear = true }) + +vim.api.nvim_create_autocmd("VimLeavePre", { + group = "watchexec_nvim", + callback = function() + require("watchexec.runner").stop() + end, +}) + +---@type table|nil +local resize_timer + +vim.api.nvim_create_autocmd("VimResized", { + group = "watchexec_nvim", + callback = function() + if resize_timer then + resize_timer:close() + end + + resize_timer = vim.defer_fn(function() + resize_timer = nil + require("watchexec.window").resize_float() + require("watchexec.indicator").reposition() + end, 100) + end, +}) diff --git a/tests/watchexec/commands_spec.lua b/tests/watchexec/commands_spec.lua new file mode 100644 index 0000000..598b806 --- /dev/null +++ b/tests/watchexec/commands_spec.lua @@ -0,0 +1,269 @@ +local watchexec = require("watchexec") +local stub = require("luassert.stub") + +describe("watchexec commands", function() + local runner + local window + local indicator + + before_each(function() + runner = require("watchexec.runner") + window = require("watchexec.window") + indicator = require("watchexec.indicator") + + stub(runner, "start") + stub(runner, "stop") + + stub(runner, "is_running", function() + return false + end) + + stub(window, "open") + stub(window, "close") + stub(window, "toggle") + + stub(window, "resize_float") + stub(window, "clear") + + stub(window, "is_visible", function() + return false + end) + + stub(indicator, "refresh") + stub(indicator, "reset") + end) + + after_each(function() + runner.start:revert() + runner.stop:revert() + runner.is_running:revert() + + window.open:revert() + window.close:revert() + window.toggle:revert() + window.resize_float:revert() + window.clear:revert() + window.is_visible:revert() + + indicator.refresh:revert() + indicator.reset:revert() + end) + + describe("run()", function() + it("opens window and starts runner", function() + watchexec.run("echo hello") + + assert.stub(runner.start).was_called_with("echo hello") + assert.stub(window.clear).was_called(1) + assert.stub(window.open).was_called(1) + end) + + it("does not open window if already visible", function() + window.is_visible:revert() + stub(window, "is_visible", function() + return true + end) + + watchexec.run("echo hello") + + assert.stub(window.clear).was_called(1) + assert.stub(window.open).was_called(0) + end) + + it("stops previous run before starting new one", function() + runner.is_running:revert() + stub(runner, "is_running", function() + return true + end) + + watchexec.run("echo hello") + + assert.stub(runner.stop).was_called(1) + assert.stub(window.clear).was_called(1) + assert.stub(runner.start).was_called_with("echo hello") + end) + end) + + describe("stop()", function() + it("stops the runner", function() + watchexec.stop() + + assert.stub(runner.stop).was_called(1) + assert.stub(window.clear).was_called(1) + assert.stub(indicator.reset).was_called(1) + assert.stub(indicator.refresh).was_called(1) + end) + end) + + describe("VimLeavePre autocmd", function() + it("stops the runner on exit", function() + dofile("plugin/watchexec.lua") + + vim.api.nvim_exec_autocmds("VimLeavePre", { group = "watchexec_nvim" }) + + assert.stub(runner.stop).was_called(1) + assert.stub(window.clear).was_called(0) + end) + end) + + describe("VimResized autocmd", function() + it("calls resize_float on terminal resize", function() + dofile("plugin/watchexec.lua") + + vim.api.nvim_exec_autocmds("VimResized", { group = "watchexec_nvim" }) + + vim.wait(200, function() + return pcall(function() + assert.stub(window.resize_float).was_called(1) + return true + end) + end) + + assert.stub(window.resize_float).was_called(1) + end) + end) + + describe("keymaps", function() + before_each(function() + vim.g.loaded_watchexec = nil + dofile("plugin/watchexec.lua") + end) + + local function leader() + return vim.g.mapleader or "\\" + end + + it("defines wxt toggle keymap", function() + local maps = vim.api.nvim_get_keymap("n") + local found = false + for _, m in ipairs(maps) do + if m.lhs == leader() .. "wxt" then + found = true + break + end + end + assert.is_true(found) + end) + + it("defines wxs stop keymap", function() + local maps = vim.api.nvim_get_keymap("n") + local found = false + for _, m in ipairs(maps) do + if m.lhs == leader() .. "wxs" then + found = true + break + end + end + assert.is_true(found) + end) + + it("defines wxr run keymap", function() + local maps = vim.api.nvim_get_keymap("n") + local found = false + for _, m in ipairs(maps) do + if m.lhs == leader() .. "wxr" then + found = true + break + end + end + assert.is_true(found) + end) + + it("invokes toggle via wxt", function() + local ldr = leader() + vim.cmd("normal " .. ldr .. "wxt") + + assert.stub(window.clear).was_called(1) + assert.stub(runner.start).was_called(0) + assert.stub(window.open).was_called(1) + end) + + it("invokes stop via wxs", function() + local ldr = leader() + vim.cmd("normal " .. ldr .. "wxs") + + assert.stub(runner.stop).was_called(1) + assert.stub(window.clear).was_called(1) + end) + + it("prompts and runs via wxr", function() + local input_stub = stub(vim.fn, "input", function() + return "echo hello" + end) + + local ldr = leader() + vim.cmd("normal " .. ldr .. "wxr") + + assert.stub(window.clear).was_called(1) + assert.stub(runner.start).was_called_with("echo hello") + assert.stub(window.open).was_called(1) + + input_stub:revert() + end) + end) + + describe("toggle()", function() + it("opens window with placeholder if nothing running", function() + watchexec.toggle() + + assert.stub(window.clear).was_called(1) + assert.stub(runner.start).was_called(0) + assert.stub(window.open).was_called(1) + end) + + it("hides window if runner is running and window is visible", function() + runner.is_running:revert() + + stub(runner, "is_running", function() + return true + end) + + window.is_visible:revert() + + stub(window, "is_visible", function() + return true + end) + + watchexec.toggle() + + assert.stub(window.clear).was_called(0) + assert.stub(runner.start).was_called(0) + assert.stub(window.toggle).was_called(1) + end) + + it("opens window if runner is running but not visible", function() + runner.is_running:revert() + + stub(runner, "is_running", function() + return true + end) + + window.is_visible:revert() + + stub(window, "is_visible", function() + return false + end) + + watchexec.toggle() + + assert.stub(window.clear).was_called(0) + assert.stub(runner.start).was_called(0) + assert.stub(window.open).was_called(1) + end) + + it("closes window if nothing running and window is visible", function() + window.is_visible:revert() + + stub(window, "is_visible", function() + return true + end) + + watchexec.toggle() + + assert.stub(window.clear).was_called(0) + assert.stub(runner.start).was_called(0) + assert.stub(window.close).was_called(1) + assert.stub(window.open).was_called(0) + end) + end) +end) diff --git a/tests/watchexec/health_spec.lua b/tests/watchexec/health_spec.lua index 89c8b52..28ef111 100644 --- a/tests/watchexec/health_spec.lua +++ b/tests/watchexec/health_spec.lua @@ -2,8 +2,11 @@ local health = require("watchexec.health") local stub = require("luassert.stub") describe("watchexec healthcheck", function() - local start_stub, ok_stub, error_stub - local has_stub, exec_stub + local start_stub + local ok_stub + local error_stub + local has_stub + local exec_stub before_each(function() start_stub = stub(vim.health, "start") @@ -29,6 +32,7 @@ describe("watchexec healthcheck", function() has_stub = stub(vim.fn, "has", function() return 1 end) + exec_stub = stub(vim.fn, "executable", function() return 1 end) @@ -44,6 +48,7 @@ describe("watchexec healthcheck", function() has_stub = stub(vim.fn, "has", function() return 1 end) + exec_stub = stub(vim.fn, "executable", function() return 0 end) diff --git a/tests/watchexec/watchexec_spec.lua b/tests/watchexec/watchexec_spec.lua index 3eed771..cc7154f 100644 --- a/tests/watchexec/watchexec_spec.lua +++ b/tests/watchexec/watchexec_spec.lua @@ -1,17 +1,31 @@ -local plugin = require("watchexec") - -describe("watchexec logic", function() - before_each(function() - plugin.setup({ - greeting = "Hello Test!", - }) - end) +local watchexec = require("watchexec") +describe("watchexec module", function() it("can be required without errors", function() - assert.not_nil(plugin) + assert.not_nil(watchexec) end) - it("correctly applies user configuration", function() - assert.equals("Hello Test!", plugin.config.greeting) + it("exposes setup function", function() + assert.is_function(watchexec.setup) + end) + + it("exposes run function", function() + assert.is_function(watchexec.run) + end) + + it("exposes stop function", function() + assert.is_function(watchexec.stop) + end) + + it("exposes toggle function", function() + assert.is_function(watchexec.toggle) + end) + + it("setup delegates to config", function() + local config = require("watchexec.config") + + watchexec.setup({ auto_scroll = false }) + + assert.equals(false, config.get().auto_scroll) end) end) From c2414689546b839d14de8e2837b5fb3432f77e61 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:00:14 -0500 Subject: [PATCH 4/7] docs: add vimdoc help file for watchexec.nvim Create doc/watchexec.txt covering introduction, requirements, installation, setup, configuration (all fields with defaults), commands, keymaps, highlight groups, and the public API. Tags are generated via helptags. --- doc/tags | 31 ++++++ doc/watchexec.txt | 273 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 doc/tags create mode 100644 doc/watchexec.txt diff --git a/doc/tags b/doc/tags new file mode 100644 index 0000000..180195f --- /dev/null +++ b/doc/tags @@ -0,0 +1,31 @@ +:WatchexecRun watchexec.txt /*:WatchexecRun* +:WatchexecStop watchexec.txt /*:WatchexecStop* +:WatchexecToggle watchexec.txt /*:WatchexecToggle* +hl-WatchexecFailure watchexec.txt /*hl-WatchexecFailure* +hl-WatchexecSuccess watchexec.txt /*hl-WatchexecSuccess* +watchexec-api watchexec.txt /*watchexec-api* +watchexec-commands watchexec.txt /*watchexec-commands* +watchexec-config watchexec.txt /*watchexec-config* +watchexec-contents watchexec.txt /*watchexec-contents* +watchexec-highlights watchexec.txt /*watchexec-highlights* +watchexec-installation watchexec.txt /*watchexec-installation* +watchexec-introduction watchexec.txt /*watchexec-introduction* +watchexec-keymaps watchexec.txt /*watchexec-keymaps* +watchexec-requirements watchexec.txt /*watchexec-requirements* +watchexec-setup watchexec.txt /*watchexec-setup* +watchexec-wxr watchexec.txt /*watchexec-wxr* +watchexec-wxs watchexec.txt /*watchexec-wxs* +watchexec-wxt watchexec.txt /*watchexec-wxt* +watchexec.Config watchexec.txt /*watchexec.Config* +watchexec.Config.auto_scroll watchexec.txt /*watchexec.Config.auto_scroll* +watchexec.Config.indicator watchexec.txt /*watchexec.Config.indicator* +watchexec.Config.indicator.patterns watchexec.txt /*watchexec.Config.indicator.patterns* +watchexec.Config.max_lines watchexec.txt /*watchexec.Config.max_lines* +watchexec.Config.watchexec watchexec.txt /*watchexec.Config.watchexec* +watchexec.Config.window watchexec.txt /*watchexec.Config.window* +watchexec.Config.window.float watchexec.txt /*watchexec.Config.window.float* +watchexec.nvim watchexec.txt /*watchexec.nvim* +watchexec.run watchexec.txt /*watchexec.run* +watchexec.setup watchexec.txt /*watchexec.setup* +watchexec.stop watchexec.txt /*watchexec.stop* +watchexec.toggle watchexec.txt /*watchexec.toggle* diff --git a/doc/watchexec.txt b/doc/watchexec.txt new file mode 100644 index 0000000..1d5a37f --- /dev/null +++ b/doc/watchexec.txt @@ -0,0 +1,273 @@ +*watchexec.nvim* Integrate the watchexec CLI into Neovim. + +============================================================================== +CONTENTS *watchexec-contents* + + 1. Introduction ................................ |watchexec-introduction| + 2. Requirements ................................ |watchexec-requirements| + 3. Installation ................................ |watchexec-installation| + 4. Setup ....................................... |watchexec-setup| + 5. Configuration .............................. |watchexec-config| + 6. Commands ................................... |watchexec-commands| + 7. Keymaps .................................... |watchexec-keymaps| + 8. Highlight Groups ........................... |watchexec-highlights| + 9. API ........................................ |watchexec-api| + +============================================================================== +1. INTRODUCTION *watchexec-introduction* + +watchexec.nvim integrates the watchexec CLI into Neovim, providing a floating +or split output window for displaying file-watching command output. It also +includes a status indicator that shows the last command outcome (success or +failure) when the output window is hidden, and highlights relevant keywords +(Error, Warning, Success) in the output buffer. + +============================================================================== +2. REQUIREMENTS *watchexec-requirements* + + - Neovim >= 0.10. + - watchexec CLI: https://github.com/watchexec/watchexec + Install via cargo: `cargo install watchexec` + +============================================================================== +3. INSTALLATION *watchexec-installation* + +lazy.nvim~ +>lua + { + "stevanfreeborn/watchexec.nvim", + opts = {}, + } +< + +packer.nvim~ +>lua + use { + "stevanfreeborn/watchexec.nvim", + config = function() + require("watchexec").setup({}) + end, + } +< + +vim-plug~ +>vim + Plug 'stevanfreeborn/watchexec.nvim' + lua require("watchexec").setup({}) +< + +============================================================================== +4. SETUP *watchexec-setup* + +Call `setup()` in your init.lua with an optional configuration table: + +>lua + require("watchexec").setup({ + auto_scroll = false, + max_lines = 1000, + watchexec = { + bin = "watchexec", + args = { "--shell", "bash" }, + }, + window = { + type = "split", + split = "below", + size = 15, + }, + indicator = { + enabled = true, + position = "bottom-right", + }, + }) +< + +Without calling `setup()`, the plugin uses all default values. + +============================================================================== +5. CONFIGURATION *watchexec-config* + +The `setup()` function accepts an optional table |watchexec.Config|. + + *watchexec.Config* +watchexec (table|nil) ~ + *watchexec.Config.watchexec* + Options for the watchexec binary. + + bin (string|nil) ~ + Path to the watchexec executable. Auto-detected from PATH and candidate + locations (e.g. ~/.cargo/bin/watchexec). + Default: "watchexec" + + args (string[]|nil) ~ + Extra arguments passed to the watchexec binary before the user command. + Default: {} + +window (table|nil) ~ + *watchexec.Config.window* + Options for the output window. + + type ("float"|"split"|nil) ~ + Window type: "float" for a floating window, "split" for a split. + Default: "float" + + split ("below"|"above"|"left"|"right"|nil) ~ + Split direction. Only used when `type` is "split". + Default: "below" + + size (integer|nil) ~ + Height (for horizontal splits) or width (for vertical splits) in rows or + columns. + Default: 12 + + border (string|string[]|nil) ~ + Border style for floating windows. See |nvim_open_win()|. + Default: "single" + + float (table|nil) ~ + Geometry for the floating window. + *watchexec.Config.window.float* + relative (string|nil) ~ + Positioning relative to. See |nvim_open_win()|. + Default: "editor" + + width (number|nil) ~ + Width in columns. Values <= 1 are interpreted as a fraction of the + editor width. + Default: 0.8 + + height (number|nil) ~ + Height in rows. Values <= 1 are interpreted as a fraction of the + editor height. + Default: 0.6 + + row (number|nil) ~ + Row position. Values <= 1 are interpreted as a fraction. + Default: 0.5 + + col (number|nil) ~ + Column position. Values <= 1 are interpreted as a fraction. + Default: 0.5 + +indicator (table|nil) ~ + *watchexec.Config.indicator* + Options for the status indicator, a small non-focusable float that appears + when the main window is hidden to show the last command outcome. + + enabled (boolean|nil) ~ + Enable or disable the indicator entirely. + Default: true + + position ("bottom-left"|"bottom-right"|"top-left"|"top-right"|nil) ~ + Screen corner where the indicator appears. + Default: "bottom-right" + + success_hl (string|nil) ~ + Highlight group for the success state. + Default: "WatchexecSuccess" + + failure_hl (string|nil) ~ + Highlight group for the failure state. + Default: "WatchexecFailure" + + width (integer|nil) ~ + Width of the indicator in screen cells. + Default: 2 + + height (integer|nil) ~ + Height of the indicator in screen cells. + Default: 1 + + patterns (table|nil) ~ + Lua patterns for detecting command lifecycle in output. + *watchexec.Config.indicator.patterns* + success (string|nil) ~ + Pattern that signals a successful command completion. + Default: "%[Command was successful%]" + + running (string|nil) ~ + Pattern that signals a command started running. + Default: "%[Running" + +auto_scroll (boolean|nil) ~ + *watchexec.Config.auto_scroll* + When true, scrolls the output window to the bottom on each new line. + Default: true + +max_lines (integer|nil) ~ + *watchexec.Config.max_lines* + Maximum number of lines kept in the output buffer. Oldest lines are trimmed + when exceeded. + Default: 5000 + +============================================================================== +6. COMMANDS *watchexec-commands* + +:WatchexecRun {command} ~ *:WatchexecRun* + Start watchexec with the given shell command. Stops any previously running + process, opens the output window, and begins watching. + +:WatchexecStop ~ *:WatchexecStop* + Stop the currently running watchexec process and clear the output window. + +:WatchexecToggle ~ *:WatchexecToggle* + Toggle the watchexec output window. If a process is running and the window + is hidden, shows it. If visible, hides it. If no process is running, opens + or closes the window with a placeholder message. + +============================================================================== +7. KEYMAPS *watchexec-keymaps* + +wxt ~ *watchexec-wxt* + Toggle the watchexec output window. Same as |:WatchexecToggle|. + +wxs ~ *watchexec-wxs* + Stop the currently running watchexec process. Same as |:WatchexecStop|. + +wxr ~ *watchexec-wxr* + Prompt for a shell command via |input()| and run it under watchexec. Same as + |:WatchexecRun|. + +============================================================================== +8. HIGHLIGHT GROUPS *watchexec-highlights* + +These groups are defined by the plugin and can be customized by linking or +overriding them in your colorscheme. + + *hl-WatchexecSuccess* +WatchexecSuccess ~ + Background highlight for the indicator when the last command succeeded. + Default: `guibg=#00ff00` + + *hl-WatchexecFailure* +WatchexecFailure ~ + Background highlight for the indicator when the last command failed. + Default: `guibg=#ff0000` + +The runner also applies built-in diagnostic highlights to output lines: + + |hl-DiagnosticError| ~ Matches error, fail, fatal keywords (case-insensitive) + |hl-DiagnosticWarn| ~ Matches warn keywords + |hl-DiagnosticOk| ~ Matches success, passed, ok keywords + +============================================================================== +9. API *watchexec-api* + +require("watchexec").setup({opts}) ~ *watchexec.setup* + Configure the plugin with |watchexec.Config|. Must be called before using + other functions. + +require("watchexec").run({command}) ~ *watchexec.run* + Start a watchexec process for the given shell command. Stops any previous + process and opens the output window if hidden. + +require("watchexec").stop() ~ *watchexec.stop* + Stop the currently running watchexec process, clear the output, and reset + the indicator. + +require("watchexec").toggle() ~ *watchexec.toggle* + Toggle the output window. Behaviour depends on whether a process is + running and whether the window is currently visible (see |:WatchexecToggle| + for details). + +============================================================================== + vim:tw=78:ts=8:ft=help:norl: From 8bbc045485ff08ad276cd8b512ea52b8ea63d4cd Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:03:34 -0500 Subject: [PATCH 5/7] docs: add comprehensive README Replace the placeholder README with a full reference covering features, requirements, installation (lazy/packer/vim-plug), configuration tables with defaults, commands, keymaps, highlight groups, API, and a quick start guide. --- README.md | 202 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 201 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a58274c..a9014c3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,203 @@ # watchexec.nvim -This plugin allows you to start and stop a task using watchexec and display the output of the task within Neovim. +Integrate the [watchexec](https://github.com/watchexec/watchexec) CLI into +Neovim — run file-watching commands and view their output in a floating or +split window. + +## Features + +- **Floating or split** output window, configurable per-user. +- **Status indicator** — a small non-focusable float that shows success or + failure when the main window is hidden. +- **ANSI escape sequence stripping** so output is clean. +- **Keyword highlighting** via `DiagnosticError`, `DiagnosticWarn`, and + `DiagnosticOk` for error/warning/success keywords in output. +- **Auto-scroll** to the latest output, with configurable buffer size limits. +- **Auto-resize** on `VimResized`, and automatic cleanup on `VimLeavePre`. +- **Binary auto-discovery** — searches PATH, `~/.cargo/bin`, Homebrew, and + WSL locations. + +## Requirements + +- Neovim >= 0.10 +- [watchexec CLI](https://github.com/watchexec/watchexec) + +Install the CLI: + +``` +cargo install watchexec +``` + +Or download a prebuilt binary from the [releases page](https://github.com/watchexec/watchexec/releases). + +## Installation + +**lazy.nvim** +```lua +{ + "stevanfreeborn/watchexec.nvim", + opts = {}, +} +``` + +**packer.nvim** +```lua +use { + "stevanfreeborn/watchexec.nvim", + config = function() + require("watchexec").setup({}) + end, +} +``` + +**vim-plug** +```vim +Plug 'stevanfreeborn/watchexec.nvim' +lua require("watchexec").setup({}) +``` + +## Quick Start + +After installing, restart Neovim and run: + +``` +:WatchexecRun echo hello +``` + +Or press `wxr`, type a command at the prompt, and press Enter. + +The output window opens automatically. Press `q` or `` inside the window +to close it. Press `wxt` to toggle it back. + +## Configuration + +`setup()` accepts an optional table with the following fields: + +### `watchexec` — binary options + +| Field | Type | Default | Description | +|---------|----------|------------------|-------------| +| `bin` | `string` | `"watchexec"` | Path to the watchexec executable. Auto-detected from PATH and common locations. | +| `args` | `table` | `{}` | Extra arguments passed to watchexec before the user command. | + +### `window` — output window options + +| Field | Type | Default | Description | +|----------|----------------------|--------------|-------------| +| `type` | `"float"` / `"split"` | `"float"` | Window type. | +| `split` | `"below"` / `"above"` / `"left"` / `"right"` | `"below"` | Split direction (only used when `type` is `"split"`). | +| `size` | `integer` | `12` | Split window size in rows/columns. | +| `border` | `string` / `table` | `"single"` | Border style for floats (see `:help nvim_open_win()`). | +| `float` | `table` | *(see below)* | Float geometry. | + +#### `window.float` — float geometry + +| Field | Type | Default | Description | +|------------|----------|--------------|-------------| +| `relative` | `string` | `"editor"` | Positioning anchor. | +| `width` | `number` | `0.8` | Width in columns (values <= 1 are fractions of editor width). | +| `height` | `number` | `0.6` | Height in rows (values <= 1 are fractions of editor height). | +| `row` | `number` | `0.5` | Row position (values <= 1 are fractions). | +| `col` | `number` | `0.5` | Column position (values <= 1 are fractions). | + +### `indicator` — status indicator options + +| Field | Type | Default | Description | +|--------------|-----------------------------------------------|---------------------|-------------| +| `enabled` | `boolean` | `true` | Enable/disable the indicator. | +| `position` | `"bottom-left"` / `"bottom-right"` / `"top-left"` / `"top-right"` | `"bottom-right"` | Screen corner. | +| `success_hl` | `string` | `"WatchexecSuccess"` | Highlight for success state. | +| `failure_hl` | `string` | `"WatchexecFailure"` | Highlight for failure state. | +| `width` | `integer` | `2` | Indicator width in cells. | +| `height` | `integer` | `1` | Indicator height in cells. | +| `patterns` | `table` | *(see below)* | Lua patterns for parsing output. | + +#### `indicator.patterns` + +| Field | Type | Default | Description | +|-----------|----------|-----------------------------|-------------| +| `success` | `string` | `"%[Command was successful%]"` | Pattern matching successful command output. | +| `running` | `string` | `"%[Running"` | Pattern matching command start. | + +### General options + +| Field | Type | Default | Description | +|---------------|-----------|---------|-------------| +| `auto_scroll` | `boolean` | `true` | Scroll to bottom on new output. | +| `max_lines` | `integer` | `5000` | Maximum lines in the output buffer (oldest trimmed). | + +### Full config example + +```lua +require("watchexec").setup({ + auto_scroll = false, + max_lines = 1000, + watchexec = { + bin = "watchexec", + args = { "--shell", "bash" }, + }, + window = { + type = "split", + split = "below", + size = 15, + }, + indicator = { + enabled = true, + position = "bottom-left", + }, +}) +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `:WatchexecRun {command}` | Start watchexec with the given shell command. Stops any previous run and opens the output window. | +| `:WatchexecStop` | Stop the currently running watchexec process and clear the output. | +| `:WatchexecToggle` | Toggle the output window. | + +## Keymaps + +| Keymap | Action | Description | +|----------------|--------|-------------| +| `wxt` | `:WatchexecToggle` | Toggle the output window. | +| `wxs` | `:WatchexecStop` | Stop the running process. | +| `wxr` | `:WatchexecRun` | Prompt for a command and run it. | + +## Highlight Groups + +| Group | Default | Description | +|-------|---------|-------------| +| `WatchexecSuccess` | `guibg=#00ff00` | Indicator background when the last command succeeded. | +| `WatchexecFailure` | `guibg=#ff0000` | Indicator background when the last command failed. | + +Output lines are also highlighted using built-in diagnostic groups: +- `DiagnosticError` — for error, fail, fatal keywords +- `DiagnosticWarn` — for warning keywords +- `DiagnosticOk` — for success, passed, ok keywords + +## API + +```lua +---@param opts? watchexec.Config +require("watchexec").setup(opts) + +---@param command string +require("watchexec").run(command) + +require("watchexec").stop() + +require("watchexec").toggle() +``` + +## Documentation + +Full help is available in Neovim: + +``` +:help watchexec +``` + +## License + +MIT From 0434637a12821c291e696fc46187bbab55e9aab4 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:07:44 -0500 Subject: [PATCH 6/7] docs: cleanup --- .vscode/settings.json | 10 ++++ README.md | 115 +++++++++++++++++++++--------------------- 2 files changed, 68 insertions(+), 57 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a3ce0a2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "cSpell.words": [ + "guibg", + "Keymap", + "Keymaps", + "nvim", + "stevanfreeborn", + "watchexec" + ] +} \ No newline at end of file diff --git a/README.md b/README.md index a9014c3..7afd627 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,12 @@ split window. ## Features - **Floating or split** output window, configurable per-user. -- **Status indicator** — a small non-focusable float that shows success or - failure when the main window is hidden. +- **Status indicator** — a small non-focusable float that shows success or failure when the main window is hidden. - **ANSI escape sequence stripping** so output is clean. -- **Keyword highlighting** via `DiagnosticError`, `DiagnosticWarn`, and - `DiagnosticOk` for error/warning/success keywords in output. +- **Keyword highlighting** via `DiagnosticError`, `DiagnosticWarn`, and `DiagnosticOk` for error/warning/success keywords in output. - **Auto-scroll** to the latest output, with configurable buffer size limits. - **Auto-resize** on `VimResized`, and automatic cleanup on `VimLeavePre`. -- **Binary auto-discovery** — searches PATH, `~/.cargo/bin`, Homebrew, and - WSL locations. +- **Binary auto-discovery** — searches PATH, `~/.cargo/bin`, Homebrew, and WSL locations. ## Requirements @@ -24,7 +21,7 @@ split window. Install the CLI: -``` +```pwsh cargo install watchexec ``` @@ -32,7 +29,8 @@ Or download a prebuilt binary from the [releases page](https://github.com/watche ## Installation -**lazy.nvim** +### lazy.nvim + ```lua { "stevanfreeborn/watchexec.nvim", @@ -40,7 +38,8 @@ Or download a prebuilt binary from the [releases page](https://github.com/watche } ``` -**packer.nvim** +### packer.nvim + ```lua use { "stevanfreeborn/watchexec.nvim", @@ -50,7 +49,8 @@ use { } ``` -**vim-plug** +### vim-plug + ```vim Plug 'stevanfreeborn/watchexec.nvim' lua require("watchexec").setup({}) @@ -60,7 +60,7 @@ lua require("watchexec").setup({}) After installing, restart Neovim and run: -``` +```txt :WatchexecRun echo hello ``` @@ -75,55 +75,55 @@ to close it. Press `wxt` to toggle it back. ### `watchexec` — binary options -| Field | Type | Default | Description | -|---------|----------|------------------|-------------| -| `bin` | `string` | `"watchexec"` | Path to the watchexec executable. Auto-detected from PATH and common locations. | -| `args` | `table` | `{}` | Extra arguments passed to watchexec before the user command. | +| Field | Type | Default | Description | +|--------|----------|---------------|---------------------------------------------------------------------------------| +| `bin` | `string` | `"watchexec"` | Path to the watchexec executable. Auto-detected from PATH and common locations. | +| `args` | `table` | `{}` | Extra arguments passed to watchexec before the user command. | ### `window` — output window options -| Field | Type | Default | Description | -|----------|----------------------|--------------|-------------| -| `type` | `"float"` / `"split"` | `"float"` | Window type. | -| `split` | `"below"` / `"above"` / `"left"` / `"right"` | `"below"` | Split direction (only used when `type` is `"split"`). | -| `size` | `integer` | `12` | Split window size in rows/columns. | -| `border` | `string` / `table` | `"single"` | Border style for floats (see `:help nvim_open_win()`). | -| `float` | `table` | *(see below)* | Float geometry. | +| Field | Type | Default | Description | +|----------|----------------------------------------------|---------------|--------------------------------------------------------| +| `type` | `"float"` / `"split"` | `"float"` | Window type. | +| `split` | `"below"` / `"above"` / `"left"` / `"right"` | `"below"` | Split direction (only used when `type` is `"split"`). | +| `size` | `integer` | `12` | Split window size in rows/columns. | +| `border` | `string` / `table` | `"single"` | Border style for floats (see `:help nvim_open_win()`). | +| `float` | `table` | *(see below)* | Float geometry. | #### `window.float` — float geometry -| Field | Type | Default | Description | -|------------|----------|--------------|-------------| -| `relative` | `string` | `"editor"` | Positioning anchor. | -| `width` | `number` | `0.8` | Width in columns (values <= 1 are fractions of editor width). | -| `height` | `number` | `0.6` | Height in rows (values <= 1 are fractions of editor height). | -| `row` | `number` | `0.5` | Row position (values <= 1 are fractions). | -| `col` | `number` | `0.5` | Column position (values <= 1 are fractions). | +| Field | Type | Default | Description | +|------------|----------|------------|---------------------------------------------------------------| +| `relative` | `string` | `"editor"` | Positioning anchor. | +| `width` | `number` | `0.8` | Width in columns (values <= 1 are fractions of editor width). | +| `height` | `number` | `0.6` | Height in rows (values <= 1 are fractions of editor height). | +| `row` | `number` | `0.5` | Row position (values <= 1 are fractions). | +| `col` | `number` | `0.5` | Column position (values <= 1 are fractions). | ### `indicator` — status indicator options -| Field | Type | Default | Description | -|--------------|-----------------------------------------------|---------------------|-------------| -| `enabled` | `boolean` | `true` | Enable/disable the indicator. | -| `position` | `"bottom-left"` / `"bottom-right"` / `"top-left"` / `"top-right"` | `"bottom-right"` | Screen corner. | -| `success_hl` | `string` | `"WatchexecSuccess"` | Highlight for success state. | -| `failure_hl` | `string` | `"WatchexecFailure"` | Highlight for failure state. | -| `width` | `integer` | `2` | Indicator width in cells. | -| `height` | `integer` | `1` | Indicator height in cells. | -| `patterns` | `table` | *(see below)* | Lua patterns for parsing output. | +| Field | Type | Default | Description | +|--------------|-------------------------------------------------------------------|----------------------|----------------------------------| +| `enabled` | `boolean` | `true` | Enable/disable the indicator. | +| `position` | `"bottom-left"` / `"bottom-right"` / `"top-left"` / `"top-right"` | `"bottom-right"` | Screen corner. | +| `success_hl` | `string` | `"WatchexecSuccess"` | Highlight for success state. | +| `failure_hl` | `string` | `"WatchexecFailure"` | Highlight for failure state. | +| `width` | `integer` | `2` | Indicator width in cells. | +| `height` | `integer` | `1` | Indicator height in cells. | +| `patterns` | `table` | *(see below)* | Lua patterns for parsing output. | #### `indicator.patterns` -| Field | Type | Default | Description | -|-----------|----------|-----------------------------|-------------| +| Field | Type | Default | Description | +|-----------|----------|--------------------------------|---------------------------------------------| | `success` | `string` | `"%[Command was successful%]"` | Pattern matching successful command output. | -| `running` | `string` | `"%[Running"` | Pattern matching command start. | +| `running` | `string` | `"%[Running"` | Pattern matching command start. | ### General options -| Field | Type | Default | Description | -|---------------|-----------|---------|-------------| -| `auto_scroll` | `boolean` | `true` | Scroll to bottom on new output. | +| Field | Type | Default | Description | +|---------------|-----------|---------|------------------------------------------------------| +| `auto_scroll` | `boolean` | `true` | Scroll to bottom on new output. | | `max_lines` | `integer` | `5000` | Maximum lines in the output buffer (oldest trimmed). | ### Full config example @@ -150,28 +150,29 @@ require("watchexec").setup({ ## Commands -| Command | Description | -|---------|-------------| +| Command | Description | +|---------------------------|---------------------------------------------------------------------------------------------------| | `:WatchexecRun {command}` | Start watchexec with the given shell command. Stops any previous run and opens the output window. | -| `:WatchexecStop` | Stop the currently running watchexec process and clear the output. | -| `:WatchexecToggle` | Toggle the output window. | +| `:WatchexecStop` | Stop the currently running watchexec process and clear the output. | +| `:WatchexecToggle` | Toggle the output window. | ## Keymaps -| Keymap | Action | Description | -|----------------|--------|-------------| -| `wxt` | `:WatchexecToggle` | Toggle the output window. | -| `wxs` | `:WatchexecStop` | Stop the running process. | -| `wxr` | `:WatchexecRun` | Prompt for a command and run it. | +| Keymap | Action | Description | +|---------------|--------------------|----------------------------------| +| `wxt` | `:WatchexecToggle` | Toggle the output window. | +| `wxs` | `:WatchexecStop` | Stop the running process. | +| `wxr` | `:WatchexecRun` | Prompt for a command and run it. | ## Highlight Groups -| Group | Default | Description | -|-------|---------|-------------| +| Group | Default | Description | +|--------------------|-----------------|-------------------------------------------------------| | `WatchexecSuccess` | `guibg=#00ff00` | Indicator background when the last command succeeded. | -| `WatchexecFailure` | `guibg=#ff0000` | Indicator background when the last command failed. | +| `WatchexecFailure` | `guibg=#ff0000` | Indicator background when the last command failed. | Output lines are also highlighted using built-in diagnostic groups: + - `DiagnosticError` — for error, fail, fatal keywords - `DiagnosticWarn` — for warning keywords - `DiagnosticOk` — for success, passed, ok keywords @@ -194,7 +195,7 @@ require("watchexec").toggle() Full help is available in Neovim: -``` +```txt :help watchexec ``` From e4088c23a7c0886d89465e4ce4e7f04b839e3a01 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:10:26 -0500 Subject: [PATCH 7/7] chore: run stylua --- lua/watchexec/runner.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/lua/watchexec/runner.lua b/lua/watchexec/runner.lua index 749618b..939ab1a 100644 --- a/lua/watchexec/runner.lua +++ b/lua/watchexec/runner.lua @@ -54,7 +54,6 @@ local function apply_highlights(buf, line_idx, line) { p = "%[ok%]", h = "DiagnosticOk" }, { p = "[Oo][Kk]!", h = "DiagnosticOk" }, }) do - local s, e = line:find(p.p) if s then