diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a3ce0a2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "cSpell.words": [ + "guibg", + "Keymap", + "Keymaps", + "nvim", + "stevanfreeborn", + "watchexec" + ] +} \ No newline at end of file diff --git a/README.md b/README.md index a58274c..7afd627 100644 --- a/README.md +++ b/README.md @@ -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 `wxr`, type a command at the prompt, and press Enter. + +The output window opens automatically. Press `q` or `` inside the window +to close it. Press `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 | +|---------------|--------------------|----------------------------------| +| `wxt` | `:WatchexecToggle` | Toggle the output window. | +| `wxs` | `:WatchexecStop` | Stop the running process. | +| `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 diff --git a/doc/tags b/doc/tags new file mode 100644 index 0000000..180195f --- /dev/null +++ b/doc/tags @@ -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* diff --git a/doc/watchexec.txt b/doc/watchexec.txt new file mode 100644 index 0000000..1d5a37f --- /dev/null +++ b/doc/watchexec.txt @@ -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* + +wxt ~ *watchexec-wxt* + Toggle the watchexec output window. Same as |:WatchexecToggle|. + +wxs ~ *watchexec-wxs* + Stop the currently running watchexec process. Same as |:WatchexecStop|. + +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: diff --git a/lua/watchexec/config.lua b/lua/watchexec/config.lua new file mode 100644 index 0000000..44e6d78 --- /dev/null +++ b/lua/watchexec/config.lua @@ -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 diff --git a/lua/watchexec/indicator.lua b/lua/watchexec/indicator.lua new file mode 100644 index 0000000..cadabf9 --- /dev/null +++ b/lua/watchexec/indicator.lua @@ -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 diff --git a/lua/watchexec/init.lua b/lua/watchexec/init.lua index da825df..3e8468f 100644 --- a/lua/watchexec/init.lua +++ b/lua/watchexec/init.lua @@ -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 diff --git a/lua/watchexec/runner.lua b/lua/watchexec/runner.lua new file mode 100644 index 0000000..939ab1a --- /dev/null +++ b/lua/watchexec/runner.lua @@ -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 diff --git a/lua/watchexec/window.lua b/lua/watchexec/window.lua new file mode 100644 index 0000000..7989b02 --- /dev/null +++ b/lua/watchexec/window.lua @@ -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 ( 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", "", 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 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 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 diff --git a/plugin/watchexec.lua b/plugin/watchexec.lua index c852cd0..74e625b 100644 --- a/plugin/watchexec.lua +++ b/plugin/watchexec.lua @@ -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", "wxt", function() + require("watchexec").toggle() +end, { desc = "Toggle watchexec window" }) + +vim.keymap.set("n", "wxs", function() + require("watchexec").stop() +end, { desc = "Stop watchexec" }) + +vim.keymap.set("n", "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, +}) diff --git a/tests/watchexec/commands_spec.lua b/tests/watchexec/commands_spec.lua new file mode 100644 index 0000000..598b806 --- /dev/null +++ b/tests/watchexec/commands_spec.lua @@ -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 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 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 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 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 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 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) diff --git a/tests/watchexec/config_spec.lua b/tests/watchexec/config_spec.lua new file mode 100644 index 0000000..b208d7a --- /dev/null +++ b/tests/watchexec/config_spec.lua @@ -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) diff --git a/tests/watchexec/health_spec.lua b/tests/watchexec/health_spec.lua index 89c8b52..28ef111 100644 --- a/tests/watchexec/health_spec.lua +++ b/tests/watchexec/health_spec.lua @@ -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) diff --git a/tests/watchexec/indicator_spec.lua b/tests/watchexec/indicator_spec.lua new file mode 100644 index 0000000..7019164 --- /dev/null +++ b/tests/watchexec/indicator_spec.lua @@ -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) diff --git a/tests/watchexec/runner_spec.lua b/tests/watchexec/runner_spec.lua new file mode 100644 index 0000000..b619b09 --- /dev/null +++ b/tests/watchexec/runner_spec.lua @@ -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) diff --git a/tests/watchexec/watchexec_spec.lua b/tests/watchexec/watchexec_spec.lua index 3eed771..cc7154f 100644 --- a/tests/watchexec/watchexec_spec.lua +++ b/tests/watchexec/watchexec_spec.lua @@ -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) diff --git a/tests/watchexec/window_spec.lua b/tests/watchexec/window_spec.lua new file mode 100644 index 0000000..965f86a --- /dev/null +++ b/tests/watchexec/window_spec.lua @@ -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 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 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 == "" 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 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)