Files
2026-07-18 18:22:48 -05:00

277 lines
6.9 KiB
Lua

---@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
---Normalize output text: strip ANSI, handle \\r characters (CRLF -> LF,
---inline progress overwrites), and detect leading \\r for line overwrite.
---@param text string
---@return string, boolean
local function normalize_output(text)
if not text or text == "" then
return "", false
end
text = text:gsub("\r\n", "\n")
text =
text:gsub("\x1b%[%??[0-9;]*[a-zA-Z]", ""):gsub("\x1b%][0-9;]*.-(\x1b\\|\x07)", ""):gsub("\x1b[()][0-9A-Za-z]", "")
local start_cr = text:len() > 0 and text:byte(1) == 0x0D
local lines = vim.split(text, "\n", { plain = true })
local out_lines = {}
for i = 1, #lines do
local current = lines[i]
local found = false
for p = #current, 1, -1 do
if current:byte(p) == 0x0D then
current = current:sub(p + 1)
found = true
break
end
end
if not found or current ~= "" then
table.insert(out_lines, current)
end
end
return table.concat(out_lines, "\n"), start_cr
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, overwrite = normalize_output(text)
if clean == "" then
return
end
require("watchexec.window").append(clean, overwrite)
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, _ = normalize_output(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
M._normalize_output = normalize_output
M._strip_ansi = strip_ansi
return M