diff --git a/doc/cp.txt b/doc/cp.txt index bb0f0f4..f730423 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -148,6 +148,7 @@ Here's an example configuration with lazy.nvim: >lua scrapers = { 'atcoder', 'codeforces', 'cses' }, filename = default_filename, -- + run_panel = { + ansi = true, diff_mode = 'vim', next_test_key = '', prev_test_key = '', @@ -199,6 +200,12 @@ Here's an example configuration with lazy.nvim: >lua *cp.RunPanelConfig* Fields: ~ + {ansi} (boolean, default: true) Enable ANSI color parsing and + highlighting. When true, compiler output and test results + display with colored syntax highlighting. When false, + ANSI escape codes are stripped for plain text display. + Requires vim.g.terminal_color_* to be configured for + proper color display. {diff_mode} (string, default: "vim") Diff backend: "vim" or "git". Git provides character-level precision, vim uses built-in diff. diff --git a/lua/cp/ansi.lua b/lua/cp/ansi.lua index d8116f0..c8e2a01 100644 --- a/lua/cp/ansi.lua +++ b/lua/cp/ansi.lua @@ -4,6 +4,8 @@ local M = {} +local logger = require('cp.log') + ---@param raw_output string|table ---@return string function M.bytes_to_string(raw_output) @@ -192,6 +194,21 @@ function M.setup_highlight_groups() BrightWhite = vim.g.terminal_color_15, } + local missing_color = false + for _, terminal_color in pairs(color_map) do + if terminal_color == nil then + missing_color = true + break + end + end + + if missing_color or #color_map == 0 then + logger.log( + 'ansi terminal colors (vim.g.terminal_color_*) not configured. . ANSI colors will not display properly. ', + vim.log.levels.WARN + ) + end + local combinations = { { bold = false, italic = false }, { bold = true, italic = false }, @@ -202,7 +219,7 @@ function M.setup_highlight_groups() for _, combo in ipairs(combinations) do for color_name, terminal_color in pairs(color_map) do local parts = { 'CpAnsi' } - local opts = { fg = terminal_color } + local opts = { fg = terminal_color or 'NONE' } if combo.bold then table.insert(parts, 'Bold') diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 1a52a5c..a1564fd 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -30,6 +30,7 @@ ---@field setup_code? fun(ctx: ProblemContext) ---@class RunPanelConfig +---@field ansi boolean Enable ANSI color parsing and highlighting ---@field diff_mode "vim"|"git" Diff backend to use ---@field next_test_key string Key to navigate to next test case ---@field prev_test_key string Key to navigate to previous test case @@ -97,6 +98,7 @@ M.defaults = { scrapers = constants.PLATFORMS, filename = nil, run_panel = { + ansi = true, diff_mode = 'vim', next_test_key = '', prev_test_key = '', @@ -186,6 +188,11 @@ function M.setup(user_config) }) vim.validate({ + ansi = { + config.run_panel.ansi, + 'boolean', + 'ansi color parsing must be enabled xor disabled', + }, diff_mode = { config.run_panel.diff_mode, function(value) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 2e3a742..7560f01 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -8,7 +8,7 @@ local scrape = require('cp.scrape') local snippets = require('cp.snippets') if not vim.fn.has('nvim-0.10.0') then - vim.notify('[cp.nvim]: requires nvim-0.10.0+', vim.log.levels.ERROR) + logger.log('[cp.nvim]: requires nvim-0.10.0+', vim.log.levels.ERROR) return {} end @@ -537,8 +537,10 @@ local function toggle_run_panel(is_debug) refresh_run_panel() vim.schedule(function() - local ansi = require('cp.ansi') - ansi.setup_highlight_groups() + if config.run_panel.ansi then + local ansi = require('cp.ansi') + ansi.setup_highlight_groups() + end if current_diff_layout then update_diff_panes() end diff --git a/lua/cp/run.lua b/lua/cp/run.lua index 3303cbd..ddd2346 100644 --- a/lua/cp/run.lua +++ b/lua/cp/run.lua @@ -241,9 +241,13 @@ local function run_single_test_case(ctx, contest_config, cp_config, test_case) local actual_highlights = {} if actual_output ~= '' then - local parsed = ansi.parse_ansi_text(actual_output) - actual_output = table.concat(parsed.lines, '\n') - actual_highlights = parsed.highlights + if cp_config.run_panel.ansi then + local parsed = ansi.parse_ansi_text(actual_output) + actual_output = table.concat(parsed.lines, '\n') + actual_highlights = parsed.highlights + else + actual_output = actual_output:gsub('\027%[[%d;]*[a-zA-Z]', '') + end end local max_lines = cp_config.run_panel.max_output_lines @@ -362,11 +366,18 @@ end function M.handle_compilation_failure(compilation_output) local ansi = require('cp.ansi') + local config = require('cp.config').setup() - -- Always parse the compilation output - it contains everything now - local parsed = ansi.parse_ansi_text(compilation_output or '') - local clean_text = table.concat(parsed.lines, '\n') - local highlights = parsed.highlights + local clean_text + local highlights = {} + + if config.run_panel.ansi then + local parsed = ansi.parse_ansi_text(compilation_output or '') + clean_text = table.concat(parsed.lines, '\n') + highlights = parsed.highlights + else + clean_text = (compilation_output or ''):gsub('\027%[[%d;]*[a-zA-Z]', '') + end for _, test_case in ipairs(run_panel_state.test_cases) do test_case.status = 'fail' diff --git a/spec/ansi_spec.lua b/spec/ansi_spec.lua index 0b7faf8..ff7a96b 100644 --- a/spec/ansi_spec.lua +++ b/spec/ansi_spec.lua @@ -212,4 +212,33 @@ describe('ansi parser', function() assert.is_nil(state.foreground) end) end) + + describe('setup_highlight_groups', function() + it('creates highlight groups with fallback colors when terminal colors are nil', function() + local original_colors = {} + for i = 0, 15 do + original_colors[i] = vim.g['terminal_color_' .. i] + vim.g['terminal_color_' .. i] = nil + end + + ansi.setup_highlight_groups() + + local highlight = vim.api.nvim_get_hl(0, { name = 'CpAnsiRed' }) + -- When 'NONE' is set, nvim_get_hl returns nil for that field + assert.is_nil(highlight.fg) + + for i = 0, 15 do + vim.g['terminal_color_' .. i] = original_colors[i] + end + end) + + it('creates highlight groups with proper colors when terminal colors are set', function() + vim.g.terminal_color_1 = '#ff0000' + + ansi.setup_highlight_groups() + + local highlight = vim.api.nvim_get_hl(0, { name = 'CpAnsiRed' }) + assert.equals(0xff0000, highlight.fg) + end) + end) end) diff --git a/spec/config_spec.lua b/spec/config_spec.lua index 7fc049b..8c4c79c 100644 --- a/spec/config_spec.lua +++ b/spec/config_spec.lua @@ -81,6 +81,16 @@ describe('cp.config', function() end) describe('run_panel config validation', function() + it('validates ansi is boolean', function() + local invalid_config = { + run_panel = { ansi = 'invalid' }, + } + + assert.has_error(function() + config.setup(invalid_config) + end, 'ansi: expected ansi color parsing must be enabled xor disabled, got string') + end) + it('validates diff_mode values', function() local invalid_config = { run_panel = { diff_mode = 'invalid' }, @@ -114,6 +124,7 @@ describe('cp.config', function() it('accepts valid run_panel config', function() local valid_config = { run_panel = { + ansi = false, diff_mode = 'git', next_test_key = 'j', prev_test_key = 'k', diff --git a/spec/extmark_spec.lua b/spec/extmark_spec.lua new file mode 100644 index 0000000..6383d55 --- /dev/null +++ b/spec/extmark_spec.lua @@ -0,0 +1,215 @@ +describe('extmarks', function() + local spec_helper = require('spec.spec_helper') + local highlight + + before_each(function() + spec_helper.setup() + highlight = require('cp.highlight') + end) + + after_each(function() + spec_helper.teardown() + end) + + describe('buffer deletion', function() + it('clears namespace on buffer delete', function() + local bufnr = 1 + local namespace = 100 + local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace') + local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark') + + highlight.apply_highlights(bufnr, { + { + line = 0, + col_start = 0, + col_end = 5, + highlight_group = 'CpDiffAdded', + }, + }, namespace) + + assert.stub(mock_clear).was_called_with(bufnr, namespace, 0, -1) + mock_clear:revert() + mock_extmark:revert() + end) + + it('handles invalid buffer gracefully', function() + local bufnr = 999 + local namespace = 100 + local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace') + local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark') + + mock_clear.on_call_with(bufnr, namespace, 0, -1).invokes(function() + error('Invalid buffer') + end) + + local success = pcall(highlight.apply_highlights, bufnr, { + { + line = 0, + col_start = 0, + col_end = 5, + highlight_group = 'CpDiffAdded', + }, + }, namespace) + + assert.is_false(success) + mock_clear:revert() + mock_extmark:revert() + end) + end) + + describe('namespace isolation', function() + it('creates unique namespaces', function() + local mock_create = stub(vim.api, 'nvim_create_namespace') + mock_create.on_call_with('cp_diff_highlights').returns(100) + mock_create.on_call_with('cp_test_list').returns(200) + mock_create.on_call_with('cp_ansi_highlights').returns(300) + + local diff_ns = highlight.create_namespace() + local test_ns = vim.api.nvim_create_namespace('cp_test_list') + local ansi_ns = vim.api.nvim_create_namespace('cp_ansi_highlights') + + assert.equals(100, diff_ns) + assert.equals(200, test_ns) + assert.equals(300, ansi_ns) + + mock_create:revert() + end) + + it('clears specific namespace independently', function() + local bufnr = 1 + local ns1 = 100 + local ns2 = 200 + local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace') + local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark') + + highlight.apply_highlights(bufnr, { + { line = 0, col_start = 0, col_end = 5, highlight_group = 'CpDiffAdded' }, + }, ns1) + + highlight.apply_highlights(bufnr, { + { line = 1, col_start = 0, col_end = 3, highlight_group = 'CpDiffRemoved' }, + }, ns2) + + assert.stub(mock_clear).was_called_with(bufnr, ns1, 0, -1) + assert.stub(mock_clear).was_called_with(bufnr, ns2, 0, -1) + assert.stub(mock_clear).was_called(2) + + mock_clear:revert() + mock_extmark:revert() + end) + end) + + describe('multiple updates', function() + it('clears previous extmarks on each update', function() + local bufnr = 1 + local namespace = 100 + local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace') + local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark') + + highlight.apply_highlights(bufnr, { + { line = 0, col_start = 0, col_end = 5, highlight_group = 'CpDiffAdded' }, + }, namespace) + + highlight.apply_highlights(bufnr, { + { line = 1, col_start = 0, col_end = 3, highlight_group = 'CpDiffRemoved' }, + }, namespace) + + assert.stub(mock_clear).was_called(2) + assert.stub(mock_clear).was_called_with(bufnr, namespace, 0, -1) + assert.stub(mock_extmark).was_called(2) + + mock_clear:revert() + mock_extmark:revert() + end) + + it('handles empty highlights', function() + local bufnr = 1 + local namespace = 100 + local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace') + local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark') + + highlight.apply_highlights(bufnr, { + { line = 0, col_start = 0, col_end = 5, highlight_group = 'CpDiffAdded' }, + }, namespace) + + highlight.apply_highlights(bufnr, {}, namespace) + + assert.stub(mock_clear).was_called(2) + assert.stub(mock_extmark).was_called(1) + + mock_clear:revert() + mock_extmark:revert() + end) + + it('skips invalid highlights', function() + local bufnr = 1 + local namespace = 100 + local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace') + local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark') + + highlight.apply_highlights(bufnr, { + { line = 0, col_start = 5, col_end = 5, highlight_group = 'CpDiffAdded' }, + { line = 1, col_start = 7, col_end = 3, highlight_group = 'CpDiffAdded' }, + { line = 2, col_start = 0, col_end = 5, highlight_group = 'CpDiffAdded' }, + }, namespace) + + assert.stub(mock_clear).was_called_with(bufnr, namespace, 0, -1) + assert.stub(mock_extmark).was_called(1) + assert.stub(mock_extmark).was_called_with(bufnr, namespace, 2, 0, { + end_col = 5, + hl_group = 'CpDiffAdded', + priority = 100, + }) + + mock_clear:revert() + mock_extmark:revert() + end) + end) + + describe('error handling', function() + it('fails when clear_namespace fails', function() + local bufnr = 1 + local namespace = 100 + local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace') + local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark') + + mock_clear.on_call_with(bufnr, namespace, 0, -1).invokes(function() + error('Namespace clear failed') + end) + + local success = pcall(highlight.apply_highlights, bufnr, { + { line = 0, col_start = 0, col_end = 5, highlight_group = 'CpDiffAdded' }, + }, namespace) + + assert.is_false(success) + assert.stub(mock_extmark).was_not_called() + + mock_clear:revert() + mock_extmark:revert() + end) + end) + + describe('parse_and_apply_diff cleanup', function() + it('clears namespace before applying parsed diff', function() + local bufnr = 1 + local namespace = 100 + local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace') + local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark') + local mock_set_lines = stub(vim.api, 'nvim_buf_set_lines') + local mock_get_option = stub(vim.api, 'nvim_get_option_value') + local mock_set_option = stub(vim.api, 'nvim_set_option_value') + + mock_get_option.returns(false) + + highlight.parse_and_apply_diff(bufnr, '+hello {+world+}', namespace) + + assert.stub(mock_clear).was_called_with(bufnr, namespace, 0, -1) + + mock_clear:revert() + mock_extmark:revert() + mock_set_lines:revert() + mock_get_option:revert() + mock_set_option:revert() + end) + end) +end) diff --git a/spec/run_spec.lua b/spec/run_spec.lua new file mode 100644 index 0000000..e70f4f7 --- /dev/null +++ b/spec/run_spec.lua @@ -0,0 +1,27 @@ +describe('run module', function() + local run = require('cp.run') + + describe('basic functionality', function() + it('has required functions', function() + assert.is_function(run.load_test_cases) + assert.is_function(run.run_test_case) + assert.is_function(run.run_all_test_cases) + assert.is_function(run.get_run_panel_state) + assert.is_function(run.handle_compilation_failure) + end) + + it('can get panel state', function() + local state = run.get_run_panel_state() + assert.is_table(state) + assert.is_table(state.test_cases) + end) + + it('handles compilation failure', function() + local compilation_output = 'error.cpp:1:1: error: undefined variable' + + assert.does_not_error(function() + run.handle_compilation_failure(compilation_output) + end) + end) + end) +end)