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)