From 1c46c3fcd935e9ae2feb2b512290d6afd14ab1d1 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:51:37 -0500 Subject: [PATCH] feat: implement public API and plugin commands Rewrite init.lua to expose setup(), run(), stop(), and toggle() that coordinate across config, runner, window, and indicator modules. Replace the placeholder SayHello command in plugin/watchexec.lua with keymaps (wx{t,s,r}), :WatchexecRun/:WatchexecStop/:WatchexecToggle commands, VimLeavePre cleanup, and VimResized auto-resize. --- lua/watchexec/init.lua | 66 ++++++- plugin/watchexec.lua | 59 ++++++- tests/watchexec/commands_spec.lua | 269 +++++++++++++++++++++++++++++ tests/watchexec/health_spec.lua | 9 +- tests/watchexec/watchexec_spec.lua | 36 ++-- 5 files changed, 417 insertions(+), 22 deletions(-) create mode 100644 tests/watchexec/commands_spec.lua 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/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/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/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)