diff --git a/lua/opencode/ui/output.lua b/lua/opencode/ui/output.lua index a1c31048..8f6764e3 100644 --- a/lua/opencode/ui/output.lua +++ b/lua/opencode/ui/output.lua @@ -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 @@ -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 diff --git a/tests/unit/output_spec.lua b/tests/unit/output_spec.lua index 2844333e..de34cdde 100644 --- a/tests/unit/output_spec.lua +++ b/tests/unit/output_spec.lua @@ -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)