Merge pull request 'feat: implement commands' (#2) from stevanfreeborn/feat/implement-commands-and-ui into main

This commit is contained in:
Stevan Freeborn
2026-07-07 01:11:48 +00:00
17 changed files with 2576 additions and 23 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"cSpell.words": [
"guibg",
"Keymap",
"Keymaps",
"nvim",
"stevanfreeborn",
"watchexec"
]
}
+202 -1
View File
@@ -1,3 +1,204 @@
# 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:
```pwsh
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:
```txt
:WatchexecRun echo hello
```
Or press `<Leader>wxr`, type a command at the prompt, and press Enter.
The output window opens automatically. Press `q` or `<Esc>` inside the window
to close it. Press `<Leader>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 |
|---------------|--------------------|----------------------------------|
| `<Leader>wxt` | `:WatchexecToggle` | Toggle the output window. |
| `<Leader>wxs` | `:WatchexecStop` | Stop the running process. |
| `<Leader>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:
```txt
:help watchexec
```
## License
MIT
+31
View File
@@ -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*
+273
View File
@@ -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*
<Leader>wxt ~ *watchexec-wxt*
Toggle the watchexec output window. Same as |:WatchexecToggle|.
<Leader>wxs ~ *watchexec-wxs*
Stop the currently running watchexec process. Same as |:WatchexecStop|.
<Leader>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:
+161
View File
@@ -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
+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
+59 -7
View File
@@ -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
+231
View File
@@ -0,0 +1,231 @@
---@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
+245
View File
@@ -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 (<Esc> 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", "<Esc>", 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 <command> 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 <command> 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
+57 -2
View File
@@ -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", "<Leader>wxt", function()
require("watchexec").toggle()
end, { desc = "Toggle watchexec window" })
vim.keymap.set("n", "<Leader>wxs", function()
require("watchexec").stop()
end, { desc = "Stop watchexec" })
vim.keymap.set("n", "<Leader>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,
})
+269
View File
@@ -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 <Leader>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 <Leader>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 <Leader>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 <Leader>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 <Leader>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 <Leader>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)
+126
View File
@@ -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)
+7 -2
View File
@@ -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)
+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)
+25 -11
View File
@@ -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)
+306
View File
@@ -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 <command> 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 <Esc> 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 == "<Esc>" 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 <Esc> 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)