Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions lua/opencode/ui/output.lua
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,22 @@ function Output.new()
end

---Add a new line
---@param line string
---@return number index The index of the added line
---@param line string|nil
---@return number index of the first line added (0 if line was nil)
function Output:add_line(line)
table.insert(self.lines, line)
return #self.lines
if line == nil then
table.insert(self.lines, '')
return #self.lines
end
local segments = vim.split(line, '\r?\n')
local first_idx = 0
for _, segment in ipairs(segments) do
table.insert(self.lines, segment)
if first_idx == 0 then
first_idx = #self.lines
end
end
return first_idx
end

---Get line by index
Expand All @@ -63,11 +74,14 @@ end
---@param prefix? string Optional prefix for each line
function Output:add_lines(lines, prefix)
for _, line in ipairs(lines) do
if line == '' then
if line == nil or line == '' then
table.insert(self.lines, '')
else
prefix = prefix or ''
table.insert(self.lines, prefix .. line)
local segments = vim.split(line, '\r?\n')
for _, segment in ipairs(segments) do
table.insert(self.lines, prefix .. segment)
end
end
end
end
Expand Down
78 changes: 78 additions & 0 deletions tests/unit/output_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,82 @@ describe('Output targets', function()
assert.equals(0, #output.actions)
assert.equals(0, #output.targets)
end)

describe('splitting against embedded newlines', function()
it('splits a multi-line add_line into multiple lines', function()
local output = Output.new()
output:add_line('yes')
output:add_line('no\nway')
output:add_line('windows\r\nline')
output:add_line('a\n\nb')
assert.are.same({
'yes',
'no',
'way',
'windows',
'line',
'a',
'',
'b',
}, output:get_lines())
end)

it('preserves a lone \\r in the line (rare; not split)', function()
-- Lua patterns lack regex alternation, so vim.split cannot
-- match \r\n, \r, and \n in one shot. We split on \r?\n which
-- covers \n and \r\n (the conventions any modern system
-- produces); a bare \r (old Mac line ending) is kept verbatim.
-- It's harmless: nvim_buf_set_lines only rejects \n.
local output = Output.new()
output:add_line('also\rcr')
assert.are.same({ 'also\rcr' }, output:get_lines())
end)

it('returns the first-line index so extmarks land on the heading line', function()
local output = Output.new()
local first = output:add_line('heading\nbody1\nbody2')
assert.are.equal(1, first)
assert.are.equal(3, #output.lines)
-- Caller can add the extmark on the heading line.
output:add_extmark(first - 1, { line_hl_group = 'X' })
assert.are.equal('X', output.extmarks[0][1].line_hl_group)
assert.is_nil(output.extmarks[1])
assert.is_nil(output.extmarks[2])
end)

it('splits each entry when going through add_lines', function()
local output = Output.new()
output:add_lines({ 'plain', 'embedded\nnewline', 'mixed\r\nends' })
assert.are.same({ 'plain', 'embedded', 'newline', 'mixed', 'ends' }, output:get_lines())
end)

it('splits entries inside add_lines when a prefix is provided', function()
local output = Output.new()
output:add_lines({ 'one', 'two\nthree' }, '> ')
assert.are.same({ '> one', '> two', '> three' }, output:get_lines())
end)

it('treats nil and empty strings as a single blank line', function()
local output = Output.new()
output:add_line(nil)
output:add_line('x')
output:add_line(nil)
output:add_line('')
assert.are.same({ '', 'x', '', '' }, output:get_lines())
end)

it('never produces a line containing newlines', function()
-- The crash we're guarding against is nvim_buf_set_lines rejecting
-- items with embedded newlines. Lone \r is preserved by design
-- (see the dedicated test above) and is harmless to the buffer.
local output = Output.new()
output:add_line('server-supplied')
output:add_line('with\nembedded\nnewlines')
output:add_lines({ 'still\nbad', 'cr\rhere', 'mixed\r\nends' })

for i, line in ipairs(output:get_lines()) do
assert.is_nil(line:find('\n'), 'line ' .. i .. ' contains \\n: ' .. vim.inspect(line))
end
end)
end)
end)
Loading