Merge pull request 'feat: implement commands' (#2) from stevanfreeborn/feat/implement-commands-and-ui into main
This commit is contained in:
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"guibg",
|
||||
"Keymap",
|
||||
"Keymaps",
|
||||
"nvim",
|
||||
"stevanfreeborn",
|
||||
"watchexec"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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*
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user