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)