diff --git a/lua/watchexec/runner.lua b/lua/watchexec/runner.lua index 939ab1a..cda2b77 100644 --- a/lua/watchexec/runner.lua +++ b/lua/watchexec/runner.lua @@ -35,6 +35,44 @@ local function strip_ansi(text) return result end +---Normalize output text: strip ANSI, handle \\r characters (CRLF -> LF, +---inline progress overwrites), and detect leading \\r for line overwrite. +---@param text string +---@return string, boolean +local function normalize_output(text) + if not text or text == "" then + return "", false + end + + text = text:gsub("\r\n", "\n") + text = + text:gsub("\x1b%[%??[0-9;]*[a-zA-Z]", ""):gsub("\x1b%][0-9;]*.-(\x1b\\|\x07)", ""):gsub("\x1b[()][0-9A-Za-z]", "") + + local start_cr = text:len() > 0 and text:byte(1) == 0x0D + + local lines = vim.split(text, "\n", { plain = true }) + local out_lines = {} + + for i = 1, #lines do + local current = lines[i] + local found = false + + for p = #current, 1, -1 do + if current:byte(p) == 0x0D then + current = current:sub(p + 1) + found = true + break + end + end + + if not found or current ~= "" then + table.insert(out_lines, current) + end + end + + return table.concat(out_lines, "\n"), start_cr +end + ---Apply diagnostic highlights to keywords found in a line. ---Matches error, warning, success patterns using case-insensitive patterns. ---@param buf integer @@ -69,9 +107,13 @@ local function process_data(text) return end - local clean = strip_ansi(text) + local clean, overwrite = normalize_output(text) - require("watchexec.window").append(clean) + if clean == "" then + return + end + + require("watchexec.window").append(clean, overwrite) require("watchexec.indicator").process_output(clean) require("watchexec.indicator").refresh() @@ -159,7 +201,7 @@ function M.start(command) on_stderr = function(_, data, _) vim.schedule(function() local text = table.concat(data, "\n") - local clean = strip_ansi(text) + local clean, _ = normalize_output(text) if clean ~= "" then process_data(clean) @@ -228,4 +270,7 @@ function M.get_cmd() return state.cmd end +M._normalize_output = normalize_output +M._strip_ansi = strip_ansi + return M diff --git a/lua/watchexec/window.lua b/lua/watchexec/window.lua index 7989b02..4c7f201 100644 --- a/lua/watchexec/window.lua +++ b/lua/watchexec/window.lua @@ -25,6 +25,7 @@ local state = { ---@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 @@ -155,10 +156,13 @@ end ---Append text to the output buffer. ---On first append, replaces the "waiting for output" placeholder. +---When overwrite is true and the buffer has content, replaces the last line +---instead of appending (handles \\r-based progress overwrites). ---Truncates the buffer when max_lines is exceeded. ---Auto-scrolls to the bottom when enabled. ---@param text string -function M.append(text) +---@param overwrite boolean|nil +function M.append(text, overwrite) local buf = state.buf if not buf or not vim.api.nvim_buf_is_valid(buf) then @@ -174,6 +178,8 @@ function M.append(text) if first_line:match("^ No job running") then vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + elseif overwrite and current > 0 then + vim.api.nvim_buf_set_lines(buf, current - 1, -1, false, lines) else vim.api.nvim_buf_set_lines(buf, current, -1, false, lines) end diff --git a/tests/watchexec/runner_spec.lua b/tests/watchexec/runner_spec.lua index b619b09..f1289ae 100644 --- a/tests/watchexec/runner_spec.lua +++ b/tests/watchexec/runner_spec.lua @@ -186,6 +186,57 @@ describe("watchexec runner", function() end) end) + describe("normalize_output", function() + it("removes ANSI sequences", function() + local result = runner._strip_ansi("\x1b[31mred\x1b[0m") + + assert.equals("red", result) + end) + + it("converts \\r\\n to \\n", function() + local result, _ = runner._normalize_output("line1\r\nline2\r\nline3") + + assert.equals("line1\nline2\nline3", result) + end) + + it("resolves inline overwrites (\\r splits)", function() + local result, _ = runner._normalize_output("a\rb\rc") + + assert.equals("c", result) + end) + + it("resolves mixed inline and line (\\r in last line)", function() + local result, _ = runner._normalize_output("a\rb\nc\rd") + + assert.equals("b\nd", result) + end) + + it("detects leading overwrite", function() + local _, overwrite = runner._normalize_output("\rxyz") + + assert.is_true(overwrite) + end) + + it("does not detect overwrite on normal text", function() + local _, overwrite = runner._normalize_output("xyz") + + assert.is_false(overwrite) + end) + + it("does not detect overwrite on CRLF that starts with \\r", function() + local _, overwrite = runner._normalize_output("\r\nxyz") + + assert.is_false(overwrite) + end) + + it("handles empty text", function() + local result, overwrite = runner._normalize_output("") + + assert.equals("", result) + assert.is_false(overwrite) + end) + end) + describe("on_exit guard", function() it("ignores stale on_exit from replaced job", function() local call_count = 0 diff --git a/tests/watchexec/window_spec.lua b/tests/watchexec/window_spec.lua index 965f86a..a8538b8 100644 --- a/tests/watchexec/window_spec.lua +++ b/tests/watchexec/window_spec.lua @@ -254,6 +254,48 @@ describe("watchexec window", function() assert.equals(line_count, cursor[1]) end) + + it("overwrite replaces the last line", function() + window.open() + local buf = window.get_buf() + + window.append("first") + window.append("second") + window.append("replacement", true) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + + assert.equals(2, #lines) + assert.equals("first", lines[1]) + assert.equals("replacement", lines[2]) + end) + + it("overwrite with multiple lines replaces last line and appends", function() + window.open() + local buf = window.get_buf() + + window.append("first") + window.append("second") + window.append("replacement1\nreplacement2", true) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + + assert.equals(3, #lines) + assert.equals("first", lines[1]) + assert.equals("replacement1", lines[2]) + assert.equals("replacement2", lines[3]) + end) + + it("overwrite on empty buffer appends normally", function() + window.open() + local buf = window.get_buf() + + window.append("first", true) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + + assert.equals("first", lines[1]) + end) end) describe("keymaps", function() @@ -262,12 +304,14 @@ describe("watchexec window", function() 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) @@ -276,12 +320,14 @@ describe("watchexec window", function() 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)