Merge pull request 'fix: address carriage return line endings in output window' (#3) from stevanfreeborn/fix/remove-carriage-return-characters into main

This commit is contained in:
Stevan Freeborn
2026-07-18 23:26:58 +00:00
4 changed files with 152 additions and 4 deletions
+48 -3
View File
@@ -35,6 +35,44 @@ local function strip_ansi(text)
return result return result
end 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. ---Apply diagnostic highlights to keywords found in a line.
---Matches error, warning, success patterns using case-insensitive patterns. ---Matches error, warning, success patterns using case-insensitive patterns.
---@param buf integer ---@param buf integer
@@ -69,9 +107,13 @@ local function process_data(text)
return return
end 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").process_output(clean)
require("watchexec.indicator").refresh() require("watchexec.indicator").refresh()
@@ -159,7 +201,7 @@ function M.start(command)
on_stderr = function(_, data, _) on_stderr = function(_, data, _)
vim.schedule(function() vim.schedule(function()
local text = table.concat(data, "\n") local text = table.concat(data, "\n")
local clean = strip_ansi(text) local clean, _ = normalize_output(text)
if clean ~= "" then if clean ~= "" then
process_data(clean) process_data(clean)
@@ -228,4 +270,7 @@ function M.get_cmd()
return state.cmd return state.cmd
end end
M._normalize_output = normalize_output
M._strip_ansi = strip_ansi
return M return M
+7 -1
View File
@@ -25,6 +25,7 @@ local state = {
---@return integer buf ---@return integer buf
function M.create_buf() function M.create_buf()
local existing = state.buf local existing = state.buf
if existing and vim.api.nvim_buf_is_valid(existing) then if existing and vim.api.nvim_buf_is_valid(existing) then
return existing return existing
end end
@@ -155,10 +156,13 @@ end
---Append text to the output buffer. ---Append text to the output buffer.
---On first append, replaces the "waiting for output" placeholder. ---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. ---Truncates the buffer when max_lines is exceeded.
---Auto-scrolls to the bottom when enabled. ---Auto-scrolls to the bottom when enabled.
---@param text string ---@param text string
function M.append(text) ---@param overwrite boolean|nil
function M.append(text, overwrite)
local buf = state.buf local buf = state.buf
if not buf or not vim.api.nvim_buf_is_valid(buf) then 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 if first_line:match("^ No job running") then
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) 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 else
vim.api.nvim_buf_set_lines(buf, current, -1, false, lines) vim.api.nvim_buf_set_lines(buf, current, -1, false, lines)
end end
+51
View File
@@ -186,6 +186,57 @@ describe("watchexec runner", function()
end) end)
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() describe("on_exit guard", function()
it("ignores stale on_exit from replaced job", function() it("ignores stale on_exit from replaced job", function()
local call_count = 0 local call_count = 0
+46
View File
@@ -254,6 +254,48 @@ describe("watchexec window", function()
assert.equals(line_count, cursor[1]) assert.equals(line_count, cursor[1])
end) 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) end)
describe("keymaps", function() describe("keymaps", function()
@@ -262,12 +304,14 @@ describe("watchexec window", function()
local buf = window.get_buf() local buf = window.get_buf()
local maps = vim.api.nvim_buf_get_keymap(buf, "n") local maps = vim.api.nvim_buf_get_keymap(buf, "n")
local found = false local found = false
for _, m in ipairs(maps) do for _, m in ipairs(maps) do
if m.lhs == "<Esc>" then if m.lhs == "<Esc>" then
found = true found = true
break break
end end
end end
assert.is_true(found) assert.is_true(found)
end) end)
@@ -276,12 +320,14 @@ describe("watchexec window", function()
local buf = window.get_buf() local buf = window.get_buf()
local maps = vim.api.nvim_buf_get_keymap(buf, "n") local maps = vim.api.nvim_buf_get_keymap(buf, "n")
local found = false local found = false
for _, m in ipairs(maps) do for _, m in ipairs(maps) do
if m.lhs == "q" then if m.lhs == "q" then
found = true found = true
break break
end end
end end
assert.is_true(found) assert.is_true(found)
end) end)