feat(color): add complex ansi color support

This commit is contained in:
Barrett Ruth 2025-09-20 11:36:58 -04:00
parent 21b7765105
commit 2b081640df
2 changed files with 310 additions and 158 deletions

View file

@ -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

View file

@ -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)