diff --git a/lua/cp/ansi.lua b/lua/cp/ansi.lua new file mode 100644 index 0000000..0381b91 --- /dev/null +++ b/lua/cp/ansi.lua @@ -0,0 +1,183 @@ +---@class AnsiParseResult +---@field lines string[] +---@field highlights table[] + +local M = {} + +---@param raw_output string|table +---@return string +function M.bytes_to_string(raw_output) + if type(raw_output) == 'string' then + return raw_output + end + return table.concat(vim.tbl_map(string.char, raw_output)) +end + +---@param text string +---@return AnsiParseResult +function M.parse_ansi_text(text) + local clean_text = text:gsub('\027%[[%d;]*[a-zA-Z]', '') + local lines = vim.split(clean_text, '\n', { plain = true, trimempty = false }) + + local highlights = {} + local line_num = 0 + local col_pos = 0 + local current_style = nil + + local i = 1 + while i <= #text do + local ansi_start, ansi_end, code, cmd = text:find('\027%[([%d;]*)([a-zA-Z])', i) + + 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, + }) + 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 + end + break + end + end + + return { + lines = lines, + highlights = highlights, + } +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 + +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 }, + } + + for name, opts in pairs(groups) do + vim.api.nvim_set_hl(0, name, opts) + end +end + +return M diff --git a/lua/cp/execute.lua b/lua/cp/execute.lua index 945011f..a098cfe 100644 --- a/lua/cp/execute.lua +++ b/lua/cp/execute.lua @@ -86,9 +86,13 @@ function M.compile_generic(language_config, substitutions) logger.log(('compiling: %s'):format(table.concat(compile_cmd, ' '))) local start_time = vim.uv.hrtime() - local result = vim.system(compile_cmd, { text = true }):wait() + local result = vim.system(compile_cmd, { text = false }):wait() local compile_time = (vim.uv.hrtime() - start_time) / 1000000 + local ansi = require('cp.ansi') + result.stdout = ansi.bytes_to_string(result.stdout or '') + result.stderr = ansi.bytes_to_string(result.stderr or '') + if result.code == 0 then logger.log(('compilation successful (%.1fms)'):format(compile_time)) else diff --git a/lua/cp/init.lua b/lua/cp/init.lua index aed8234..a9634ac 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -251,8 +251,25 @@ local function toggle_run_panel(is_debug) vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr }) vim.api.nvim_set_option_value('readonly', was_readonly, { buf = bufnr }) + local ansi = require('cp.ansi') + ansi.setup_highlight_groups() + vim.api.nvim_buf_clear_namespace(bufnr, test_list_namespace, 0, -1) for _, hl in ipairs(highlights) do + local logger = require('cp.log') + logger.log( + 'Applying extmark: buf=' + .. bufnr + .. ' line=' + .. hl.line + .. ' col=' + .. hl.col_start + .. '-' + .. hl.col_end + .. ' group=' + .. (hl.highlight_group or 'nil') + ) + vim.api.nvim_buf_set_extmark(bufnr, test_list_namespace, hl.line, hl.col_start, { end_col = hl.col_end, hl_group = hl.highlight_group, @@ -382,6 +399,7 @@ local function toggle_run_panel(is_debug) local expected_content = current_test.expected or '' local actual_content = current_test.actual or '(not run yet)' + local actual_highlights = current_test.actual_highlights or {} local is_compilation_failure = current_test.error and current_test.error:match('Compilation failed') local should_show_diff = current_test.status == 'fail' @@ -424,7 +442,7 @@ local function toggle_run_panel(is_debug) else if desired_mode == 'single' then local lines = vim.split(actual_content, '\n', { plain = true, trimempty = true }) - update_buffer_content(current_diff_layout.buffers[1], lines, {}) + update_buffer_content(current_diff_layout.buffers[1], lines, actual_highlights) elseif desired_mode == 'git' then local diff_backend = require('cp.diff') local backend = diff_backend.get_best_backend('git') @@ -438,13 +456,13 @@ local function toggle_run_panel(is_debug) ) else local lines = vim.split(actual_content, '\n', { plain = true, trimempty = true }) - update_buffer_content(current_diff_layout.buffers[1], lines, {}) + update_buffer_content(current_diff_layout.buffers[1], lines, actual_highlights) end else local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true }) local actual_lines = vim.split(actual_content, '\n', { plain = true, trimempty = true }) update_buffer_content(current_diff_layout.buffers[1], expected_lines, {}) - update_buffer_content(current_diff_layout.buffers[2], actual_lines, {}) + update_buffer_content(current_diff_layout.buffers[2], actual_lines, actual_highlights) if should_show_diff then vim.api.nvim_set_option_value('diff', true, { win = current_diff_layout.windows[1] }) @@ -472,6 +490,9 @@ local function toggle_run_panel(is_debug) local test_render = require('cp.test_render') test_render.setup_highlights() + + local ansi = require('cp.ansi') + ansi.setup_highlight_groups() local test_state = test_module.get_run_panel_state() local tab_lines, tab_highlights = test_render.render_test_list(test_state) update_buffer_content(test_buffers.tab_buf, tab_lines, tab_highlights) @@ -596,8 +617,8 @@ local function navigate_problem(delta, language) local new_index = current_index + delta if new_index < 1 or new_index > #problems then - local direction = delta > 0 and 'next' or 'previous' - logger.log(('no %s problem available'):format(direction), vim.log.levels.INFO) + local msg = delta > 0 and 'at last problem' or 'at first problem' + logger.log(msg, vim.log.levels.WARN) return end diff --git a/lua/cp/test.lua b/lua/cp/test.lua index 753b38b..c217fa0 100644 --- a/lua/cp/test.lua +++ b/lua/cp/test.lua @@ -4,6 +4,7 @@ ---@field expected string ---@field status "pending"|"pass"|"fail"|"running"|"timeout" ---@field actual string? +---@field actual_highlights table[]? ---@field time_ms number? ---@field error string? ---@field selected boolean @@ -216,19 +217,41 @@ local function run_single_test_case(ctx, contest_config, cp_config, test_case) .system(run_cmd, { stdin = stdin_content, timeout = timeout_ms, - text = true, + text = false, }) :wait() local execution_time = (vim.uv.hrtime() - start_time) / 1000000 - local actual_output = (result.stdout or ''):gsub('\n$', '') - local stderr_output = (result.stderr or ''):gsub('\n$', '') + local ansi = require('cp.ansi') + local stdout_str = ansi.bytes_to_string(result.stdout or '') + local stderr_str = ansi.bytes_to_string(result.stderr or '') + + local actual_output = stdout_str:gsub('\n$', '') + local stderr_output = stderr_str:gsub('\n$', '') + + local actual_highlights = {} if stderr_output ~= '' then + local stderr_parsed = ansi.parse_ansi_text(stderr_output) + local clean_stderr = table.concat(stderr_parsed.lines, '\n') + + local line_offset if actual_output ~= '' then - actual_output = actual_output .. '\n\n--- stderr ---\n' .. stderr_output + local stdout_lines = vim.split(actual_output, '\n') + line_offset = #stdout_lines + 2 -- +1 for empty line, +1 for "--- stderr ---" + actual_output = actual_output .. '\n\n--- stderr ---\n' .. clean_stderr else - actual_output = '--- stderr ---\n' .. stderr_output + line_offset = 1 -- +1 for "--- stderr ---" + actual_output = '--- stderr ---\n' .. clean_stderr + end + + for _, highlight in ipairs(stderr_parsed.highlights) do + table.insert(actual_highlights, { + line = highlight.line + line_offset, + col_start = highlight.col_start, + col_end = highlight.col_end, + highlight_group = highlight.highlight_group, + }) end end @@ -264,6 +287,7 @@ local function run_single_test_case(ctx, contest_config, cp_config, test_case) return { status = status, actual = actual_output, + actual_highlights = actual_highlights, error = result.code ~= 0 and result.stderr or nil, stderr = result.stderr or '', time_ms = execution_time, @@ -317,6 +341,7 @@ function M.run_test_case(ctx, contest_config, cp_config, index) test_case.status = result.status test_case.actual = result.actual + test_case.actual_highlights = result.actual_highlights test_case.error = result.error test_case.stderr = result.stderr test_case.time_ms = result.time_ms @@ -346,9 +371,44 @@ function M.get_run_panel_state() end function M.handle_compilation_failure(compilation_stderr) + local ansi = require('cp.ansi') + local logger = require('cp.log') + local clean_text = 'Compilation failed' + local highlights = {} + + if compilation_stderr and compilation_stderr ~= '' then + logger.log('Raw compilation stderr length: ' .. #compilation_stderr) + logger.log('Has ANSI codes: ' .. tostring(compilation_stderr:find('\027%[[%d;]*m') ~= nil)) + + -- Show first 200 chars to see actual ANSI sequences + local sample = compilation_stderr:sub(1, 200):gsub('\027', '\\027') + logger.log('Stderr sample: ' .. sample) + + local parsed = ansi.parse_ansi_text(compilation_stderr) + clean_text = table.concat(parsed.lines, '\n') + highlights = parsed.highlights + + logger.log('Parsed highlights count: ' .. #highlights) + for i, hl in ipairs(highlights) do + logger.log( + 'Highlight ' + .. i + .. ': line=' + .. hl.line + .. ' col=' + .. hl.col_start + .. '-' + .. hl.col_end + .. ' group=' + .. (hl.highlight_group or 'nil') + ) + end + end + for i, test_case in ipairs(run_panel_state.test_cases) do test_case.status = 'fail' - test_case.actual = compilation_stderr or 'Compilation failed' + test_case.actual = clean_text + test_case.actual_highlights = highlights test_case.error = 'Compilation failed' test_case.stderr = '' test_case.time_ms = 0 diff --git a/spec/ansi_spec.lua b/spec/ansi_spec.lua new file mode 100644 index 0000000..09c7304 --- /dev/null +++ b/spec/ansi_spec.lua @@ -0,0 +1,107 @@ +describe('ansi parser', function() + local ansi = require('cp.ansi') + + describe('bytes_to_string', function() + it('returns string as-is', function() + local input = 'hello world' + assert.equals('hello world', ansi.bytes_to_string(input)) + end) + + it('converts byte array to string', function() + local input = { 104, 101, 108, 108, 111 } + assert.equals('hello', ansi.bytes_to_string(input)) + end) + end) + + describe('parse_ansi_text', function() + it('strips ansi codes from simple text', function() + local input = 'Hello \027[31mworld\027[0m!' + local result = ansi.parse_ansi_text(input) + + assert.equals('Hello world!', table.concat(result.lines, '\n')) + end) + + it('handles text without ansi codes', function() + local input = 'Plain text' + local result = ansi.parse_ansi_text(input) + + assert.equals('Plain text', table.concat(result.lines, '\n')) + assert.equals(0, #result.highlights) + end) + + it('creates correct highlight for colored text', function() + local input = 'Hello \027[31mworld\027[0m!' + local result = ansi.parse_ansi_text(input) + + assert.equals(1, #result.highlights) + local highlight = result.highlights[1] + assert.equals(0, highlight.line) + assert.equals(6, highlight.col_start) + assert.equals(11, highlight.col_end) + 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' + 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) + end) + + it('handles multiline colored text', function() + local input = '\027[31mline1\nline2\027[0m' + 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) + end) + + it('handles compiler-like output', 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) + 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')) + end) + + it('handles reset codes', function() + assert.is_nil(ansi.ansi_code_to_highlight('0')) + assert.is_nil(ansi.ansi_code_to_highlight('')) + end) + + it('handles unknown codes', function() + assert.is_nil(ansi.ansi_code_to_highlight('99')) + end) + end) +end)