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:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user