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.
This commit is contained in:
Stevan Freeborn
2026-07-06 19:51:33 -05:00
parent 9dc2961180
commit 398d3dd585
4 changed files with 806 additions and 0 deletions
+193
View File
@@ -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
+232
View File
@@ -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
+163
View File
@@ -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)
+218
View File
@@ -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)