From 2b081640dfae31019049f20494d8d7459cdb883c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 20 Sep 2025 11:36:58 -0400 Subject: [PATCH] feat(color): add complex ansi color support --- lua/cp/ansi.lua | 276 ++++++++++++++++++++++++++------------------- spec/ansi_spec.lua | 192 ++++++++++++++++++++++++------- 2 files changed, 310 insertions(+), 158 deletions(-) diff --git a/lua/cp/ansi.lua b/lua/cp/ansi.lua index 0381b91..044bdff 100644 --- a/lua/cp/ansi.lua +++ b/lua/cp/ansi.lua @@ -22,7 +22,43 @@ function M.parse_ansi_text(text) local highlights = {} local line_num = 0 local col_pos = 0 - local current_style = nil + + local ansi_state = { + bold = false, + italic = false, + foreground = nil, + } + + local function get_highlight_group() + if not ansi_state.bold and not ansi_state.italic and not ansi_state.foreground then + return nil + end + + local parts = { 'CpAnsi' } + if ansi_state.bold then + table.insert(parts, 'Bold') + end + if ansi_state.italic then + table.insert(parts, 'Italic') + end + if ansi_state.foreground then + table.insert(parts, ansi_state.foreground) + end + + return table.concat(parts) + end + + local function apply_highlight(start_line, start_col, end_line, end_col) + local hl_group = get_highlight_group() + if hl_group then + table.insert(highlights, { + line = start_line, + col_start = start_col, + col_end = end_col, + highlight_group = hl_group, + }) + end + end local i = 1 while i <= #text do @@ -31,71 +67,13 @@ function M.parse_ansi_text(text) if ansi_start then if ansi_start > i then local segment = text:sub(i, ansi_start - 1) - if current_style then - local start_line = line_num - local start_col = col_pos - - for char in segment:gmatch('.') do - if char == '\n' then - if col_pos > start_col then - table.insert(highlights, { - line = start_line, - col_start = start_col, - col_end = col_pos, - highlight_group = current_style, - }) - end - line_num = line_num + 1 - start_line = line_num - col_pos = 0 - start_col = 0 - else - col_pos = col_pos + 1 - end - end - - if col_pos > start_col then - table.insert(highlights, { - line = start_line, - col_start = start_col, - col_end = col_pos, - highlight_group = current_style, - }) - end - else - for char in segment:gmatch('.') do - if char == '\n' then - line_num = line_num + 1 - col_pos = 0 - else - col_pos = col_pos + 1 - end - end - end - end - - if cmd == 'm' then - local logger = require('cp.log') - logger.log('Found color code: "' .. code .. '"') - current_style = M.ansi_code_to_highlight(code) - logger.log('Mapped to highlight: ' .. (current_style or 'nil')) - end - i = ansi_end + 1 - else - local segment = text:sub(i) - if current_style and segment ~= '' then local start_line = line_num local start_col = col_pos for char in segment:gmatch('.') do if char == '\n' then if col_pos > start_col then - table.insert(highlights, { - line = start_line, - col_start = start_col, - col_end = col_pos, - highlight_group = current_style, - }) + apply_highlight(start_line, start_col, start_line, col_pos) end line_num = line_num + 1 start_line = line_num @@ -107,12 +85,36 @@ function M.parse_ansi_text(text) end if col_pos > start_col then - table.insert(highlights, { - line = start_line, - col_start = start_col, - col_end = col_pos, - highlight_group = current_style, - }) + apply_highlight(start_line, start_col, start_line, col_pos) + end + end + + if cmd == 'm' then + M.update_ansi_state(ansi_state, code) + end + i = ansi_end + 1 + else + local segment = text:sub(i) + if segment ~= '' then + local start_line = line_num + local start_col = col_pos + + for char in segment:gmatch('.') do + if char == '\n' then + if col_pos > start_col then + apply_highlight(start_line, start_col, start_line, col_pos) + end + line_num = line_num + 1 + start_line = line_num + col_pos = 0 + start_col = 0 + else + col_pos = col_pos + 1 + end + end + + if col_pos > start_col then + apply_highlight(start_line, start_col, start_line, col_pos) end end break @@ -125,59 +127,101 @@ function M.parse_ansi_text(text) } end ----@param code string ----@return string? -function M.ansi_code_to_highlight(code) - local color_map = { - -- Simple colors - ['31'] = 'CpAnsiRed', - ['32'] = 'CpAnsiGreen', - ['33'] = 'CpAnsiYellow', - ['34'] = 'CpAnsiBlue', - ['35'] = 'CpAnsiMagenta', - ['36'] = 'CpAnsiCyan', - ['37'] = 'CpAnsiWhite', - ['91'] = 'CpAnsiBrightRed', - ['92'] = 'CpAnsiBrightGreen', - ['93'] = 'CpAnsiBrightYellow', - -- Bold colors (G++ style) - ['01;31'] = 'CpAnsiBrightRed', -- bold red - ['01;32'] = 'CpAnsiBrightGreen', -- bold green - ['01;33'] = 'CpAnsiBrightYellow', -- bold yellow - ['01;34'] = 'CpAnsiBrightBlue', -- bold blue - ['01;35'] = 'CpAnsiBrightMagenta', -- bold magenta - ['01;36'] = 'CpAnsiBrightCyan', -- bold cyan - -- Bold/formatting only - ['01'] = 'CpAnsiBold', -- bold text - ['1'] = 'CpAnsiBold', -- bold text (alternate) - -- Reset codes - ['0'] = nil, - [''] = nil, - } - return color_map[code] -end +---@param ansi_state table +---@param code_string string +function M.update_ansi_state(ansi_state, code_string) + if code_string == '' or code_string == '0' then + ansi_state.bold = false + ansi_state.italic = false + ansi_state.foreground = nil + return + end -function M.setup_highlight_groups() - local groups = { - CpAnsiRed = { fg = vim.g.terminal_color_1 }, - CpAnsiGreen = { fg = vim.g.terminal_color_2 }, - CpAnsiYellow = { fg = vim.g.terminal_color_3 }, - CpAnsiBlue = { fg = vim.g.terminal_color_4 }, - CpAnsiMagenta = { fg = vim.g.terminal_color_5 }, - CpAnsiCyan = { fg = vim.g.terminal_color_6 }, - CpAnsiWhite = { fg = vim.g.terminal_color_7 }, - CpAnsiBrightRed = { fg = vim.g.terminal_color_9 }, - CpAnsiBrightGreen = { fg = vim.g.terminal_color_10 }, - CpAnsiBrightYellow = { fg = vim.g.terminal_color_11 }, - CpAnsiBrightBlue = { fg = vim.g.terminal_color_12 }, - CpAnsiBrightMagenta = { fg = vim.g.terminal_color_13 }, - CpAnsiBrightCyan = { fg = vim.g.terminal_color_14 }, - CpAnsiBold = { bold = true }, - } + local codes = vim.split(code_string, ';', { plain = true }) - for name, opts in pairs(groups) do - vim.api.nvim_set_hl(0, name, opts) + for _, code in ipairs(codes) do + local num = tonumber(code) + if num then + if num == 1 then + ansi_state.bold = true + elseif num == 3 then + ansi_state.italic = true + elseif num == 22 then + ansi_state.bold = false + elseif num == 23 then + ansi_state.italic = false + elseif num >= 30 and num <= 37 then + local colors = { 'Black', 'Red', 'Green', 'Yellow', 'Blue', 'Magenta', 'Cyan', 'White' } + ansi_state.foreground = colors[num - 29] + elseif num >= 90 and num <= 97 then + local colors = { + 'BrightBlack', + 'BrightRed', + 'BrightGreen', + 'BrightYellow', + 'BrightBlue', + 'BrightMagenta', + 'BrightCyan', + 'BrightWhite', + } + ansi_state.foreground = colors[num - 89] + elseif num == 39 then + ansi_state.foreground = nil + end + end end end +function M.setup_highlight_groups() + local color_map = { + Black = vim.g.terminal_color_0, + Red = vim.g.terminal_color_1, + Green = vim.g.terminal_color_2, + Yellow = vim.g.terminal_color_3, + Blue = vim.g.terminal_color_4, + Magenta = vim.g.terminal_color_5, + Cyan = vim.g.terminal_color_6, + White = vim.g.terminal_color_7, + BrightBlack = vim.g.terminal_color_8, + BrightRed = vim.g.terminal_color_9, + BrightGreen = vim.g.terminal_color_10, + BrightYellow = vim.g.terminal_color_11, + BrightBlue = vim.g.terminal_color_12, + BrightMagenta = vim.g.terminal_color_13, + BrightCyan = vim.g.terminal_color_14, + BrightWhite = vim.g.terminal_color_15, + } + + local combinations = { + { bold = false, italic = false }, + { bold = true, italic = false }, + { bold = false, italic = true }, + { bold = true, italic = true }, + } + + for _, combo in ipairs(combinations) do + for color_name, terminal_color in pairs(color_map) do + local parts = { 'CpAnsi' } + local opts = { fg = terminal_color } + + if combo.bold then + table.insert(parts, 'Bold') + opts.bold = true + end + if combo.italic then + table.insert(parts, 'Italic') + opts.italic = true + end + table.insert(parts, color_name) + + local hl_name = table.concat(parts) + vim.api.nvim_set_hl(0, hl_name, opts) + end + end + + vim.api.nvim_set_hl(0, 'CpAnsiBold', { bold = true }) + vim.api.nvim_set_hl(0, 'CpAnsiItalic', { italic = true }) + vim.api.nvim_set_hl(0, 'CpAnsiBoldItalic', { bold = true, italic = true }) +end + return M diff --git a/spec/ansi_spec.lua b/spec/ansi_spec.lua index 09c7304..f80d8b4 100644 --- a/spec/ansi_spec.lua +++ b/spec/ansi_spec.lua @@ -29,7 +29,7 @@ describe('ansi parser', function() assert.equals(0, #result.highlights) end) - it('creates correct highlight for colored text', function() + it('creates correct highlight for simple colored text', function() local input = 'Hello \027[31mworld\027[0m!' local result = ansi.parse_ansi_text(input) @@ -41,67 +41,175 @@ describe('ansi parser', function() assert.equals('CpAnsiRed', highlight.highlight_group) end) - it('handles multiple colors on same line', function() - local input = '\027[31mred\027[0m and \027[32mgreen\027[0m' + it('handles bold text', function() + local input = 'Hello \027[1mbold\027[0m world' local result = ansi.parse_ansi_text(input) - assert.equals('red and green', table.concat(result.lines, '\n')) - assert.equals(2, #result.highlights) - - local red_highlight = result.highlights[1] - assert.equals(0, red_highlight.col_start) - assert.equals(3, red_highlight.col_end) - assert.equals('CpAnsiRed', red_highlight.highlight_group) - - local green_highlight = result.highlights[2] - assert.equals(8, green_highlight.col_start) - assert.equals(13, green_highlight.col_end) - assert.equals('CpAnsiGreen', green_highlight.highlight_group) + assert.equals('Hello bold world', table.concat(result.lines, '\n')) + assert.equals(1, #result.highlights) + local highlight = result.highlights[1] + assert.equals('CpAnsiBold', highlight.highlight_group) end) - it('handles multiline colored text', function() - local input = '\027[31mline1\nline2\027[0m' + it('handles italic text', function() + local input = 'Hello \027[3mitalic\027[0m world' local result = ansi.parse_ansi_text(input) - assert.equals('line1\nline2', table.concat(result.lines, '\n')) - assert.equals(2, #result.highlights) - - local line1_highlight = result.highlights[1] - assert.equals(0, line1_highlight.line) - assert.equals(0, line1_highlight.col_start) - assert.equals(5, line1_highlight.col_end) - - local line2_highlight = result.highlights[2] - assert.equals(1, line2_highlight.line) - assert.equals(0, line2_highlight.col_start) - assert.equals(5, line2_highlight.col_end) + assert.equals('Hello italic world', table.concat(result.lines, '\n')) + assert.equals(1, #result.highlights) + local highlight = result.highlights[1] + assert.equals('CpAnsiItalic', highlight.highlight_group) end) - it('handles compiler-like output', function() + it('handles bold + color combination', function() + local input = 'Hello \027[1;31mbold red\027[0m world' + local result = ansi.parse_ansi_text(input) + + assert.equals('Hello bold red world', table.concat(result.lines, '\n')) + assert.equals(1, #result.highlights) + local highlight = result.highlights[1] + assert.equals('CpAnsiBoldRed', highlight.highlight_group) + assert.equals(6, highlight.col_start) + assert.equals(14, highlight.col_end) + end) + + it('handles italic + color combination', function() + local input = 'Hello \027[3;32mitalic green\027[0m world' + local result = ansi.parse_ansi_text(input) + + assert.equals('Hello italic green world', table.concat(result.lines, '\n')) + assert.equals(1, #result.highlights) + local highlight = result.highlights[1] + assert.equals('CpAnsiItalicGreen', highlight.highlight_group) + end) + + it('handles bold + italic + color combination', function() + local input = 'Hello \027[1;3;33mbold italic yellow\027[0m world' + local result = ansi.parse_ansi_text(input) + + assert.equals('Hello bold italic yellow world', table.concat(result.lines, '\n')) + assert.equals(1, #result.highlights) + local highlight = result.highlights[1] + assert.equals('CpAnsiBoldItalicYellow', highlight.highlight_group) + end) + + it('handles sequential attribute setting', function() + local input = 'Hello \027[1m\027[31mbold red\027[0m world' + local result = ansi.parse_ansi_text(input) + + assert.equals('Hello bold red world', table.concat(result.lines, '\n')) + assert.equals(1, #result.highlights) + local highlight = result.highlights[1] + assert.equals('CpAnsiBoldRed', highlight.highlight_group) + end) + + it('handles selective attribute reset', function() + local input = 'Hello \027[1;31mbold red\027[22mno longer bold\027[0m world' + local result = ansi.parse_ansi_text(input) + + assert.equals('Hello bold redno longer bold world', table.concat(result.lines, '\n')) + assert.equals(2, #result.highlights) + + local bold_red = result.highlights[1] + assert.equals('CpAnsiBoldRed', bold_red.highlight_group) + assert.equals(6, bold_red.col_start) + assert.equals(14, bold_red.col_end) + + local just_red = result.highlights[2] + assert.equals('CpAnsiRed', just_red.highlight_group) + assert.equals(14, just_red.col_start) + assert.equals(28, just_red.col_end) + end) + + it('handles bright colors', function() + local input = 'Hello \027[91mbright red\027[0m world' + local result = ansi.parse_ansi_text(input) + + assert.equals(1, #result.highlights) + local highlight = result.highlights[1] + assert.equals('CpAnsiBrightRed', highlight.highlight_group) + end) + + it('handles compiler-like output with complex formatting', function() local input = "error.cpp:10:5: \027[1m\027[31merror:\027[0m\027[1m 'undefined' was not declared\027[0m" local result = ansi.parse_ansi_text(input) local clean_text = table.concat(result.lines, '\n') - assert.is_true(clean_text:find("error.cpp:10:5: error: 'undefined' was not declared") ~= nil) - assert.is_false(clean_text:find('\027') ~= nil) + assert.equals("error.cpp:10:5: error: 'undefined' was not declared", clean_text) + assert.equals(2, #result.highlights) + + local error_highlight = result.highlights[1] + assert.equals('CpAnsiBoldRed', error_highlight.highlight_group) + assert.equals(16, error_highlight.col_start) + assert.equals(22, error_highlight.col_end) + + local message_highlight = result.highlights[2] + assert.equals('CpAnsiBold', message_highlight.highlight_group) + assert.equals(22, message_highlight.col_start) + assert.equals(48, message_highlight.col_end) + end) + + it('handles multiline with persistent state', function() + local input = '\027[1;31mline1\nline2\nline3\027[0m' + local result = ansi.parse_ansi_text(input) + + assert.equals('line1\nline2\nline3', table.concat(result.lines, '\n')) + assert.equals(3, #result.highlights) + + for i, highlight in ipairs(result.highlights) do + assert.equals('CpAnsiBoldRed', highlight.highlight_group) + assert.equals(i - 1, highlight.line) + assert.equals(0, highlight.col_start) + assert.equals(5, highlight.col_end) + end end) end) - describe('ansi_code_to_highlight', function() - it('maps standard colors', function() - assert.equals('CpAnsiRed', ansi.ansi_code_to_highlight('31')) - assert.equals('CpAnsiGreen', ansi.ansi_code_to_highlight('32')) - assert.equals('CpAnsiYellow', ansi.ansi_code_to_highlight('33')) + describe('update_ansi_state', function() + it('resets all state on reset code', function() + local state = { bold = true, italic = true, foreground = 'Red' } + ansi.update_ansi_state(state, '0') + + assert.is_false(state.bold) + assert.is_false(state.italic) + assert.is_nil(state.foreground) end) - it('handles reset codes', function() - assert.is_nil(ansi.ansi_code_to_highlight('0')) - assert.is_nil(ansi.ansi_code_to_highlight('')) + it('sets individual attributes', function() + local state = { bold = false, italic = false, foreground = nil } + + ansi.update_ansi_state(state, '1') + assert.is_true(state.bold) + + ansi.update_ansi_state(state, '3') + assert.is_true(state.italic) + + ansi.update_ansi_state(state, '31') + assert.equals('Red', state.foreground) end) - it('handles unknown codes', function() - assert.is_nil(ansi.ansi_code_to_highlight('99')) + it('handles compound codes', function() + local state = { bold = false, italic = false, foreground = nil } + ansi.update_ansi_state(state, '1;3;31') + + assert.is_true(state.bold) + assert.is_true(state.italic) + assert.equals('Red', state.foreground) + end) + + it('handles selective resets', function() + local state = { bold = true, italic = true, foreground = 'Red' } + + ansi.update_ansi_state(state, '22') + assert.is_false(state.bold) + assert.is_true(state.italic) + assert.equals('Red', state.foreground) + + ansi.update_ansi_state(state, '39') + assert.is_false(state.bold) + assert.is_true(state.italic) + assert.is_nil(state.foreground) end) end) end)