From 289e6efe62e90bce253131db34f5126dd7a0ec23 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 12:07:29 -0400 Subject: [PATCH 01/31] feat(test): test panel --- lua/cp/config.lua | 63 ++++++++++++++++ lua/cp/diff.lua | 150 +++++++++++++++++++++++++++++++++++++ lua/cp/highlight.lua | 164 +++++++++++++++++++++++++++++++++++++++++ lua/cp/init.lua | 90 +++++++--------------- lua/cp/test_render.lua | 102 +++++++++++++++++++++++++ 5 files changed, 504 insertions(+), 65 deletions(-) create mode 100644 lua/cp/diff.lua create mode 100644 lua/cp/highlight.lua create mode 100644 lua/cp/test_render.lua diff --git a/lua/cp/config.lua b/lua/cp/config.lua index cae943a..a36f6f2 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -31,6 +31,22 @@ ---@field before_debug? fun(ctx: ProblemContext) ---@field setup_code? fun(ctx: ProblemContext) +---@class TestPanelConfig +---@field diff_mode "vim"|"git" Diff backend to use +---@field toggle_key string Key to toggle test panel +---@field status_format "compact"|"verbose" Status display format + +---@class DiffGitConfig +---@field command string Git executable name +---@field args string[] Additional git diff arguments + +---@class DiffVimConfig +---@field enable_diffthis boolean Enable vim's diffthis + +---@class DiffConfig +---@field git DiffGitConfig +---@field vim DiffVimConfig + ---@class cp.Config ---@field contests table ---@field snippets table[] @@ -38,6 +54,8 @@ ---@field debug boolean ---@field scrapers table ---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string +---@field test_panel TestPanelConfig +---@field diff DiffConfig ---@class cp.UserConfig ---@field contests? table @@ -46,6 +64,8 @@ ---@field debug? boolean ---@field scrapers? table ---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string +---@field test_panel? TestPanelConfig +---@field diff? DiffConfig local M = {} local constants = require('cp.constants') @@ -62,6 +82,20 @@ M.defaults = { debug = false, scrapers = constants.PLATFORMS, filename = nil, + test_panel = { + diff_mode = "vim", + toggle_key = "t", + status_format = "compact", + }, + diff = { + git = { + command = "git", + args = {"diff", "--no-index", "--word-diff=plain", "--word-diff-regex=.", "--no-prefix"}, + }, + vim = { + enable_diffthis = true, + }, + }, } ---@param user_config cp.UserConfig|nil @@ -79,6 +113,8 @@ function M.setup(user_config) debug = { user_config.debug, { 'boolean', 'nil' }, true }, scrapers = { user_config.scrapers, { 'table', 'nil' }, true }, filename = { user_config.filename, { 'function', 'nil' }, true }, + test_panel = { user_config.test_panel, { 'table', 'nil' }, true }, + diff = { user_config.diff, { 'table', 'nil' }, true }, }) if user_config.hooks then @@ -101,6 +137,33 @@ function M.setup(user_config) }) end + if user_config.test_panel then + vim.validate({ + diff_mode = { + user_config.test_panel.diff_mode, + function(value) + return vim.tbl_contains({"vim", "git"}, value) + end, + "diff_mode must be 'vim' or 'git'", + }, + toggle_key = { user_config.test_panel.toggle_key, 'string', true }, + status_format = { + user_config.test_panel.status_format, + function(value) + return vim.tbl_contains({"compact", "verbose"}, value) + end, + "status_format must be 'compact' or 'verbose'", + }, + }) + end + + if user_config.diff then + vim.validate({ + git = { user_config.diff.git, { 'table', 'nil' }, true }, + vim = { user_config.diff.vim, { 'table', 'nil' }, true }, + }) + end + if user_config.contests then for contest_name, contest_config in pairs(user_config.contests) do for lang_name, lang_config in pairs(contest_config) do diff --git a/lua/cp/diff.lua b/lua/cp/diff.lua new file mode 100644 index 0000000..03928fa --- /dev/null +++ b/lua/cp/diff.lua @@ -0,0 +1,150 @@ +---@class DiffResult +---@field content string[] +---@field highlights table[]? + +---@class DiffBackend +---@field name string +---@field render fun(expected: string, actual: string, mode: string?): DiffResult + +local M = {} + +---Vim's built-in diff backend using diffthis +---@type DiffBackend +local vim_backend = { + name = 'vim', + render = function(expected, actual, mode) + -- For vim backend, we return the content as-is since diffthis handles highlighting + local expected_lines = vim.split(expected, '\n', { plain = true, trimempty = true }) + local actual_lines = vim.split(actual, '\n', { plain = true, trimempty = true }) + + return { + content = actual_lines, + highlights = nil -- diffthis handles highlighting + } + end +} + +---Git word-diff backend for character-level precision +---@type DiffBackend +local git_backend = { + name = 'git', + render = function(expected, actual, mode) + -- Create temporary files for git diff + local tmp_expected = vim.fn.tempname() + local tmp_actual = vim.fn.tempname() + + vim.fn.writefile(vim.split(expected, '\n', { plain = true }), tmp_expected) + vim.fn.writefile(vim.split(actual, '\n', { plain = true }), tmp_actual) + + local cmd = { + 'git', 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', + '--no-prefix', tmp_expected, tmp_actual + } + + local result = vim.system(cmd, { text = true }):wait() + + -- Clean up temp files + vim.fn.delete(tmp_expected) + vim.fn.delete(tmp_actual) + + if result.code == 0 then + -- No differences, return actual content as-is + return { + content = vim.split(actual, '\n', { plain = true, trimempty = true }), + highlights = {} + } + else + -- Parse git diff output + local lines = vim.split(result.stdout or '', '\n', { plain = true }) + local content_lines = {} + local highlights = {} + + -- Skip git diff header lines (start with @@, +++, ---, etc.) + local content_started = false + for _, line in ipairs(lines) do + if content_started or (not line:match('^@@') and not line:match('^%+%+%+') and not line:match('^%-%-%-') and not line:match('^index')) then + content_started = true + if line:match('^[^%-+]') or line:match('^%+') then + -- Skip lines starting with - (removed lines) for the actual pane + -- Only show lines that are unchanged or added + if not line:match('^%-') then + local clean_line = line:gsub('^%+', '') -- Remove + prefix + table.insert(content_lines, clean_line) + + -- Parse highlights will be handled in highlight.lua + table.insert(highlights, { + line = #content_lines, + content = clean_line + }) + end + end + end + end + + return { + content = content_lines, + highlights = highlights + } + end + end +} + +---Available diff backends +---@type table +local backends = { + vim = vim_backend, + git = git_backend, +} + +---Get available backend names +---@return string[] +function M.get_available_backends() + return vim.tbl_keys(backends) +end + +---Get a diff backend by name +---@param name string +---@return DiffBackend? +function M.get_backend(name) + return backends[name] +end + +---Check if git backend is available +---@return boolean +function M.is_git_available() + local result = vim.system({'git', '--version'}, { text = true }):wait() + return result.code == 0 +end + +---Get the best available backend based on config and system availability +---@param preferred_backend? string +---@return DiffBackend +function M.get_best_backend(preferred_backend) + if preferred_backend and backends[preferred_backend] then + if preferred_backend == 'git' and not M.is_git_available() then + -- Fall back to vim if git is not available + return backends.vim + end + return backends[preferred_backend] + end + + -- Default to git if available, otherwise vim + if M.is_git_available() then + return backends.git + else + return backends.vim + end +end + +---Render diff using specified backend +---@param expected string +---@param actual string +---@param backend_name? string +---@param mode? string +---@return DiffResult +function M.render_diff(expected, actual, backend_name, mode) + local backend = M.get_best_backend(backend_name) + return backend.render(expected, actual, mode) +end + +return M \ No newline at end of file diff --git a/lua/cp/highlight.lua b/lua/cp/highlight.lua new file mode 100644 index 0000000..13d7e65 --- /dev/null +++ b/lua/cp/highlight.lua @@ -0,0 +1,164 @@ +---@class DiffHighlight +---@field line number +---@field col_start number +---@field col_end number +---@field highlight_group string + +---@class ParsedDiff +---@field content string[] +---@field highlights DiffHighlight[] + +local M = {} + +---Parse git diff markers and extract highlight information +---@param text string Raw git diff output line +---@return string cleaned_text, DiffHighlight[] +local function parse_diff_line(text) + local highlights = {} + local cleaned_text = text + local offset = 0 + + -- Pattern for removed text: [-removed text-] + for removed_text in text:gmatch('%[%-(.-)%-%]') do + local start_pos = text:find('%[%-' .. vim.pesc(removed_text) .. '%-%]', 1, false) + if start_pos then + -- Remove the marker and adjust positions + local marker_len = #('[-%-%]') + #removed_text + cleaned_text = cleaned_text:gsub('%[%-' .. vim.pesc(removed_text) .. '%-%]', '', 1) + + -- Since we're removing text, we don't add highlights for removed content in the actual pane + -- This is handled by showing removed content in the expected pane + end + end + + -- Reset for added text parsing on the cleaned text + local final_text = cleaned_text + local final_highlights = {} + offset = 0 + + -- Pattern for added text: {+added text+} + for added_text in cleaned_text:gmatch('{%+(.-)%+}') do + local start_pos = final_text:find('{%+' .. vim.pesc(added_text) .. '%+}', 1, false) + if start_pos then + -- Calculate position after previous highlights + local highlight_start = start_pos - offset - 1 -- 0-based for extmarks + local highlight_end = highlight_start + #added_text + + table.insert(final_highlights, { + line = 0, -- Will be set by caller + col_start = highlight_start, + col_end = highlight_end, + highlight_group = 'CpDiffAdded' + }) + + -- Remove the marker + local marker_len = #{'{+'} + #{'+}'} + #added_text + final_text = final_text:gsub('{%+' .. vim.pesc(added_text) .. '%+}', added_text, 1) + offset = offset + #{'{+'} + #{'+}'} + end + end + + return final_text, final_highlights +end + +---Parse complete git diff output +---@param diff_output string +---@return ParsedDiff +function M.parse_git_diff(diff_output) + local lines = vim.split(diff_output, '\n', { plain = true }) + local content_lines = {} + local all_highlights = {} + + -- Skip git diff header lines + local content_started = false + for _, line in ipairs(lines) do + -- Skip header lines (@@, +++, ---, index, etc.) + if content_started or ( + not line:match('^@@') and + not line:match('^%+%+%+') and + not line:match('^%-%-%-') and + not line:match('^index') and + not line:match('^diff %-%-git') + ) then + content_started = true + + -- Process content lines + if line:match('^%+') then + -- Added line - remove + prefix and parse highlights + local clean_line = line:sub(2) -- Remove + prefix + local parsed_line, line_highlights = parse_diff_line(clean_line) + + table.insert(content_lines, parsed_line) + + -- Set line numbers for highlights + local line_num = #content_lines + for _, highlight in ipairs(line_highlights) do + highlight.line = line_num - 1 -- 0-based for extmarks + table.insert(all_highlights, highlight) + end + elseif line:match('^%-') then + -- Removed line - we handle this in the expected pane, skip for actual + elseif not line:match('^\\') then -- Skip "\ No newline" messages + -- Unchanged line + local parsed_line, line_highlights = parse_diff_line(line) + table.insert(content_lines, parsed_line) + + -- Set line numbers for any highlights (shouldn't be any for unchanged lines) + local line_num = #content_lines + for _, highlight in ipairs(line_highlights) do + highlight.line = line_num - 1 -- 0-based for extmarks + table.insert(all_highlights, highlight) + end + end + end + end + + return { + content = content_lines, + highlights = all_highlights + } +end + +---Apply highlights to a buffer using extmarks +---@param bufnr number +---@param highlights DiffHighlight[] +---@param namespace number +function M.apply_highlights(bufnr, highlights, namespace) + -- Clear existing highlights in this namespace + vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1) + + for _, highlight in ipairs(highlights) do + if highlight.col_start < highlight.col_end then + vim.api.nvim_buf_set_extmark(bufnr, namespace, highlight.line, highlight.col_start, { + end_col = highlight.col_end, + hl_group = highlight.highlight_group, + priority = 100, + }) + end + end +end + +---Create namespace for diff highlights +---@return number +function M.create_namespace() + return vim.api.nvim_create_namespace('cp_diff_highlights') +end + +---Parse and apply git diff to buffer +---@param bufnr number +---@param diff_output string +---@param namespace number +---@return string[] content_lines +function M.parse_and_apply_diff(bufnr, diff_output, namespace) + local parsed = M.parse_git_diff(diff_output) + + -- Set buffer content + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, parsed.content) + + -- Apply highlights + M.apply_highlights(bufnr, parsed.highlights, namespace) + + return parsed.content +end + +return M \ No newline at end of file diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 075ee65..607b8ad 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -191,9 +191,13 @@ local function toggle_test_panel(is_debug) local expected_buf = vim.api.nvim_create_buf(false, true) local actual_buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = tab_buf }) - vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = expected_buf }) - vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = actual_buf }) + -- Set buffer options + local buffer_opts = { 'bufhidden', 'wipe' } + for _, buf in ipairs({tab_buf, expected_buf, actual_buf}) do + vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = buf }) + vim.api.nvim_set_option_value('readonly', true, { buf = buf }) + vim.api.nvim_set_option_value('modifiable', false, { buf = buf }) + end local main_win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(main_win, tab_buf) @@ -222,61 +226,16 @@ local function toggle_test_panel(is_debug) } local function render_test_tabs() + local test_render = require('cp.test_render') + test_render.setup_highlights() local test_state = test_module.get_test_panel_state() - local tab_lines = {} + return test_render.render_test_list(test_state, config.test_panel) + end - local max_status_width = 0 - local max_code_width = 0 - local max_time_width = 0 - - for _, test_case in ipairs(test_state.test_cases) do - local status_text = test_case.status == 'pending' and '' or string.upper(test_case.status) - max_status_width = math.max(max_status_width, #status_text) - - if test_case.code then - max_code_width = math.max(max_code_width, #tostring(test_case.code)) - end - - if test_case.time_ms then - local time_text = string.format('%.0fms', test_case.time_ms) - max_time_width = math.max(max_time_width, #time_text) - end - end - - for i, test_case in ipairs(test_state.test_cases) do - local prefix = i == test_state.current_index and '> ' or ' ' - local tab = string.format('%s%d.', prefix, i) - - if test_case.ok ~= nil then - tab = tab .. string.format(' [ok:%-5s]', tostring(test_case.ok)) - end - - if test_case.code then - tab = tab .. string.format(' [code:%-' .. max_code_width .. 's]', tostring(test_case.code)) - end - - if test_case.time_ms then - local time_text = string.format('%.0fms', test_case.time_ms) - tab = tab .. string.format(' [time:%-' .. max_time_width .. 's]', time_text) - end - - if test_case.signal then - tab = tab .. string.format(' [%s]', test_case.signal) - end - - table.insert(tab_lines, tab) - end - - local current_test = test_state.test_cases[test_state.current_index] - if current_test then - table.insert(tab_lines, '') - table.insert(tab_lines, 'Input:') - for _, line in ipairs(vim.split(current_test.input, '\n', { plain = true, trimempty = true })) do - table.insert(tab_lines, line) - end - end - - return tab_lines + local function update_buffer_content(bufnr, lines) + vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr }) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr }) end local function update_expected_pane() @@ -290,11 +249,7 @@ local function toggle_test_panel(is_debug) local expected_text = current_test.expected local expected_lines = vim.split(expected_text, '\n', { plain = true, trimempty = true }) - vim.api.nvim_buf_set_lines(test_buffers.expected_buf, 0, -1, false, expected_lines) - - if vim.fn.has('nvim-0.8.0') == 1 then - vim.api.nvim_set_option_value('winbar', 'Expected', { win = test_windows.expected_win }) - end + update_buffer_content(test_buffers.expected_buf, expected_lines) end local function update_actual_pane() @@ -315,10 +270,12 @@ local function toggle_test_panel(is_debug) actual_lines = { '(not run yet)' } end - vim.api.nvim_buf_set_lines(test_buffers.actual_buf, 0, -1, false, actual_lines) + update_buffer_content(test_buffers.actual_buf, actual_lines) - if vim.fn.has('nvim-0.8.0') == 1 then - vim.api.nvim_set_option_value('winbar', 'Actual', { win = test_windows.actual_win }) + local test_render = require('cp.test_render') + local status_bar_text = test_render.render_status_bar(current_test) + if status_bar_text ~= '' then + vim.api.nvim_set_option_value('winbar', status_bar_text, { win = test_windows.actual_win }) end vim.api.nvim_set_option_value('diff', enable_diff, { win = test_windows.expected_win }) @@ -340,7 +297,7 @@ local function toggle_test_panel(is_debug) end local tab_lines = render_test_tabs() - vim.api.nvim_buf_set_lines(test_buffers.tab_buf, 0, -1, false, tab_lines) + update_buffer_content(test_buffers.tab_buf, tab_lines) update_expected_pane() update_actual_pane() @@ -373,6 +330,9 @@ local function toggle_test_panel(is_debug) vim.keymap.set('n', 'q', function() toggle_test_panel() end, { buffer = buf, silent = true }) + vim.keymap.set('n', config.test_panel.toggle_key, function() + toggle_test_panel() + end, { buffer = buf, silent = true }) end if is_debug and config.hooks and config.hooks.before_debug then diff --git a/lua/cp/test_render.lua b/lua/cp/test_render.lua new file mode 100644 index 0000000..5ca1298 --- /dev/null +++ b/lua/cp/test_render.lua @@ -0,0 +1,102 @@ +---@class TestRenderConfig +---@field status_format "compact"|"verbose" + +---@class StatusInfo +---@field text string +---@field highlight_group string + +local M = {} + +---Convert test status to CP terminology with colors +---@param test_case TestCase +---@return StatusInfo +local function get_status_info(test_case) + if test_case.status == 'pass' then + return { text = 'AC', highlight_group = 'CpTestAC' } + elseif test_case.status == 'fail' then + if test_case.timed_out then + return { text = 'TLE', highlight_group = 'CpTestError' } + elseif test_case.code and test_case.code ~= 0 then + return { text = 'RTE', highlight_group = 'CpTestError' } + else + return { text = 'WA', highlight_group = 'CpTestError' } + end + elseif test_case.status == 'timeout' then + return { text = 'TLE', highlight_group = 'CpTestError' } + elseif test_case.status == 'running' then + return { text = '...', highlight_group = 'CpTestPending' } + else + return { text = '', highlight_group = 'CpTestPending' } + end +end + +---Render test cases list with improved layout +---@param test_state TestPanelState +---@param config? TestRenderConfig +---@return string[] +function M.render_test_list(test_state, config) + config = config or { status_format = 'compact' } + local lines = {} + + for i, test_case in ipairs(test_state.test_cases) do + local is_current = i == test_state.current_index + local prefix = is_current and '> ' or ' ' + local status_info = get_status_info(test_case) + + local status_text = status_info.text ~= '' and status_info.text or '' + local line = string.format('%s%d. %s', prefix, i, status_text) + + table.insert(lines, line) + + if is_current and test_case.input and test_case.input ~= '' then + for _, input_line in ipairs(vim.split(test_case.input, '\n', { plain = true, trimempty = false })) do + table.insert(lines, ' ' .. input_line) + end + end + end + + return lines +end + +---Create status bar content for diff pane +---@param test_case TestCase? +---@return string +function M.render_status_bar(test_case) + if not test_case then + return '' + end + + local parts = {} + + if test_case.time_ms then + table.insert(parts, string.format('%.0fms', test_case.time_ms)) + end + + if test_case.code then + table.insert(parts, string.format('Exit: %d', test_case.code)) + end + + return table.concat(parts, ' │ ') +end + +---Get highlight groups needed for test rendering +---@return table +function M.get_highlight_groups() + return { + CpTestAC = { fg = '#10b981', bold = true }, + CpTestError = { fg = '#ef4444', bold = true }, + CpTestPending = { fg = '#6b7280' }, + CpDiffRemoved = { fg = '#ef4444', bg = '#1f1f1f' }, + CpDiffAdded = { fg = '#10b981', bg = '#1f1f1f' }, + } +end + +---Setup highlight groups +function M.setup_highlights() + local groups = M.get_highlight_groups() + for group_name, opts in pairs(groups) do + vim.api.nvim_set_hl(0, group_name, opts) + end +end + +return M From d193fabfb9fc34e6c1a4616fcb1755ebc7e5b923 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 12:11:50 -0400 Subject: [PATCH 02/31] feat(test: git diff backend --- doc/cp.txt | 87 ++++++++++++++++++++++++++++++++++++++----------- lua/cp/diff.lua | 42 +++--------------------- lua/cp/init.lua | 48 ++++++++++++++++++++------- 3 files changed, 110 insertions(+), 67 deletions(-) diff --git a/doc/cp.txt b/doc/cp.txt index f743f51..4e8f3ac 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -50,8 +50,8 @@ Setup Commands ~ Action Commands ~ :CP test [--debug] Toggle test panel for individual test case - debugging. Shows per-test results with three-pane - layout for easy Expected/Actual comparison. + debugging. Shows per-test results with redesigned + layout for efficient comparison. Use --debug flag to compile with debug flags Requires contest setup first. @@ -115,6 +115,21 @@ Optional configuration with lazy.nvim: > vim.diagnostic.enable(false) end, }, + test_panel = { + diff_mode = "vim", -- "vim" or "git" + toggle_key = "t", -- key to toggle test panel + status_format = "compact", -- "compact" or "verbose" + }, + diff = { + git = { + command = "git", + args = {"diff", "--no-index", "--word-diff=plain", + "--word-diff-regex=.", "--no-prefix"}, + }, + vim = { + enable_diffthis = true, + }, + }, snippets = { ... }, -- LuaSnip snippets filename = function(contest, contest_id, problem_id, config, language) ... end, } @@ -131,6 +146,8 @@ Optional configuration with lazy.nvim: > during operation. • {scrapers} (`table`) Per-platform scraper control. Default enables all platforms. + • {test_panel} (`TestPanelConfig`) Test panel behavior configuration. + • {diff} (`DiffConfig`) Diff backend configuration. • {filename}? (`function`) Custom filename generation function. `function(contest, contest_id, problem_id, config, language)` Should return full filename with extension. @@ -157,6 +174,20 @@ Optional configuration with lazy.nvim: > • {extension} (`string`) File extension (e.g. "cc", "py"). • {executable}? (`string`) Executable name for interpreted languages. +*cp.TestPanelConfig* + + Fields: ~ + • {diff_mode} (`string`, default: `"vim"`) Diff backend: "vim" or "git". + Git provides character-level precision, vim uses built-in diff. + • {toggle_key} (`string`, default: `"t"`) Key to toggle test panel. + • {status_format} (`string`, default: `"compact"`) Status display format. + +*cp.DiffConfig* + + Fields: ~ + • {git} (`DiffGitConfig`) Git diff backend configuration. + • {vim} (`DiffVimConfig`) Vim diff backend configuration. + *cp.Hooks* Fields: ~ @@ -256,8 +287,9 @@ Example: Quick setup for single Codeforces problem > TEST PANEL *cp-test* -The test panel provides individual test case debugging with a three-pane -layout showing test list, expected output, and actual output side-by-side. +The test panel provides individual test case debugging with a streamlined +layout optimized for modern screens. Shows test status with competitive +programming terminology and efficient space usage. Activation ~ *:CP-test* @@ -270,29 +302,46 @@ Activation ~ Interface ~ -The test panel uses a three-pane layout for easy comparison: > +The test panel uses a redesigned two-pane layout for efficient comparison: +(note that the diff is indeed highlighted, not the weird amalgamation of +characters below) > - ┌─────────────────────────────────────────────────────────────┐ - │ 1. [ok:true ] [code:0] [time:12ms] │ - │> 2. [ok:false] [code:0] [time:45ms] │ - │ │ - │ Input: │ - │ 5 3 │ - │ │ - └─────────────────────────────────────────────────────────────┘ - ┌─ Expected ──────────────────┐ ┌───── Actual ────────────────┐ - │ 8 │ │ 7 │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - └─────────────────────────────┘ └─────────────────────────────┘ + ┌─ Tests ─────────────────────┐ ┌─ Expected vs Actual ───────────────────────┐ + │ AC 1. 12ms │ │ 45ms │ Exit: 0 │ + │ WA > 2. 45ms │ ├────────────────────────────────────────────┤ + │ 5 3 │ │ │ + │ │ │ 4[-2-]{+3+} │ + │ AC 3. 9ms │ │ 100 │ + │ RTE 4. 0ms │ │ hello w[-o-]r{+o+}ld │ + │ │ │ │ + └─────────────────────────────┘ └────────────────────────────────────────────┘ < +Status Indicators ~ + +Test cases use competitive programming terminology: + + AC Accepted (passed) + WA Wrong Answer (output mismatch) + TLE Time Limit Exceeded (timeout) + RTE Runtime Error (non-zero exit) + Keymaps ~ *cp-test-keys* Navigate to next test case Navigate to previous test case q Exit test panel (restore layout) +t Toggle test panel (configurable via test_panel.toggle_key) + +Diff Modes ~ + +Two diff backends are available: + + vim Built-in vim diff (default, always available) + git Character-level git word-diff (requires git, more precise) + +The git backend shows character-level changes with [-removed-] and {+added+} +markers for precise difference analysis. Execution Details ~ diff --git a/lua/cp/diff.lua b/lua/cp/diff.lua index 03928fa..97c1b76 100644 --- a/lua/cp/diff.lua +++ b/lua/cp/diff.lua @@ -48,42 +48,16 @@ local git_backend = { vim.fn.delete(tmp_actual) if result.code == 0 then - -- No differences, return actual content as-is return { content = vim.split(actual, '\n', { plain = true, trimempty = true }), highlights = {} } else - -- Parse git diff output - local lines = vim.split(result.stdout or '', '\n', { plain = true }) - local content_lines = {} - local highlights = {} - - -- Skip git diff header lines (start with @@, +++, ---, etc.) - local content_started = false - for _, line in ipairs(lines) do - if content_started or (not line:match('^@@') and not line:match('^%+%+%+') and not line:match('^%-%-%-') and not line:match('^index')) then - content_started = true - if line:match('^[^%-+]') or line:match('^%+') then - -- Skip lines starting with - (removed lines) for the actual pane - -- Only show lines that are unchanged or added - if not line:match('^%-') then - local clean_line = line:gsub('^%+', '') -- Remove + prefix - table.insert(content_lines, clean_line) - - -- Parse highlights will be handled in highlight.lua - table.insert(highlights, { - line = #content_lines, - content = clean_line - }) - end - end - end - end - + local highlight_module = require('cp.highlight') return { - content = content_lines, - highlights = highlights + content = {}, + highlights = {}, + raw_diff = result.stdout or '' } end end @@ -122,18 +96,12 @@ end function M.get_best_backend(preferred_backend) if preferred_backend and backends[preferred_backend] then if preferred_backend == 'git' and not M.is_git_available() then - -- Fall back to vim if git is not available return backends.vim end return backends[preferred_backend] end - -- Default to git if available, otherwise vim - if M.is_git_available() then - return backends.git - else - return backends.vim - end + return backends.vim end ---Render diff using specified backend diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 607b8ad..c16f185 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -225,6 +225,9 @@ local function toggle_test_panel(is_debug) actual_buf = actual_buf, } + local highlight = require('cp.highlight') + local diff_namespace = highlight.create_namespace() + local function render_test_tabs() local test_render = require('cp.test_render') test_render.setup_highlights() @@ -250,6 +253,15 @@ local function toggle_test_panel(is_debug) local expected_lines = vim.split(expected_text, '\n', { plain = true, trimempty = true }) update_buffer_content(test_buffers.expected_buf, expected_lines) + + local diff_backend = require('cp.diff') + local backend = diff_backend.get_best_backend(config.test_panel.diff_mode) + + if backend.name == 'vim' and current_test.status == 'fail' then + vim.api.nvim_set_option_value('diff', true, { win = test_windows.expected_win }) + else + vim.api.nvim_set_option_value('diff', false, { win = test_windows.expected_win }) + end end local function update_actual_pane() @@ -270,24 +282,38 @@ local function toggle_test_panel(is_debug) actual_lines = { '(not run yet)' } end - update_buffer_content(test_buffers.actual_buf, actual_lines) - local test_render = require('cp.test_render') local status_bar_text = test_render.render_status_bar(current_test) if status_bar_text ~= '' then vim.api.nvim_set_option_value('winbar', status_bar_text, { win = test_windows.actual_win }) end - vim.api.nvim_set_option_value('diff', enable_diff, { win = test_windows.expected_win }) - vim.api.nvim_set_option_value('diff', enable_diff, { win = test_windows.actual_win }) - if enable_diff then - vim.api.nvim_win_call(test_windows.expected_win, function() - vim.cmd.diffthis() - end) - vim.api.nvim_win_call(test_windows.actual_win, function() - vim.cmd.diffthis() - end) + local diff_backend = require('cp.diff') + local backend = diff_backend.get_best_backend(config.test_panel.diff_mode) + + if backend.name == 'git' then + local diff_result = backend.render(current_test.expected, current_test.actual) + if diff_result.raw_diff and diff_result.raw_diff ~= '' then + highlight.parse_and_apply_diff(test_buffers.actual_buf, diff_result.raw_diff, diff_namespace) + else + update_buffer_content(test_buffers.actual_buf, actual_lines) + end + else + update_buffer_content(test_buffers.actual_buf, actual_lines) + vim.api.nvim_set_option_value('diff', true, { win = test_windows.expected_win }) + vim.api.nvim_set_option_value('diff', true, { win = test_windows.actual_win }) + vim.api.nvim_win_call(test_windows.expected_win, function() + vim.cmd.diffthis() + end) + vim.api.nvim_win_call(test_windows.actual_win, function() + vim.cmd.diffthis() + end) + end + else + update_buffer_content(test_buffers.actual_buf, actual_lines) + vim.api.nvim_set_option_value('diff', false, { win = test_windows.expected_win }) + vim.api.nvim_set_option_value('diff', false, { win = test_windows.actual_win }) end end From ab9a0f43b53ae787856605c8b2ea6473fef3a52e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 12:11:56 -0400 Subject: [PATCH 03/31] fix(ci): format --- lua/cp/config.lua | 14 +++++++------- lua/cp/diff.lua | 24 +++++++++++++++--------- lua/cp/highlight.lua | 31 +++++++++++++++++-------------- lua/cp/init.lua | 8 ++++++-- lua/cp/test_render.lua | 4 +++- 5 files changed, 48 insertions(+), 33 deletions(-) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index a36f6f2..ca82b99 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -83,14 +83,14 @@ M.defaults = { scrapers = constants.PLATFORMS, filename = nil, test_panel = { - diff_mode = "vim", - toggle_key = "t", - status_format = "compact", + diff_mode = 'vim', + toggle_key = 't', + status_format = 'compact', }, diff = { git = { - command = "git", - args = {"diff", "--no-index", "--word-diff=plain", "--word-diff-regex=.", "--no-prefix"}, + command = 'git', + args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' }, }, vim = { enable_diffthis = true, @@ -142,7 +142,7 @@ function M.setup(user_config) diff_mode = { user_config.test_panel.diff_mode, function(value) - return vim.tbl_contains({"vim", "git"}, value) + return vim.tbl_contains({ 'vim', 'git' }, value) end, "diff_mode must be 'vim' or 'git'", }, @@ -150,7 +150,7 @@ function M.setup(user_config) status_format = { user_config.test_panel.status_format, function(value) - return vim.tbl_contains({"compact", "verbose"}, value) + return vim.tbl_contains({ 'compact', 'verbose' }, value) end, "status_format must be 'compact' or 'verbose'", }, diff --git a/lua/cp/diff.lua b/lua/cp/diff.lua index 97c1b76..c665b78 100644 --- a/lua/cp/diff.lua +++ b/lua/cp/diff.lua @@ -19,9 +19,9 @@ local vim_backend = { return { content = actual_lines, - highlights = nil -- diffthis handles highlighting + highlights = nil, -- diffthis handles highlighting } - end + end, } ---Git word-diff backend for character-level precision @@ -37,8 +37,14 @@ local git_backend = { vim.fn.writefile(vim.split(actual, '\n', { plain = true }), tmp_actual) local cmd = { - 'git', 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', - '--no-prefix', tmp_expected, tmp_actual + 'git', + 'diff', + '--no-index', + '--word-diff=plain', + '--word-diff-regex=.', + '--no-prefix', + tmp_expected, + tmp_actual, } local result = vim.system(cmd, { text = true }):wait() @@ -50,17 +56,17 @@ local git_backend = { if result.code == 0 then return { content = vim.split(actual, '\n', { plain = true, trimempty = true }), - highlights = {} + highlights = {}, } else local highlight_module = require('cp.highlight') return { content = {}, highlights = {}, - raw_diff = result.stdout or '' + raw_diff = result.stdout or '', } end - end + end, } ---Available diff backends @@ -86,7 +92,7 @@ end ---Check if git backend is available ---@return boolean function M.is_git_available() - local result = vim.system({'git', '--version'}, { text = true }):wait() + local result = vim.system({ 'git', '--version' }, { text = true }):wait() return result.code == 0 end @@ -115,4 +121,4 @@ function M.render_diff(expected, actual, backend_name, mode) return backend.render(expected, actual, mode) end -return M \ No newline at end of file +return M diff --git a/lua/cp/highlight.lua b/lua/cp/highlight.lua index 13d7e65..3145a15 100644 --- a/lua/cp/highlight.lua +++ b/lua/cp/highlight.lua @@ -23,7 +23,7 @@ local function parse_diff_line(text) local start_pos = text:find('%[%-' .. vim.pesc(removed_text) .. '%-%]', 1, false) if start_pos then -- Remove the marker and adjust positions - local marker_len = #('[-%-%]') + #removed_text + local marker_len = #'[-%-%]' + #removed_text cleaned_text = cleaned_text:gsub('%[%-' .. vim.pesc(removed_text) .. '%-%]', '', 1) -- Since we're removing text, we don't add highlights for removed content in the actual pane @@ -41,20 +41,20 @@ local function parse_diff_line(text) local start_pos = final_text:find('{%+' .. vim.pesc(added_text) .. '%+}', 1, false) if start_pos then -- Calculate position after previous highlights - local highlight_start = start_pos - offset - 1 -- 0-based for extmarks + local highlight_start = start_pos - offset - 1 -- 0-based for extmarks local highlight_end = highlight_start + #added_text table.insert(final_highlights, { line = 0, -- Will be set by caller col_start = highlight_start, col_end = highlight_end, - highlight_group = 'CpDiffAdded' + highlight_group = 'CpDiffAdded', }) -- Remove the marker - local marker_len = #{'{+'} + #{'+}'} + #added_text + local marker_len = #{ '{+' } + #{ '+}' } + #added_text final_text = final_text:gsub('{%+' .. vim.pesc(added_text) .. '%+}', added_text, 1) - offset = offset + #{'{+'} + #{'+}'} + offset = offset + #{ '{+' } + #{ '+}' } end end @@ -73,13 +73,16 @@ function M.parse_git_diff(diff_output) local content_started = false for _, line in ipairs(lines) do -- Skip header lines (@@, +++, ---, index, etc.) - if content_started or ( - not line:match('^@@') and - not line:match('^%+%+%+') and - not line:match('^%-%-%-') and - not line:match('^index') and - not line:match('^diff %-%-git') - ) then + if + content_started + or ( + not line:match('^@@') + and not line:match('^%+%+%+') + and not line:match('^%-%-%-') + and not line:match('^index') + and not line:match('^diff %-%-git') + ) + then content_started = true -- Process content lines @@ -115,7 +118,7 @@ function M.parse_git_diff(diff_output) return { content = content_lines, - highlights = all_highlights + highlights = all_highlights, } end @@ -161,4 +164,4 @@ function M.parse_and_apply_diff(bufnr, diff_output, namespace) return parsed.content end -return M \ No newline at end of file +return M diff --git a/lua/cp/init.lua b/lua/cp/init.lua index c16f185..f0e4e61 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -193,7 +193,7 @@ local function toggle_test_panel(is_debug) -- Set buffer options local buffer_opts = { 'bufhidden', 'wipe' } - for _, buf in ipairs({tab_buf, expected_buf, actual_buf}) do + for _, buf in ipairs({ tab_buf, expected_buf, actual_buf }) do vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = buf }) vim.api.nvim_set_option_value('readonly', true, { buf = buf }) vim.api.nvim_set_option_value('modifiable', false, { buf = buf }) @@ -295,7 +295,11 @@ local function toggle_test_panel(is_debug) if backend.name == 'git' then local diff_result = backend.render(current_test.expected, current_test.actual) if diff_result.raw_diff and diff_result.raw_diff ~= '' then - highlight.parse_and_apply_diff(test_buffers.actual_buf, diff_result.raw_diff, diff_namespace) + highlight.parse_and_apply_diff( + test_buffers.actual_buf, + diff_result.raw_diff, + diff_namespace + ) else update_buffer_content(test_buffers.actual_buf, actual_lines) end diff --git a/lua/cp/test_render.lua b/lua/cp/test_render.lua index 5ca1298..904b445 100644 --- a/lua/cp/test_render.lua +++ b/lua/cp/test_render.lua @@ -49,7 +49,9 @@ function M.render_test_list(test_state, config) table.insert(lines, line) if is_current and test_case.input and test_case.input ~= '' then - for _, input_line in ipairs(vim.split(test_case.input, '\n', { plain = true, trimempty = false })) do + for _, input_line in + ipairs(vim.split(test_case.input, '\n', { plain = true, trimempty = false })) + do table.insert(lines, ' ' .. input_line) end end From 5ca6b8b272ce6fe4c56a989812f2d6e409db74e4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 12:31:19 -0400 Subject: [PATCH 04/31] feat: more stuff --- doc/cp.txt | 48 +++++++++++++++++++++++++---------------- lua/cp/config.lua | 49 ++++++++++++++++++++++++------------------ lua/cp/execute.lua | 2 +- lua/cp/init.lua | 11 ++++++---- lua/cp/test.lua | 2 +- lua/cp/test_render.lua | 7 +----- 6 files changed, 67 insertions(+), 52 deletions(-) diff --git a/doc/cp.txt b/doc/cp.txt index 4e8f3ac..1404d65 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -85,7 +85,7 @@ Optional configuration with lazy.nvim: > 'g++', '-std=c++{version}', '-O2', '-Wall', '-Wextra', '-DLOCAL', '{source}', '-o', '{binary}', }, - run = { '{binary}' }, + test = { '{binary}' }, debug = { 'g++', '-std=c++{version}', '-g3', '-fsanitize=address,undefined', '-DLOCAL', @@ -95,7 +95,7 @@ Optional configuration with lazy.nvim: > extension = "cc", }, python = { - run = { 'python3', '{source}' }, + test = { 'python3', '{source}' }, debug = { 'python3', '{source}' }, extension = "py", }, @@ -104,11 +104,8 @@ Optional configuration with lazy.nvim: > }, }, hooks = { - before_run = function(ctx) vim.cmd.w() end, - before_debug = function(ctx) - -- ctx.problem_id, ctx.platform, ctx.source_file, etc. - vim.cmd.w() - end, + before_test = function(ctx) vim.cmd.w() end, + before_debug = function(ctx) ... end, setup_code = function(ctx) vim.wo.foldmethod = "marker" vim.wo.foldmarker = "{{{,}}}" @@ -116,9 +113,10 @@ Optional configuration with lazy.nvim: > end, }, test_panel = { - diff_mode = "vim", -- "vim" or "git" - toggle_key = "t", -- key to toggle test panel - status_format = "compact", -- "compact" or "verbose" + diff_mode = "vim", -- "vim" or "git" + toggle_key = "t", -- toggle test panel + next_test_key = "", -- navigate to next test case + prev_test_key = "", -- navigate to previous test case }, diff = { git = { @@ -126,9 +124,6 @@ Optional configuration with lazy.nvim: > args = {"diff", "--no-index", "--word-diff=plain", "--word-diff-regex=.", "--no-prefix"}, }, - vim = { - enable_diffthis = true, - }, }, snippets = { ... }, -- LuaSnip snippets filename = function(contest, contest_id, problem_id, config, language) ... end, @@ -168,7 +163,7 @@ Optional configuration with lazy.nvim: > Fields: ~ • {compile}? (`string[]`) Compile command template with `{version}`, `{source}`, `{binary}` placeholders. - • {run} (`string[]`) Run command template. + • {test} (`string[]`) Test execution command template. • {debug}? (`string[]`) Debug compile command template. • {version}? (`number`) Language version (e.g. 20, 23 for C++). • {extension} (`string`) File extension (e.g. "cc", "py"). @@ -180,23 +175,38 @@ Optional configuration with lazy.nvim: > • {diff_mode} (`string`, default: `"vim"`) Diff backend: "vim" or "git". Git provides character-level precision, vim uses built-in diff. • {toggle_key} (`string`, default: `"t"`) Key to toggle test panel. - • {status_format} (`string`, default: `"compact"`) Status display format. + • {next_test_key} (`string`, default: `""`) Key to navigate to next test case. + • {prev_test_key} (`string`, default: `""`) Key to navigate to previous test case. *cp.DiffConfig* Fields: ~ • {git} (`DiffGitConfig`) Git diff backend configuration. - • {vim} (`DiffVimConfig`) Vim diff backend configuration. *cp.Hooks* Fields: ~ + • {before_test}? (`function`) Called before test panel opens. + `function(ctx: ProblemContext)` • {before_debug}? (`function`) Called before debug compilation. `function(ctx: ProblemContext)` • {setup_code}? (`function`) Called after source file is opened. - Used to configure buffer settings. + Good for configuring buffer settings. `function(ctx: ProblemContext)` +*ProblemContext* + + Fields: ~ + • {contest} (`string`) Platform name (e.g. "atcoder", "codeforces") + • {contest_id} (`string`) Contest ID (e.g. "abc123", "1933") + • {problem_id}? (`string`) Problem ID (e.g. "a", "b") - nil for CSES + • {source_file} (`string`) Source filename (e.g. "abc123a.cpp") + • {binary_file} (`string`) Binary output path (e.g. "build/abc123a.run") + • {input_file} (`string`) Test input path (e.g. "io/abc123a.cpin") + • {output_file} (`string`) Program output path (e.g. "io/abc123a.cpout") + • {expected_file} (`string`) Expected output path (e.g. "io/abc123a.expected") + • {problem_name} (`string`) Display name (e.g. "abc123a") + WORKFLOW *cp-workflow* For the sake of consistency and simplicity, cp.nvim extracts contest/problem identifiers from @@ -328,8 +338,8 @@ Test cases use competitive programming terminology: Keymaps ~ *cp-test-keys* - Navigate to next test case - Navigate to previous test case + Navigate to next test case (configurable via test_panel.next_test_key) + Navigate to previous test case (configurable via test_panel.prev_test_key) q Exit test panel (restore layout) t Toggle test panel (configurable via test_panel.toggle_key) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index ca82b99..6c67c95 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -1,6 +1,6 @@ ---@class LanguageConfig ---@field compile? string[] Compile command template ----@field run string[] Run command template +---@field test string[] Test execution command template ---@field debug? string[] Debug command template ---@field executable? string Executable name ---@field version? number Language version @@ -8,7 +8,7 @@ ---@class PartialLanguageConfig ---@field compile? string[] Compile command template ----@field run? string[] Run command template +---@field test? string[] Test execution command template ---@field debug? string[] Debug command template ---@field executable? string Executable name ---@field version? number Language version @@ -27,25 +27,22 @@ ---@field timeout_ms? number ---@class Hooks ----@field before_run? fun(ctx: ProblemContext) +---@field before_test? fun(ctx: ProblemContext) ---@field before_debug? fun(ctx: ProblemContext) ---@field setup_code? fun(ctx: ProblemContext) ---@class TestPanelConfig ---@field diff_mode "vim"|"git" Diff backend to use ---@field toggle_key string Key to toggle test panel ----@field status_format "compact"|"verbose" Status display format +---@field next_test_key string Key to navigate to next test case +---@field prev_test_key string Key to navigate to previous test case ---@class DiffGitConfig ---@field command string Git executable name ---@field args string[] Additional git diff arguments ----@class DiffVimConfig ----@field enable_diffthis boolean Enable vim's diffthis - ---@class DiffConfig ---@field git DiffGitConfig ----@field vim DiffVimConfig ---@class cp.Config ---@field contests table @@ -75,7 +72,7 @@ M.defaults = { contests = {}, snippets = {}, hooks = { - before_run = nil, + before_test = nil, before_debug = nil, setup_code = nil, }, @@ -85,16 +82,14 @@ M.defaults = { test_panel = { diff_mode = 'vim', toggle_key = 't', - status_format = 'compact', + next_test_key = '', + prev_test_key = '', }, diff = { git = { command = 'git', args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' }, }, - vim = { - enable_diffthis = true, - }, }, } @@ -119,8 +114,8 @@ function M.setup(user_config) if user_config.hooks then vim.validate({ - before_run = { - user_config.hooks.before_run, + before_test = { + user_config.hooks.before_test, { 'function', 'nil' }, true, }, @@ -146,13 +141,26 @@ function M.setup(user_config) end, "diff_mode must be 'vim' or 'git'", }, - toggle_key = { user_config.test_panel.toggle_key, 'string', true }, - status_format = { - user_config.test_panel.status_format, + toggle_key = { + user_config.test_panel.toggle_key, function(value) - return vim.tbl_contains({ 'compact', 'verbose' }, value) + return type(value) == 'string' and value ~= '' end, - "status_format must be 'compact' or 'verbose'", + 'toggle_key must be a non-empty string', + }, + next_test_key = { + user_config.test_panel.next_test_key, + function(value) + return type(value) == 'string' and value ~= '' + end, + 'next_test_key must be a non-empty string', + }, + prev_test_key = { + user_config.test_panel.prev_test_key, + function(value) + return type(value) == 'string' and value ~= '' + end, + 'prev_test_key must be a non-empty string', }, }) end @@ -160,7 +168,6 @@ function M.setup(user_config) if user_config.diff then vim.validate({ git = { user_config.diff.git, { 'table', 'nil' }, true }, - vim = { user_config.diff.vim, { 'table', 'nil' }, true }, }) end diff --git a/lua/cp/execute.lua b/lua/cp/execute.lua index 1d25b87..3c7a2bf 100644 --- a/lua/cp/execute.lua +++ b/lua/cp/execute.lua @@ -278,7 +278,7 @@ function M.run_problem(ctx, contest_config, is_debug) input_data = table.concat(vim.fn.readfile(ctx.input_file), '\n') .. '\n' end - local run_cmd = build_command(language_config.run, language_config.executable, substitutions) + local run_cmd = build_command(language_config.test, language_config.executable, substitutions) local exec_result = execute_command(run_cmd, input_data, contest_config.timeout_ms) local formatted_output = format_output(exec_result, ctx.expected_file, is_debug) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index f0e4e61..0792b37 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -232,7 +232,7 @@ local function toggle_test_panel(is_debug) local test_render = require('cp.test_render') test_render.setup_highlights() local test_state = test_module.get_test_panel_state() - return test_render.render_test_list(test_state, config.test_panel) + return test_render.render_test_list(test_state) end local function update_buffer_content(bufnr, lines) @@ -305,7 +305,6 @@ local function toggle_test_panel(is_debug) end else update_buffer_content(test_buffers.actual_buf, actual_lines) - vim.api.nvim_set_option_value('diff', true, { win = test_windows.expected_win }) vim.api.nvim_set_option_value('diff', true, { win = test_windows.actual_win }) vim.api.nvim_win_call(test_windows.expected_win, function() vim.cmd.diffthis() @@ -349,10 +348,10 @@ local function toggle_test_panel(is_debug) refresh_test_panel() end - vim.keymap.set('n', '', function() + vim.keymap.set('n', config.test_panel.next_test_key, function() navigate_test_case(1) end, { buffer = test_buffers.tab_buf, silent = true }) - vim.keymap.set('n', '', function() + vim.keymap.set('n', config.test_panel.prev_test_key, function() navigate_test_case(-1) end, { buffer = test_buffers.tab_buf, silent = true }) @@ -365,6 +364,10 @@ local function toggle_test_panel(is_debug) end, { buffer = buf, silent = true }) end + if config.hooks and config.hooks.before_test then + config.hooks.before_test(ctx) + end + if is_debug and config.hooks and config.hooks.before_debug then config.hooks.before_debug(ctx) end diff --git a/lua/cp/test.lua b/lua/cp/test.lua index a1fdd85..e4719e6 100644 --- a/lua/cp/test.lua +++ b/lua/cp/test.lua @@ -172,7 +172,7 @@ local function run_single_test_case(ctx, contest_config, test_case) end end - local run_cmd = build_command(language_config.run, language_config.executable, substitutions) + local run_cmd = build_command(language_config.test, language_config.executable, substitutions) local stdin_content = test_case.input .. '\n' diff --git a/lua/cp/test_render.lua b/lua/cp/test_render.lua index 904b445..9bf92c9 100644 --- a/lua/cp/test_render.lua +++ b/lua/cp/test_render.lua @@ -1,6 +1,3 @@ ----@class TestRenderConfig ----@field status_format "compact"|"verbose" - ---@class StatusInfo ---@field text string ---@field highlight_group string @@ -32,10 +29,8 @@ end ---Render test cases list with improved layout ---@param test_state TestPanelState ----@param config? TestRenderConfig ---@return string[] -function M.render_test_list(test_state, config) - config = config or { status_format = 'compact' } +function M.render_test_list(test_state) local lines = {} for i, test_case in ipairs(test_state.test_cases) do From 56c52124ec9a1c1d564818d7e67b43ebc4b358ef Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 12:36:04 -0400 Subject: [PATCH 05/31] feat: some more tests, a checkpoint --- lua/cp/test_render.lua | 2 +- spec/config_spec.lua | 59 ++++++++++++++++++++++++++++++++++++++- spec/diff_spec.lua | 42 ++++++++++++++++++++++++++++ spec/highlight_spec.lua | 39 ++++++++++++++++++++++++++ spec/test_render_spec.lua | 30 ++++++++++++++++++++ 5 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 spec/diff_spec.lua create mode 100644 spec/highlight_spec.lua create mode 100644 spec/test_render_spec.lua diff --git a/lua/cp/test_render.lua b/lua/cp/test_render.lua index 9bf92c9..aa7acf1 100644 --- a/lua/cp/test_render.lua +++ b/lua/cp/test_render.lua @@ -13,7 +13,7 @@ local function get_status_info(test_case) elseif test_case.status == 'fail' then if test_case.timed_out then return { text = 'TLE', highlight_group = 'CpTestError' } - elseif test_case.code and test_case.code ~= 0 then + elseif test_case.code and test_case.code >= 128 then return { text = 'RTE', highlight_group = 'CpTestError' } else return { text = 'WA', highlight_group = 'CpTestError' } diff --git a/spec/config_spec.lua b/spec/config_spec.lua index 3b94ddf..a429ae5 100644 --- a/spec/config_spec.lua +++ b/spec/config_spec.lua @@ -66,13 +66,70 @@ describe('cp.config', function() it('validates hook functions', function() local invalid_config = { - hooks = { before_run = 'not_a_function' }, + hooks = { before_test = 'not_a_function' }, } assert.has_error(function() config.setup(invalid_config) end) end) + + describe('test_panel config validation', function() + it('validates diff_mode values', function() + local invalid_config = { + test_panel = { diff_mode = 'invalid' }, + } + + assert.has_error(function() + config.setup(invalid_config) + end) + end) + + it('validates toggle_key is non-empty string', function() + local invalid_config = { + test_panel = { toggle_key = '' }, + } + + assert.has_error(function() + config.setup(invalid_config) + end) + end) + + it('validates next_test_key is non-empty string', function() + local invalid_config = { + test_panel = { next_test_key = nil }, + } + + assert.has_error(function() + config.setup(invalid_config) + end) + end) + + it('validates prev_test_key is non-empty string', function() + local invalid_config = { + test_panel = { prev_test_key = '' }, + } + + assert.has_error(function() + config.setup(invalid_config) + end) + end) + + it('accepts valid test_panel config', function() + local valid_config = { + test_panel = { + diff_mode = 'git', + toggle_key = 'x', + next_test_key = 'j', + prev_test_key = 'k', + }, + } + + assert.has_no.errors(function() + config.setup(valid_config) + end) + end) + end) end) describe('default_filename', function() diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua new file mode 100644 index 0000000..bdfda29 --- /dev/null +++ b/spec/diff_spec.lua @@ -0,0 +1,42 @@ +describe('cp.diff', function() + local diff = require('cp.diff') + + describe('get_available_backends', function() + it('returns vim and git backends') + end) + + describe('get_backend', function() + it('returns vim backend by name') + it('returns git backend by name') + it('returns nil for invalid name') + end) + + describe('is_git_available', function() + it('returns true when git command succeeds') + it('returns false when git command fails') + end) + + describe('get_best_backend', function() + it('returns preferred backend when available') + it('falls back to vim when git unavailable') + it('defaults to vim backend') + end) + + describe('vim backend', function() + it('returns content as-is') + it('returns nil highlights') + end) + + describe('git backend', function() + it('creates temp files for diff') + it('returns raw diff output') + it('cleans up temp files') + it('handles no differences') + it('handles git command failure') + end) + + describe('render_diff', function() + it('uses best available backend') + it('passes parameters to backend') + end) +end) diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua new file mode 100644 index 0000000..5df7dd1 --- /dev/null +++ b/spec/highlight_spec.lua @@ -0,0 +1,39 @@ +describe('cp.highlight', function() + local highlight = require('cp.highlight') + + describe('parse_diff_line', function() + it('parses added text markers {+text+}') + it('removes removed text markers [-text-]') + it('handles mixed add/remove markers') + it('calculates correct highlight positions') + it('handles text without markers') + it('handles empty text') + end) + + describe('parse_git_diff', function() + it('skips git diff headers') + it('processes added lines') + it('ignores removed lines') + it('handles unchanged lines') + it('sets correct line numbers') + it('handles empty diff output') + end) + + describe('apply_highlights', function() + it('clears existing highlights') + it('applies extmarks with correct positions') + it('uses correct highlight groups') + it('handles empty highlights') + end) + + describe('create_namespace', function() + it('creates unique namespace') + end) + + describe('parse_and_apply_diff', function() + it('parses diff and applies to buffer') + it('sets buffer content') + it('applies highlights') + it('returns content lines') + end) +end) diff --git a/spec/test_render_spec.lua b/spec/test_render_spec.lua new file mode 100644 index 0000000..e1d5ea1 --- /dev/null +++ b/spec/test_render_spec.lua @@ -0,0 +1,30 @@ +describe('cp.test_render', function() + local test_render = require('cp.test_render') + + describe('get_status_info', function() + it('returns AC for pass status') + it('returns WA for fail status with non-zero code') + it('returns TLE for timeout status') + it('returns RTE for fail with zero code') + it('returns empty for pending status') + end) + + describe('render_test_list', function() + it('renders test cases with CP terminology') + it('shows current test with > prefix') + it('displays input only for current test') + it('handles empty test cases') + it('preserves input line breaks') + end) + + describe('render_status_bar', function() + it('formats time and exit code') + it('handles missing time') + it('handles missing exit code') + it('returns empty for nil test case') + end) + + describe('setup_highlights', function() + it('sets up all highlight groups') + end) +end) From 093782330a5b6a73a65d8d2271bb7108468a0ca8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 12:36:15 -0400 Subject: [PATCH 06/31] feat: some more tests, a checkpoint --- spec/test_render_spec.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/test_render_spec.lua b/spec/test_render_spec.lua index e1d5ea1..4398599 100644 --- a/spec/test_render_spec.lua +++ b/spec/test_render_spec.lua @@ -3,9 +3,9 @@ describe('cp.test_render', function() describe('get_status_info', function() it('returns AC for pass status') - it('returns WA for fail status with non-zero code') + it('returns WA for fail status with normal exit codes') it('returns TLE for timeout status') - it('returns RTE for fail with zero code') + it('returns RTE for fail with signal codes (>= 128)') it('returns empty for pending status') end) From 21407be376e8ea80f81d869cb36a779aeab60013 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 13:19:22 -0400 Subject: [PATCH 07/31] feat(test): rest of test suite --- spec/diff_spec.lua | 159 +++++++++++++++++++++++++++++---- spec/highlight_spec.lua | 183 +++++++++++++++++++++++++++++++++----- spec/test_render_spec.lua | 154 ++++++++++++++++++++++++++++---- 3 files changed, 439 insertions(+), 57 deletions(-) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index bdfda29..d5b7dd4 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -2,41 +2,164 @@ describe('cp.diff', function() local diff = require('cp.diff') describe('get_available_backends', function() - it('returns vim and git backends') + it('returns vim and git backends', function() + local backends = diff.get_available_backends() + assert.same({'vim', 'git'}, backends) + end) end) describe('get_backend', function() - it('returns vim backend by name') - it('returns git backend by name') - it('returns nil for invalid name') + it('returns vim backend by name', function() + local backend = diff.get_backend('vim') + assert.is_not_nil(backend) + assert.equals('vim', backend.name) + end) + + it('returns git backend by name', function() + local backend = diff.get_backend('git') + assert.is_not_nil(backend) + assert.equals('git', backend.name) + end) + + it('returns nil for invalid name', function() + local backend = diff.get_backend('invalid') + assert.is_nil(backend) + end) end) describe('is_git_available', function() - it('returns true when git command succeeds') - it('returns false when git command fails') + it('returns true when git command succeeds', function() + local mock_system = stub(vim, 'system') + mock_system.returns({ code = 0 }) + + local result = diff.is_git_available() + assert.is_true(result) + + mock_system:revert() + end) + + it('returns false when git command fails', function() + local mock_system = stub(vim, 'system') + mock_system.returns({ code = 1 }) + + local result = diff.is_git_available() + assert.is_false(result) + + mock_system:revert() + end) end) describe('get_best_backend', function() - it('returns preferred backend when available') - it('falls back to vim when git unavailable') - it('defaults to vim backend') + it('returns preferred backend when available', function() + local mock_is_available = stub(diff, 'is_git_available') + mock_is_available.returns(true) + + local backend = diff.get_best_backend('git') + assert.equals('git', backend.name) + + mock_is_available:revert() + end) + + it('falls back to vim when git unavailable', function() + local mock_is_available = stub(diff, 'is_git_available') + mock_is_available.returns(false) + + local backend = diff.get_best_backend('git') + assert.equals('vim', backend.name) + + mock_is_available:revert() + end) + + it('defaults to vim backend', function() + local backend = diff.get_best_backend() + assert.equals('vim', backend.name) + end) end) describe('vim backend', function() - it('returns content as-is') - it('returns nil highlights') + it('returns content as-is', function() + local backend = diff.get_backend('vim') + local result = backend.render('expected', 'actual') + + assert.same({'actual'}, result.content) + assert.is_nil(result.highlights) + end) end) describe('git backend', function() - it('creates temp files for diff') - it('returns raw diff output') - it('cleans up temp files') - it('handles no differences') - it('handles git command failure') + it('creates temp files for diff', function() + local mock_system = stub(vim, 'system') + local mock_tempname = stub(vim.fn, 'tempname') + local mock_writefile = stub(vim.fn, 'writefile') + local mock_delete = stub(vim.fn, 'delete') + + mock_tempname.returns('/tmp/expected', '/tmp/actual') + mock_system.returns({ code = 1, stdout = 'diff output' }) + + local backend = diff.get_backend('git') + backend.render('expected text', 'actual text') + + assert.stub(mock_writefile).was_called(2) + assert.stub(mock_delete).was_called(2) + + mock_system:revert() + mock_tempname:revert() + mock_writefile:revert() + mock_delete:revert() + end) + + it('returns raw diff output', function() + local mock_system = stub(vim, 'system') + local mock_tempname = stub(vim.fn, 'tempname') + local mock_writefile = stub(vim.fn, 'writefile') + local mock_delete = stub(vim.fn, 'delete') + + mock_tempname.returns('/tmp/expected', '/tmp/actual') + mock_system.returns({ code = 1, stdout = 'git diff output' }) + + local backend = diff.get_backend('git') + local result = backend.render('expected', 'actual') + + assert.equals('git diff output', result.raw_diff) + + mock_system:revert() + mock_tempname:revert() + mock_writefile:revert() + mock_delete:revert() + end) + + it('handles no differences', function() + local mock_system = stub(vim, 'system') + local mock_tempname = stub(vim.fn, 'tempname') + local mock_writefile = stub(vim.fn, 'writefile') + local mock_delete = stub(vim.fn, 'delete') + + mock_tempname.returns('/tmp/expected', '/tmp/actual') + mock_system.returns({ code = 0 }) + + local backend = diff.get_backend('git') + local result = backend.render('same', 'same') + + assert.same({'same'}, result.content) + assert.same({}, result.highlights) + + mock_system:revert() + mock_tempname:revert() + mock_writefile:revert() + mock_delete:revert() + end) end) describe('render_diff', function() - it('uses best available backend') - it('passes parameters to backend') + it('uses best available backend', function() + local mock_get_best = spy.on(diff, 'get_best_backend') + local mock_backend = { render = function() return {} end } + mock_get_best.returns(mock_backend) + + diff.render_diff('expected', 'actual', 'vim') + + assert.spy(mock_get_best).was_called_with('vim') + mock_get_best:revert() + end) end) end) diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 5df7dd1..ec14d43 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -1,39 +1,174 @@ describe('cp.highlight', function() local highlight = require('cp.highlight') - describe('parse_diff_line', function() - it('parses added text markers {+text+}') - it('removes removed text markers [-text-]') - it('handles mixed add/remove markers') - it('calculates correct highlight positions') - it('handles text without markers') - it('handles empty text') - end) - describe('parse_git_diff', function() - it('skips git diff headers') - it('processes added lines') - it('ignores removed lines') - it('handles unchanged lines') - it('sets correct line numbers') - it('handles empty diff output') + it('skips git diff headers', function() + local diff_output = [[ +diff --git a/test b/test +index 1234567..abcdefg 100644 +--- a/test ++++ b/test +@@ -1,3 +1,3 @@ + hello ++world +-goodbye +]] + local result = highlight.parse_git_diff(diff_output) + assert.same({'hello', 'world'}, result.content) + end) + + it('processes added lines', function() + local diff_output = '+hello w{+o+}rld' + local result = highlight.parse_git_diff(diff_output) + assert.same({'hello world'}, result.content) + assert.equals(1, #result.highlights) + assert.equals('CpDiffAdded', result.highlights[1].highlight_group) + end) + + it('ignores removed lines', function() + local diff_output = 'hello\n-removed line\n+kept line' + local result = highlight.parse_git_diff(diff_output) + assert.same({'hello', 'kept line'}, result.content) + end) + + it('handles unchanged lines', function() + local diff_output = 'unchanged line\n+added line' + local result = highlight.parse_git_diff(diff_output) + assert.same({'unchanged line', 'added line'}, result.content) + end) + + it('sets correct line numbers', function() + local diff_output = '+first {+added+}\n+second {+text+}' + local result = highlight.parse_git_diff(diff_output) + assert.equals(0, result.highlights[1].line) + assert.equals(1, result.highlights[2].line) + end) + + it('handles empty diff output', function() + local result = highlight.parse_git_diff('') + assert.same({}, result.content) + assert.same({}, result.highlights) + end) end) describe('apply_highlights', function() - it('clears existing highlights') - it('applies extmarks with correct positions') - it('uses correct highlight groups') - it('handles empty highlights') + it('clears existing highlights', function() + local mock_clear = spy.on(vim.api, 'nvim_buf_clear_namespace') + local bufnr = 1 + local namespace = 100 + + highlight.apply_highlights(bufnr, {}, namespace) + + assert.spy(mock_clear).was_called_with(bufnr, namespace, 0, -1) + mock_clear:revert() + end) + + it('applies extmarks with correct positions', function() + local mock_extmark = spy.on(vim.api, 'nvim_buf_set_extmark') + local bufnr = 1 + local namespace = 100 + local highlights = { + { + line = 0, + col_start = 5, + col_end = 10, + highlight_group = 'CpDiffAdded' + } + } + + highlight.apply_highlights(bufnr, highlights, namespace) + + assert.spy(mock_extmark).was_called_with( + bufnr, namespace, 0, 5, + { + end_col = 10, + hl_group = 'CpDiffAdded', + priority = 100 + } + ) + mock_extmark:revert() + end) + + it('uses correct highlight groups', function() + local mock_extmark = spy.on(vim.api, 'nvim_buf_set_extmark') + local highlights = { + { + line = 0, + col_start = 0, + col_end = 5, + highlight_group = 'CpDiffAdded' + } + } + + highlight.apply_highlights(1, highlights, 100) + + local call_args = mock_extmark.calls[1].vals + assert.equals('CpDiffAdded', call_args[4].hl_group) + mock_extmark:revert() + end) + + it('handles empty highlights', function() + local mock_extmark = spy.on(vim.api, 'nvim_buf_set_extmark') + + highlight.apply_highlights(1, {}, 100) + + assert.spy(mock_extmark).was_not_called() + mock_extmark:revert() + end) end) describe('create_namespace', function() - it('creates unique namespace') + it('creates unique namespace', function() + local mock_create = stub(vim.api, 'nvim_create_namespace') + mock_create.returns(42) + + local result = highlight.create_namespace() + + assert.equals(42, result) + assert.stub(mock_create).was_called_with('cp_diff_highlights') + mock_create:revert() + end) end) describe('parse_and_apply_diff', function() - it('parses diff and applies to buffer') - it('sets buffer content') - it('applies highlights') - it('returns content lines') + it('parses diff and applies to buffer', function() + local mock_set_lines = spy.on(vim.api, 'nvim_buf_set_lines') + local mock_apply = spy.on(highlight, 'apply_highlights') + local bufnr = 1 + local namespace = 100 + local diff_output = '+hello {+world+}' + + local result = highlight.parse_and_apply_diff(bufnr, diff_output, namespace) + + assert.same({'hello world'}, result) + assert.spy(mock_set_lines).was_called_with(bufnr, 0, -1, false, {'hello world'}) + assert.spy(mock_apply).was_called() + + mock_set_lines:revert() + mock_apply:revert() + end) + + it('sets buffer content', function() + local mock_set_lines = spy.on(vim.api, 'nvim_buf_set_lines') + + highlight.parse_and_apply_diff(1, '+test line', 100) + + assert.spy(mock_set_lines).was_called_with(1, 0, -1, false, {'test line'}) + mock_set_lines:revert() + end) + + it('applies highlights', function() + local mock_apply = spy.on(highlight, 'apply_highlights') + + highlight.parse_and_apply_diff(1, '+hello {+world+}', 100) + + assert.spy(mock_apply).was_called() + mock_apply:revert() + end) + + it('returns content lines', function() + local result = highlight.parse_and_apply_diff(1, '+first\n+second', 100) + assert.same({'first', 'second'}, result) + end) end) end) diff --git a/spec/test_render_spec.lua b/spec/test_render_spec.lua index 4398599..c8f7d91 100644 --- a/spec/test_render_spec.lua +++ b/spec/test_render_spec.lua @@ -2,29 +2,153 @@ describe('cp.test_render', function() local test_render = require('cp.test_render') describe('get_status_info', function() - it('returns AC for pass status') - it('returns WA for fail status with normal exit codes') - it('returns TLE for timeout status') - it('returns RTE for fail with signal codes (>= 128)') - it('returns empty for pending status') + it('returns AC for pass status', function() + local test_case = { status = 'pass' } + local result = test_render.get_status_info(test_case) + assert.equals('AC', result.text) + assert.equals('CpTestAC', result.highlight_group) + end) + + it('returns WA for fail status with normal exit codes', function() + local test_case = { status = 'fail', code = 1 } + local result = test_render.get_status_info(test_case) + assert.equals('WA', result.text) + assert.equals('CpTestError', result.highlight_group) + end) + + it('returns TLE for timeout status', function() + local test_case = { status = 'timeout' } + local result = test_render.get_status_info(test_case) + assert.equals('TLE', result.text) + assert.equals('CpTestError', result.highlight_group) + end) + + it('returns TLE for timed out fail status', function() + local test_case = { status = 'fail', timed_out = true } + local result = test_render.get_status_info(test_case) + assert.equals('TLE', result.text) + assert.equals('CpTestError', result.highlight_group) + end) + + it('returns RTE for fail with signal codes (>= 128)', function() + local test_case = { status = 'fail', code = 139 } + local result = test_render.get_status_info(test_case) + assert.equals('RTE', result.text) + assert.equals('CpTestError', result.highlight_group) + end) + + it('returns empty for pending status', function() + local test_case = { status = 'pending' } + local result = test_render.get_status_info(test_case) + assert.equals('', result.text) + assert.equals('CpTestPending', result.highlight_group) + end) + + it('returns running indicator for running status', function() + local test_case = { status = 'running' } + local result = test_render.get_status_info(test_case) + assert.equals('...', result.text) + assert.equals('CpTestPending', result.highlight_group) + end) end) describe('render_test_list', function() - it('renders test cases with CP terminology') - it('shows current test with > prefix') - it('displays input only for current test') - it('handles empty test cases') - it('preserves input line breaks') + it('renders test cases with CP terminology', function() + local test_state = { + test_cases = { + { status = 'pass', input = '5' }, + { status = 'fail', code = 1, input = '3' }, + }, + current_index = 1, + } + local result = test_render.render_test_list(test_state) + assert.equals('> 1. AC', result[1]) + assert.equals(' 2. WA', result[3]) + end) + + it('shows current test with > prefix', function() + local test_state = { + test_cases = { + { status = 'pass', input = '' }, + { status = 'pass', input = '' }, + }, + current_index = 2, + } + local result = test_render.render_test_list(test_state) + assert.equals(' 1. AC', result[1]) + assert.equals('> 2. AC', result[2]) + end) + + it('displays input only for current test', function() + local test_state = { + test_cases = { + { status = 'pass', input = '5 3' }, + { status = 'pass', input = '2 4' }, + }, + current_index = 1, + } + local result = test_render.render_test_list(test_state) + assert.equals('> 1. AC', result[1]) + assert.equals(' 5 3', result[2]) + assert.equals(' 2. AC', result[3]) + end) + + it('handles empty test cases', function() + local test_state = { test_cases = {}, current_index = 1 } + local result = test_render.render_test_list(test_state) + assert.equals(0, #result) + end) + + it('preserves input line breaks', function() + local test_state = { + test_cases = { + { status = 'pass', input = '5\n3\n1' }, + }, + current_index = 1, + } + local result = test_render.render_test_list(test_state) + assert.equals('> 1. AC', result[1]) + assert.equals(' 5', result[2]) + assert.equals(' 3', result[3]) + assert.equals(' 1', result[4]) + end) end) describe('render_status_bar', function() - it('formats time and exit code') - it('handles missing time') - it('handles missing exit code') - it('returns empty for nil test case') + it('formats time and exit code', function() + local test_case = { time_ms = 45.7, code = 0 } + local result = test_render.render_status_bar(test_case) + assert.equals('46ms │ Exit: 0', result) + end) + + it('handles missing time', function() + local test_case = { code = 0 } + local result = test_render.render_status_bar(test_case) + assert.equals('Exit: 0', result) + end) + + it('handles missing exit code', function() + local test_case = { time_ms = 123 } + local result = test_render.render_status_bar(test_case) + assert.equals('123ms', result) + end) + + it('returns empty for nil test case', function() + local result = test_render.render_status_bar(nil) + assert.equals('', result) + end) end) describe('setup_highlights', function() - it('sets up all highlight groups') + it('sets up all highlight groups', function() + local mock_set_hl = spy.on(vim.api, 'nvim_set_hl') + test_render.setup_highlights() + + assert.spy(mock_set_hl).was_called(5) + assert.spy(mock_set_hl).was_called_with(0, 'CpTestAC', { fg = '#10b981', bold = true }) + assert.spy(mock_set_hl).was_called_with(0, 'CpTestError', { fg = '#ef4444', bold = true }) + + mock_set_hl:revert() + end) end) end) From 526c82cac013bfd116fdb52c6c1ef37295c98d68 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 13:19:30 -0400 Subject: [PATCH 08/31] fix(ci): format --- spec/diff_spec.lua | 12 ++++++++---- spec/highlight_spec.lua | 37 +++++++++++++++++-------------------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index d5b7dd4..6fef325 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -4,7 +4,7 @@ describe('cp.diff', function() describe('get_available_backends', function() it('returns vim and git backends', function() local backends = diff.get_available_backends() - assert.same({'vim', 'git'}, backends) + assert.same({ 'vim', 'git' }, backends) end) end) @@ -81,7 +81,7 @@ describe('cp.diff', function() local backend = diff.get_backend('vim') local result = backend.render('expected', 'actual') - assert.same({'actual'}, result.content) + assert.same({ 'actual' }, result.content) assert.is_nil(result.highlights) end) end) @@ -140,7 +140,7 @@ describe('cp.diff', function() local backend = diff.get_backend('git') local result = backend.render('same', 'same') - assert.same({'same'}, result.content) + assert.same({ 'same' }, result.content) assert.same({}, result.highlights) mock_system:revert() @@ -153,7 +153,11 @@ describe('cp.diff', function() describe('render_diff', function() it('uses best available backend', function() local mock_get_best = spy.on(diff, 'get_best_backend') - local mock_backend = { render = function() return {} end } + local mock_backend = { + render = function() + return {} + end, + } mock_get_best.returns(mock_backend) diff.render_diff('expected', 'actual', 'vim') diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index ec14d43..84a7aa8 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -14,13 +14,13 @@ index 1234567..abcdefg 100644 -goodbye ]] local result = highlight.parse_git_diff(diff_output) - assert.same({'hello', 'world'}, result.content) + assert.same({ 'hello', 'world' }, result.content) end) it('processes added lines', function() local diff_output = '+hello w{+o+}rld' local result = highlight.parse_git_diff(diff_output) - assert.same({'hello world'}, result.content) + assert.same({ 'hello world' }, result.content) assert.equals(1, #result.highlights) assert.equals('CpDiffAdded', result.highlights[1].highlight_group) end) @@ -28,13 +28,13 @@ index 1234567..abcdefg 100644 it('ignores removed lines', function() local diff_output = 'hello\n-removed line\n+kept line' local result = highlight.parse_git_diff(diff_output) - assert.same({'hello', 'kept line'}, result.content) + assert.same({ 'hello', 'kept line' }, result.content) end) it('handles unchanged lines', function() local diff_output = 'unchanged line\n+added line' local result = highlight.parse_git_diff(diff_output) - assert.same({'unchanged line', 'added line'}, result.content) + assert.same({ 'unchanged line', 'added line' }, result.content) end) it('sets correct line numbers', function() @@ -72,20 +72,17 @@ index 1234567..abcdefg 100644 line = 0, col_start = 5, col_end = 10, - highlight_group = 'CpDiffAdded' - } + highlight_group = 'CpDiffAdded', + }, } highlight.apply_highlights(bufnr, highlights, namespace) - assert.spy(mock_extmark).was_called_with( - bufnr, namespace, 0, 5, - { - end_col = 10, - hl_group = 'CpDiffAdded', - priority = 100 - } - ) + assert.spy(mock_extmark).was_called_with(bufnr, namespace, 0, 5, { + end_col = 10, + hl_group = 'CpDiffAdded', + priority = 100, + }) mock_extmark:revert() end) @@ -96,8 +93,8 @@ index 1234567..abcdefg 100644 line = 0, col_start = 0, col_end = 5, - highlight_group = 'CpDiffAdded' - } + highlight_group = 'CpDiffAdded', + }, } highlight.apply_highlights(1, highlights, 100) @@ -140,8 +137,8 @@ index 1234567..abcdefg 100644 local result = highlight.parse_and_apply_diff(bufnr, diff_output, namespace) - assert.same({'hello world'}, result) - assert.spy(mock_set_lines).was_called_with(bufnr, 0, -1, false, {'hello world'}) + assert.same({ 'hello world' }, result) + assert.spy(mock_set_lines).was_called_with(bufnr, 0, -1, false, { 'hello world' }) assert.spy(mock_apply).was_called() mock_set_lines:revert() @@ -153,7 +150,7 @@ index 1234567..abcdefg 100644 highlight.parse_and_apply_diff(1, '+test line', 100) - assert.spy(mock_set_lines).was_called_with(1, 0, -1, false, {'test line'}) + assert.spy(mock_set_lines).was_called_with(1, 0, -1, false, { 'test line' }) mock_set_lines:revert() end) @@ -168,7 +165,7 @@ index 1234567..abcdefg 100644 it('returns content lines', function() local result = highlight.parse_and_apply_diff(1, '+first\n+second', 100) - assert.same({'first', 'second'}, result) + assert.same({ 'first', 'second' }, result) end) end) end) From fa8c663f5ed0ef93bfc3ccbf042e05fd7da46f0c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 14:01:17 -0400 Subject: [PATCH 09/31] fix(ci): selene erorrs --- lua/cp/diff.lua | 14 +++++--------- lua/cp/highlight.lua | 13 ++----------- lua/cp/init.lua | 1 - 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/lua/cp/diff.lua b/lua/cp/diff.lua index c665b78..07ea6d2 100644 --- a/lua/cp/diff.lua +++ b/lua/cp/diff.lua @@ -4,7 +4,7 @@ ---@class DiffBackend ---@field name string ----@field render fun(expected: string, actual: string, mode: string?): DiffResult +---@field render fun(expected: string, actual: string): DiffResult local M = {} @@ -12,9 +12,7 @@ local M = {} ---@type DiffBackend local vim_backend = { name = 'vim', - render = function(expected, actual, mode) - -- For vim backend, we return the content as-is since diffthis handles highlighting - local expected_lines = vim.split(expected, '\n', { plain = true, trimempty = true }) + render = function(_, actual) local actual_lines = vim.split(actual, '\n', { plain = true, trimempty = true }) return { @@ -28,7 +26,7 @@ local vim_backend = { ---@type DiffBackend local git_backend = { name = 'git', - render = function(expected, actual, mode) + render = function(expected, actual) -- Create temporary files for git diff local tmp_expected = vim.fn.tempname() local tmp_actual = vim.fn.tempname() @@ -59,7 +57,6 @@ local git_backend = { highlights = {}, } else - local highlight_module = require('cp.highlight') return { content = {}, highlights = {}, @@ -114,11 +111,10 @@ end ---@param expected string ---@param actual string ---@param backend_name? string ----@param mode? string ---@return DiffResult -function M.render_diff(expected, actual, backend_name, mode) +function M.render_diff(expected, actual, backend_name) local backend = M.get_best_backend(backend_name) - return backend.render(expected, actual, mode) + return backend.render(expected, actual) end return M diff --git a/lua/cp/highlight.lua b/lua/cp/highlight.lua index 3145a15..6695d6e 100644 --- a/lua/cp/highlight.lua +++ b/lua/cp/highlight.lua @@ -14,7 +14,6 @@ local M = {} ---@param text string Raw git diff output line ---@return string cleaned_text, DiffHighlight[] local function parse_diff_line(text) - local highlights = {} local cleaned_text = text local offset = 0 @@ -22,12 +21,7 @@ local function parse_diff_line(text) for removed_text in text:gmatch('%[%-(.-)%-%]') do local start_pos = text:find('%[%-' .. vim.pesc(removed_text) .. '%-%]', 1, false) if start_pos then - -- Remove the marker and adjust positions - local marker_len = #'[-%-%]' + #removed_text cleaned_text = cleaned_text:gsub('%[%-' .. vim.pesc(removed_text) .. '%-%]', '', 1) - - -- Since we're removing text, we don't add highlights for removed content in the actual pane - -- This is handled by showing removed content in the expected pane end end @@ -52,9 +46,8 @@ local function parse_diff_line(text) }) -- Remove the marker - local marker_len = #{ '{+' } + #{ '+}' } + #added_text final_text = final_text:gsub('{%+' .. vim.pesc(added_text) .. '%+}', added_text, 1) - offset = offset + #{ '{+' } + #{ '+}' } + offset = offset + 4 -- Length of {+ and +} end end @@ -99,9 +92,7 @@ function M.parse_git_diff(diff_output) highlight.line = line_num - 1 -- 0-based for extmarks table.insert(all_highlights, highlight) end - elseif line:match('^%-') then - -- Removed line - we handle this in the expected pane, skip for actual - elseif not line:match('^\\') then -- Skip "\ No newline" messages + elseif not line:match('^%-') and not line:match('^\\') then -- Skip removed lines and "\ No newline" messages -- Unchanged line local parsed_line, line_highlights = parse_diff_line(line) table.insert(content_lines, parsed_line) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 0792b37..50e7c25 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -192,7 +192,6 @@ local function toggle_test_panel(is_debug) local actual_buf = vim.api.nvim_create_buf(false, true) -- Set buffer options - local buffer_opts = { 'bufhidden', 'wipe' } for _, buf in ipairs({ tab_buf, expected_buf, actual_buf }) do vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = buf }) vim.api.nvim_set_option_value('readonly', true, { buf = buf }) From 1fbac30332631449ad777eacba53a8b0a0b69f1a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 14:04:37 -0400 Subject: [PATCH 10/31] fix(ci): some type errors --- lua/cp/diff.lua | 1 + vim.toml | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/lua/cp/diff.lua b/lua/cp/diff.lua index 07ea6d2..2784b4b 100644 --- a/lua/cp/diff.lua +++ b/lua/cp/diff.lua @@ -1,6 +1,7 @@ ---@class DiffResult ---@field content string[] ---@field highlights table[]? +---@field raw_diff string? ---@class DiffBackend ---@field name string diff --git a/vim.toml b/vim.toml index 9a4528d..8bf26ea 100644 --- a/vim.toml +++ b/vim.toml @@ -22,3 +22,9 @@ any = true [after_each] any = true + +[spy] +any = true + +[stub] +any = true From 259ab328a7ffe6a841b0c8ebaa7ccd87c41b1a33 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 14:08:17 -0400 Subject: [PATCH 11/31] fix(ci): use bundled table deep compares with busted --- lua/cp/test_render.lua | 4 ++-- spec/diff_spec.lua | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lua/cp/test_render.lua b/lua/cp/test_render.lua index aa7acf1..df3d9b3 100644 --- a/lua/cp/test_render.lua +++ b/lua/cp/test_render.lua @@ -7,7 +7,7 @@ local M = {} ---Convert test status to CP terminology with colors ---@param test_case TestCase ---@return StatusInfo -local function get_status_info(test_case) +function M.get_status_info(test_case) if test_case.status == 'pass' then return { text = 'AC', highlight_group = 'CpTestAC' } elseif test_case.status == 'fail' then @@ -36,7 +36,7 @@ function M.render_test_list(test_state) for i, test_case in ipairs(test_state.test_cases) do local is_current = i == test_state.current_index local prefix = is_current and '> ' or ' ' - local status_info = get_status_info(test_case) + local status_info = M.get_status_info(test_case) local status_text = status_info.text ~= '' and status_info.text or '' local line = string.format('%s%d. %s', prefix, i, status_text) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 6fef325..95097ee 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -4,7 +4,8 @@ describe('cp.diff', function() describe('get_available_backends', function() it('returns vim and git backends', function() local backends = diff.get_available_backends() - assert.same({ 'vim', 'git' }, backends) + table.sort(backends) + assert.same({ 'git', 'vim' }, backends) end) end) @@ -30,7 +31,11 @@ describe('cp.diff', function() describe('is_git_available', function() it('returns true when git command succeeds', function() local mock_system = stub(vim, 'system') - mock_system.returns({ code = 0 }) + mock_system.returns({ + wait = function() + return { code = 0 } + end, + }) local result = diff.is_git_available() assert.is_true(result) @@ -40,7 +45,11 @@ describe('cp.diff', function() it('returns false when git command fails', function() local mock_system = stub(vim, 'system') - mock_system.returns({ code = 1 }) + mock_system.returns({ + wait = function() + return { code = 1 } + end, + }) local result = diff.is_git_available() assert.is_false(result) From 34d943bd1ecafbf388a86631b9548fe700733ba5 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 14:09:51 -0400 Subject: [PATCH 12/31] fix(test): mock vim.sytsem and other calls --- spec/diff_spec.lua | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 95097ee..f8ac012 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -103,7 +103,7 @@ describe('cp.diff', function() local mock_delete = stub(vim.fn, 'delete') mock_tempname.returns('/tmp/expected', '/tmp/actual') - mock_system.returns({ code = 1, stdout = 'diff output' }) + mock_system.returns({ wait = function() return { code = 1, stdout = 'diff output' } end }) local backend = diff.get_backend('git') backend.render('expected text', 'actual text') @@ -124,7 +124,7 @@ describe('cp.diff', function() local mock_delete = stub(vim.fn, 'delete') mock_tempname.returns('/tmp/expected', '/tmp/actual') - mock_system.returns({ code = 1, stdout = 'git diff output' }) + mock_system.returns({ wait = function() return { code = 1, stdout = 'git diff output' } end }) local backend = diff.get_backend('git') local result = backend.render('expected', 'actual') @@ -144,7 +144,7 @@ describe('cp.diff', function() local mock_delete = stub(vim.fn, 'delete') mock_tempname.returns('/tmp/expected', '/tmp/actual') - mock_system.returns({ code = 0 }) + mock_system.returns({ wait = function() return { code = 0 } end }) local backend = diff.get_backend('git') local result = backend.render('same', 'same') @@ -161,17 +161,17 @@ describe('cp.diff', function() describe('render_diff', function() it('uses best available backend', function() - local mock_get_best = spy.on(diff, 'get_best_backend') local mock_backend = { render = function() return {} end, } + local mock_get_best = stub(diff, 'get_best_backend') mock_get_best.returns(mock_backend) diff.render_diff('expected', 'actual', 'vim') - assert.spy(mock_get_best).was_called_with('vim') + assert.stub(mock_get_best).was_called_with('vim') mock_get_best:revert() end) end) From 9b6df85e9e65e281e65ce20bafdd19f5afb8be02 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 14:11:16 -0400 Subject: [PATCH 13/31] fix(ci): stub vim.api calls --- lua/cp/highlight.lua | 4 ++++ spec/diff_spec.lua | 18 +++++++++++++++--- spec/highlight_spec.lua | 42 ++++++++++++++++++++++++----------------- 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/lua/cp/highlight.lua b/lua/cp/highlight.lua index 6695d6e..1aa108b 100644 --- a/lua/cp/highlight.lua +++ b/lua/cp/highlight.lua @@ -58,6 +58,10 @@ end ---@param diff_output string ---@return ParsedDiff function M.parse_git_diff(diff_output) + if diff_output == '' then + return { content = {}, highlights = {} } + end + local lines = vim.split(diff_output, '\n', { plain = true }) local content_lines = {} local all_highlights = {} diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index f8ac012..d9ac3b3 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -103,7 +103,11 @@ describe('cp.diff', function() local mock_delete = stub(vim.fn, 'delete') mock_tempname.returns('/tmp/expected', '/tmp/actual') - mock_system.returns({ wait = function() return { code = 1, stdout = 'diff output' } end }) + mock_system.returns({ + wait = function() + return { code = 1, stdout = 'diff output' } + end, + }) local backend = diff.get_backend('git') backend.render('expected text', 'actual text') @@ -124,7 +128,11 @@ describe('cp.diff', function() local mock_delete = stub(vim.fn, 'delete') mock_tempname.returns('/tmp/expected', '/tmp/actual') - mock_system.returns({ wait = function() return { code = 1, stdout = 'git diff output' } end }) + mock_system.returns({ + wait = function() + return { code = 1, stdout = 'git diff output' } + end, + }) local backend = diff.get_backend('git') local result = backend.render('expected', 'actual') @@ -144,7 +152,11 @@ describe('cp.diff', function() local mock_delete = stub(vim.fn, 'delete') mock_tempname.returns('/tmp/expected', '/tmp/actual') - mock_system.returns({ wait = function() return { code = 0 } end }) + mock_system.returns({ + wait = function() + return { code = 0 } + end, + }) local backend = diff.get_backend('git') local result = backend.render('same', 'same') diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 84a7aa8..a83abdb 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -3,16 +3,14 @@ describe('cp.highlight', function() describe('parse_git_diff', function() it('skips git diff headers', function() - local diff_output = [[ -diff --git a/test b/test + local diff_output = [[diff --git a/test b/test index 1234567..abcdefg 100644 --- a/test +++ b/test @@ -1,3 +1,3 @@ hello +world --goodbye -]] +-goodbye]] local result = highlight.parse_git_diff(diff_output) assert.same({ 'hello', 'world' }, result.content) end) @@ -64,7 +62,8 @@ index 1234567..abcdefg 100644 end) it('applies extmarks with correct positions', function() - local mock_extmark = spy.on(vim.api, 'nvim_buf_set_extmark') + local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark') + local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace') local bufnr = 1 local namespace = 100 local highlights = { @@ -78,16 +77,18 @@ index 1234567..abcdefg 100644 highlight.apply_highlights(bufnr, highlights, namespace) - assert.spy(mock_extmark).was_called_with(bufnr, namespace, 0, 5, { + assert.stub(mock_extmark).was_called_with(bufnr, namespace, 0, 5, { end_col = 10, hl_group = 'CpDiffAdded', priority = 100, }) mock_extmark:revert() + mock_clear:revert() end) it('uses correct highlight groups', function() - local mock_extmark = spy.on(vim.api, 'nvim_buf_set_extmark') + local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark') + local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace') local highlights = { { line = 0, @@ -102,15 +103,18 @@ index 1234567..abcdefg 100644 local call_args = mock_extmark.calls[1].vals assert.equals('CpDiffAdded', call_args[4].hl_group) mock_extmark:revert() + mock_clear:revert() end) it('handles empty highlights', function() - local mock_extmark = spy.on(vim.api, 'nvim_buf_set_extmark') + local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark') + local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace') highlight.apply_highlights(1, {}, 100) - assert.spy(mock_extmark).was_not_called() + assert.stub(mock_extmark).was_not_called() mock_extmark:revert() + mock_clear:revert() end) end) @@ -129,8 +133,8 @@ index 1234567..abcdefg 100644 describe('parse_and_apply_diff', function() it('parses diff and applies to buffer', function() - local mock_set_lines = spy.on(vim.api, 'nvim_buf_set_lines') - local mock_apply = spy.on(highlight, 'apply_highlights') + local mock_set_lines = stub(vim.api, 'nvim_buf_set_lines') + local mock_apply = stub(highlight, 'apply_highlights') local bufnr = 1 local namespace = 100 local diff_output = '+hello {+world+}' @@ -138,28 +142,32 @@ index 1234567..abcdefg 100644 local result = highlight.parse_and_apply_diff(bufnr, diff_output, namespace) assert.same({ 'hello world' }, result) - assert.spy(mock_set_lines).was_called_with(bufnr, 0, -1, false, { 'hello world' }) - assert.spy(mock_apply).was_called() + assert.stub(mock_set_lines).was_called_with(bufnr, 0, -1, false, { 'hello world' }) + assert.stub(mock_apply).was_called() mock_set_lines:revert() mock_apply:revert() end) it('sets buffer content', function() - local mock_set_lines = spy.on(vim.api, 'nvim_buf_set_lines') + local mock_set_lines = stub(vim.api, 'nvim_buf_set_lines') + local mock_apply = stub(highlight, 'apply_highlights') highlight.parse_and_apply_diff(1, '+test line', 100) - assert.spy(mock_set_lines).was_called_with(1, 0, -1, false, { 'test line' }) + assert.stub(mock_set_lines).was_called_with(1, 0, -1, false, { 'test line' }) mock_set_lines:revert() + mock_apply:revert() end) it('applies highlights', function() - local mock_apply = spy.on(highlight, 'apply_highlights') + local mock_set_lines = stub(vim.api, 'nvim_buf_set_lines') + local mock_apply = stub(highlight, 'apply_highlights') highlight.parse_and_apply_diff(1, '+hello {+world+}', 100) - assert.spy(mock_apply).was_called() + assert.stub(mock_apply).was_called() + mock_set_lines:revert() mock_apply:revert() end) From 1049e60736aa1f6cf67f6e6daffb95970e87058f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 14:14:12 -0400 Subject: [PATCH 14/31] fix(ci): use proper deep compare --- lua/cp/highlight.lua | 5 +++-- spec/highlight_spec.lua | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lua/cp/highlight.lua b/lua/cp/highlight.lua index 1aa108b..2a60fe0 100644 --- a/lua/cp/highlight.lua +++ b/lua/cp/highlight.lua @@ -97,8 +97,9 @@ function M.parse_git_diff(diff_output) table.insert(all_highlights, highlight) end elseif not line:match('^%-') and not line:match('^\\') then -- Skip removed lines and "\ No newline" messages - -- Unchanged line - local parsed_line, line_highlights = parse_diff_line(line) + -- Unchanged line - remove leading space if present + local clean_line = line:match('^%s') and line:sub(2) or line + local parsed_line, line_highlights = parse_diff_line(clean_line) table.insert(content_lines, parsed_line) -- Set line numbers for any highlights (shouldn't be any for unchanged lines) diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index a83abdb..d1b2ad2 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -100,8 +100,11 @@ index 1234567..abcdefg 100644 highlight.apply_highlights(1, highlights, 100) - local call_args = mock_extmark.calls[1].vals - assert.equals('CpDiffAdded', call_args[4].hl_group) + assert.stub(mock_extmark).was_called_with(1, 100, 0, 0, { + end_col = 5, + hl_group = 'CpDiffAdded', + priority = 100, + }) mock_extmark:revert() mock_clear:revert() end) From bf7fc52efccbf12b8e3d70fc4521a1a18e087a3a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 14:32:34 -0400 Subject: [PATCH 15/31] fix: table-based rendering --- lua/cp/execute.lua | 6 ++++++ lua/cp/highlight.lua | 11 ++++++++--- lua/cp/init.lua | 37 +++++++++++++++++++++++++--------- lua/cp/test_render.lua | 45 +++++++++++++++++++++++++++++++----------- 4 files changed, 75 insertions(+), 24 deletions(-) diff --git a/lua/cp/execute.lua b/lua/cp/execute.lua index 3c7a2bf..b59a954 100644 --- a/lua/cp/execute.lua +++ b/lua/cp/execute.lua @@ -284,7 +284,13 @@ function M.run_problem(ctx, contest_config, is_debug) local output_buf = vim.fn.bufnr(ctx.output_file) if output_buf ~= -1 then + local was_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = output_buf }) + local was_readonly = vim.api.nvim_get_option_value('readonly', { buf = output_buf }) + vim.api.nvim_set_option_value('readonly', false, { buf = output_buf }) + vim.api.nvim_set_option_value('modifiable', true, { buf = output_buf }) vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, vim.split(formatted_output, '\n')) + vim.api.nvim_set_option_value('modifiable', was_modifiable, { buf = output_buf }) + vim.api.nvim_set_option_value('readonly', was_readonly, { buf = output_buf }) vim.api.nvim_buf_call(output_buf, function() vim.cmd.write() end) diff --git a/lua/cp/highlight.lua b/lua/cp/highlight.lua index 2a60fe0..1f10fac 100644 --- a/lua/cp/highlight.lua +++ b/lua/cp/highlight.lua @@ -151,10 +151,15 @@ end function M.parse_and_apply_diff(bufnr, diff_output, namespace) local parsed = M.parse_git_diff(diff_output) - -- Set buffer content - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, parsed.content) + local was_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) + local was_readonly = vim.api.nvim_get_option_value('readonly', { buf = bufnr }) + + vim.api.nvim_set_option_value('readonly', false, { buf = bufnr }) + vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr }) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, parsed.content) + vim.api.nvim_set_option_value('modifiable', was_modifiable, { buf = bufnr }) + vim.api.nvim_set_option_value('readonly', was_readonly, { buf = bufnr }) - -- Apply highlights M.apply_highlights(bufnr, parsed.highlights, namespace) return parsed.content diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 50e7c25..2bd6a3e 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -227,17 +227,33 @@ local function toggle_test_panel(is_debug) local highlight = require('cp.highlight') local diff_namespace = highlight.create_namespace() - local function render_test_tabs() - local test_render = require('cp.test_render') - test_render.setup_highlights() - local test_state = test_module.get_test_panel_state() - return test_render.render_test_list(test_state) - end + local test_list_namespace = vim.api.nvim_create_namespace('cp_test_list') - local function update_buffer_content(bufnr, lines) + local function update_buffer_content(bufnr, lines, highlights) + local was_readonly = vim.api.nvim_get_option_value('readonly', { buf = bufnr }) + + vim.api.nvim_set_option_value('readonly', false, { buf = bufnr }) vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr }) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr }) + vim.api.nvim_set_option_value('readonly', was_readonly, { buf = bufnr }) + + if highlights then + vim.api.nvim_buf_clear_namespace(bufnr, test_list_namespace, 0, -1) + for _, highlight in ipairs(highlights) do + vim.api.nvim_buf_set_extmark( + bufnr, + test_list_namespace, + highlight.line, + highlight.col_start, + { + end_col = highlight.col_end, + hl_group = highlight.highlight_group, + priority = 100, + } + ) + end + end end local function update_expected_pane() @@ -324,8 +340,11 @@ local function toggle_test_panel(is_debug) return end - local tab_lines = render_test_tabs() - update_buffer_content(test_buffers.tab_buf, tab_lines) + local test_render = require('cp.test_render') + test_render.setup_highlights() + local test_state = test_module.get_test_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) update_expected_pane() update_actual_pane() diff --git a/lua/cp/test_render.lua b/lua/cp/test_render.lua index df3d9b3..1170f09 100644 --- a/lua/cp/test_render.lua +++ b/lua/cp/test_render.lua @@ -27,32 +27,53 @@ function M.get_status_info(test_case) end end ----Render test cases list with improved layout +---Render test cases as a clean table ---@param test_state TestPanelState ----@return string[] +---@return string[], table[] lines and highlight positions function M.render_test_list(test_state) local lines = {} + local highlights = {} + + local header = ' # │ Status │ Time │ Exit │ Input' + local separator = + '────┼────────┼──────┼──────┼─────────────────' + table.insert(lines, header) + table.insert(lines, separator) for i, test_case in ipairs(test_state.test_cases) do local is_current = i == test_state.current_index - local prefix = is_current and '> ' or ' ' + local prefix = is_current and '>' or ' ' local status_info = M.get_status_info(test_case) - local status_text = status_info.text ~= '' and status_info.text or '' - local line = string.format('%s%d. %s', prefix, i, status_text) + local num_col = string.format('%s%-2d', prefix, i) + local status_col = string.format(' %-6s', status_info.text) + local time_col = test_case.time_ms and string.format('%4.0fms', test_case.time_ms) or ' — ' + local exit_col = test_case.code and string.format(' %-3d', test_case.code) or ' — ' + local input_col = test_case.input and test_case.input:gsub('\n', ' ') or '' + local line = string.format( + '%s │%s │ %s │%s │ %s', + num_col, + status_col, + time_col, + exit_col, + input_col + ) table.insert(lines, line) - if is_current and test_case.input and test_case.input ~= '' then - for _, input_line in - ipairs(vim.split(test_case.input, '\n', { plain = true, trimempty = false })) - do - table.insert(lines, ' ' .. input_line) - end + if status_info.text ~= '' then + local status_start = #num_col + 3 + local status_end = status_start + #status_info.text + table.insert(highlights, { + line = #lines - 1, + col_start = status_start, + col_end = status_end, + highlight_group = status_info.highlight_group, + }) end end - return lines + return lines, highlights end ---Create status bar content for diff pane From 44f8a3cb741b076b4f69669466c3127fb06a6ea5 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 14:34:11 -0400 Subject: [PATCH 16/31] fix(test): fix test panel --- lua/cp/init.lua | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 2bd6a3e..a6a249c 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -238,21 +238,19 @@ local function toggle_test_panel(is_debug) vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr }) vim.api.nvim_set_option_value('readonly', was_readonly, { buf = bufnr }) - if highlights then - vim.api.nvim_buf_clear_namespace(bufnr, test_list_namespace, 0, -1) - for _, highlight in ipairs(highlights) do - vim.api.nvim_buf_set_extmark( - bufnr, - test_list_namespace, - highlight.line, - highlight.col_start, - { - end_col = highlight.col_end, - hl_group = highlight.highlight_group, - priority = 100, - } - ) - end + vim.api.nvim_buf_clear_namespace(bufnr, test_list_namespace, 0, -1) + for _, highlight in ipairs(highlights) do + vim.api.nvim_buf_set_extmark( + bufnr, + test_list_namespace, + highlight.line, + highlight.col_start, + { + end_col = highlight.col_end, + hl_group = highlight.highlight_group, + priority = 100, + } + ) end end @@ -267,7 +265,7 @@ local function toggle_test_panel(is_debug) local expected_text = current_test.expected local expected_lines = vim.split(expected_text, '\n', { plain = true, trimempty = true }) - update_buffer_content(test_buffers.expected_buf, expected_lines) + update_buffer_content(test_buffers.expected_buf, expected_lines, {}) local diff_backend = require('cp.diff') local backend = diff_backend.get_best_backend(config.test_panel.diff_mode) @@ -316,10 +314,10 @@ local function toggle_test_panel(is_debug) diff_namespace ) else - update_buffer_content(test_buffers.actual_buf, actual_lines) + update_buffer_content(test_buffers.actual_buf, actual_lines, {}) end else - update_buffer_content(test_buffers.actual_buf, actual_lines) + update_buffer_content(test_buffers.actual_buf, actual_lines, {}) vim.api.nvim_set_option_value('diff', true, { win = test_windows.actual_win }) vim.api.nvim_win_call(test_windows.expected_win, function() vim.cmd.diffthis() @@ -329,7 +327,7 @@ local function toggle_test_panel(is_debug) end) end else - update_buffer_content(test_buffers.actual_buf, actual_lines) + update_buffer_content(test_buffers.actual_buf, actual_lines, {}) vim.api.nvim_set_option_value('diff', false, { win = test_windows.expected_win }) vim.api.nvim_set_option_value('diff', false, { win = test_windows.actual_win }) end From ff75b975ab20856f9d52501b3c0886ade85ba17b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 14:52:20 -0400 Subject: [PATCH 17/31] feat(table): refactor table with input in the middle --- lua/cp/test_render.lua | 48 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/lua/cp/test_render.lua b/lua/cp/test_render.lua index 1170f09..abf7cd0 100644 --- a/lua/cp/test_render.lua +++ b/lua/cp/test_render.lua @@ -4,6 +4,25 @@ local M = {} +local exit_code_names = { + [128] = 'SIGHUP', + [129] = 'SIGINT', + [130] = 'SIGQUIT', + [131] = 'SIGILL', + [132] = 'SIGTRAP', + [133] = 'SIGABRT', + [134] = 'SIGBUS', + [135] = 'SIGFPE', + [136] = 'SIGKILL', + [137] = 'SIGUSR1', + [138] = 'SIGSEGV', + [139] = 'SIGUSR2', + [140] = 'SIGPIPE', + [141] = 'SIGALRM', + [142] = 'SIGTERM', + [143] = 'SIGCHLD', +} + ---Convert test status to CP terminology with colors ---@param test_case TestCase ---@return StatusInfo @@ -34,9 +53,9 @@ function M.render_test_list(test_state) local lines = {} local highlights = {} - local header = ' # │ Status │ Time │ Exit │ Input' + local header = ' # │ Status │ Time │ Exit Code' local separator = - '────┼────────┼──────┼──────┼─────────────────' + '────┼────────┼──────┼───────────' table.insert(lines, header) table.insert(lines, separator) @@ -48,16 +67,22 @@ function M.render_test_list(test_state) local num_col = string.format('%s%-2d', prefix, i) local status_col = string.format(' %-6s', status_info.text) local time_col = test_case.time_ms and string.format('%4.0fms', test_case.time_ms) or ' — ' - local exit_col = test_case.code and string.format(' %-3d', test_case.code) or ' — ' - local input_col = test_case.input and test_case.input:gsub('\n', ' ') or '' + local exit_col = ' — ' + if test_case.code then + local signal_name = exit_code_names[test_case.code] + if signal_name then + exit_col = string.format(' %d (%s)', test_case.code, signal_name) + else + exit_col = string.format(' %d', test_case.code) + end + end local line = string.format( - '%s │%s │ %s │%s │ %s', + '%s │%s │ %s │%s', num_col, status_col, time_col, - exit_col, - input_col + exit_col ) table.insert(lines, line) @@ -71,6 +96,15 @@ function M.render_test_list(test_state) highlight_group = status_info.highlight_group, }) end + + if is_current and test_case.input and test_case.input ~= '' then + for _, input_line in ipairs(vim.split(test_case.input, '\n', { plain = true, trimempty = false })) do + table.insert(lines, input_line) + end + end + + local separator = '─────────────────────────────────────────────────────────────' + table.insert(lines, separator) end return lines, highlights From 5605df8e6c4f8d85173efef1f554e6fa5d8c1613 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 14:52:25 -0400 Subject: [PATCH 18/31] fix(ci): use proper deep compare --- lua/cp/test_render.lua | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/lua/cp/test_render.lua b/lua/cp/test_render.lua index abf7cd0..a8a8d96 100644 --- a/lua/cp/test_render.lua +++ b/lua/cp/test_render.lua @@ -77,13 +77,7 @@ function M.render_test_list(test_state) end end - local line = string.format( - '%s │%s │ %s │%s', - num_col, - status_col, - time_col, - exit_col - ) + local line = string.format('%s │%s │ %s │%s', num_col, status_col, time_col, exit_col) table.insert(lines, line) if status_info.text ~= '' then @@ -98,12 +92,15 @@ function M.render_test_list(test_state) end if is_current and test_case.input and test_case.input ~= '' then - for _, input_line in ipairs(vim.split(test_case.input, '\n', { plain = true, trimempty = false })) do + for _, input_line in + ipairs(vim.split(test_case.input, '\n', { plain = true, trimempty = false })) + do table.insert(lines, input_line) end end - local separator = '─────────────────────────────────────────────────────────────' + local separator = + '─────────────────────────────────────────────────────────────' table.insert(lines, separator) end From 3c8b76207ce798c299170791ecc96fc38945ce71 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 16:00:40 -0400 Subject: [PATCH 19/31] feat(ci): pre-commit --- .github/workflows/quality.yml | 4 +- .pre-commit-config.yaml | 29 ++++++++ lua/cp/test_render.lua | 96 +++++++++++++++++++++------ pyproject.toml | 1 + uv.lock | 121 ++++++++++++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 22 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index fc2c8b5..c100808 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -44,7 +44,7 @@ jobs: - uses: JohnnyMorganz/stylua-action@v4 with: token: ${{ secrets.GITHUB_TOKEN }} - version: latest + version: 2.1.0 args: --check . lua-lint: @@ -114,4 +114,4 @@ jobs: - name: Install dependencies with mypy run: uv sync --dev - name: Type check Python files with mypy - run: uv run mypy scrapers/ tests/scrapers/ \ No newline at end of file + run: uv run mypy scrapers/ tests/scrapers/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4acb307 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +minimum_pre_commit_version: "3.5.0" +repos: + - repo: https://github.com/JohnnyMorganz/StyLua + rev: v2.1.0 + hooks: + - id: stylua-github + name: stylua (Lua formatter) + args: ["--check", "."] + files: ^(lua/|spec/|plugin/|after/|ftdetect/|.*\.lua$) + additional_dependencies: [] + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff-format + name: ruff (format) + files: ^(scrapers/|tests/scrapers/|.*\.py$) + - id: ruff + name: ruff (lint) + args: ["--no-fix"] + files: ^(scrapers/|tests/scrapers/|.*\.py$) + - repo: local + hooks: + - id: mypy + name: mypy (type check) + entry: uv run mypy + language: system + args: ["scrapers/", "tests/scrapers/"] + files: ^(scrapers/|tests/scrapers/|.*\.py$) + pass_filenames: false diff --git a/lua/cp/test_render.lua b/lua/cp/test_render.lua index a8a8d96..b0ad842 100644 --- a/lua/cp/test_render.lua +++ b/lua/cp/test_render.lua @@ -46,6 +46,49 @@ function M.get_status_info(test_case) end end +local function format_exit_code(code) + if not code then + return '—' + end + local signal_name = exit_code_names[code] + if signal_name then + return string.format('%d (%s)', code, signal_name) + else + return tostring(code) + end +end + +local function calculate_column_widths(test_state) + local widths = { num = 3, status = 6, time = 4, exit = 4 } + + for i, test_case in ipairs(test_state.test_cases) do + local prefix = i == test_state.current_index and '>' or ' ' + local num_text = string.format('%s%d', prefix, i) + widths.num = math.max(widths.num, #num_text) + + local status_info = M.get_status_info(test_case) + widths.status = math.max(widths.status, #status_info.text) + + local time_text = test_case.time_ms and string.format('%dms', test_case.time_ms) or '—' + widths.time = math.max(widths.time, #time_text) + + local exit_text = format_exit_code(test_case.code) + widths.exit = math.max(widths.exit, #exit_text) + end + + return widths +end + +local function create_separator(widths) + local parts = { + string.rep('─', widths.num), + string.rep('─', widths.status), + string.rep('─', widths.time), + string.rep('─', widths.exit), + } + return table.concat(parts, '┼') +end + ---Render test cases as a clean table ---@param test_state TestPanelState ---@return string[], table[] lines and highlight positions @@ -53,9 +96,21 @@ function M.render_test_list(test_state) local lines = {} local highlights = {} - local header = ' # │ Status │ Time │ Exit Code' - local separator = - '────┼────────┼──────┼───────────' + local widths = calculate_column_widths(test_state) + local separator = create_separator(widths) + + local header = string.format( + '%-*s│%-*s│%-*s│%-*s', + widths.num, + ' #', + widths.status, + ' Status', + widths.time, + ' Time', + widths.exit, + ' Exit Code' + ) + table.insert(lines, header) table.insert(lines, separator) @@ -64,24 +119,26 @@ function M.render_test_list(test_state) local prefix = is_current and '>' or ' ' local status_info = M.get_status_info(test_case) - local num_col = string.format('%s%-2d', prefix, i) - local status_col = string.format(' %-6s', status_info.text) - local time_col = test_case.time_ms and string.format('%4.0fms', test_case.time_ms) or ' — ' - local exit_col = ' — ' - if test_case.code then - local signal_name = exit_code_names[test_case.code] - if signal_name then - exit_col = string.format(' %d (%s)', test_case.code, signal_name) - else - exit_col = string.format(' %d', test_case.code) - end - end + local num_text = string.format('%s%d', prefix, i) + local time_text = test_case.time_ms and string.format('%dms', test_case.time_ms) or '—' + local exit_text = format_exit_code(test_case.code) - local line = string.format('%s │%s │ %s │%s', num_col, status_col, time_col, exit_col) - table.insert(lines, line) + local row = string.format( + '%-*s│ %-*s│%-*s│ %-*s', + widths.num, + num_text, + widths.status - 1, + status_info.text, + widths.time, + time_text, + widths.exit - 1, + exit_text + ) + + table.insert(lines, row) if status_info.text ~= '' then - local status_start = #num_col + 3 + local status_start = widths.num + 2 local status_end = status_start + #status_info.text table.insert(highlights, { line = #lines - 1, @@ -92,6 +149,7 @@ function M.render_test_list(test_state) end if is_current and test_case.input and test_case.input ~= '' then + table.insert(lines, separator) for _, input_line in ipairs(vim.split(test_case.input, '\n', { plain = true, trimempty = false })) do @@ -99,8 +157,6 @@ function M.render_test_list(test_state) end end - local separator = - '─────────────────────────────────────────────────────────────' table.insert(lines, separator) end diff --git a/pyproject.toml b/pyproject.toml index 289baf0..5c731b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dev = [ "types-requests>=2.32.4.20250913", "pytest>=8.0.0", "pytest-mock>=3.12.0", + "pre-commit>=4.3.0", ] [tool.pytest.ini_options] diff --git a/uv.lock b/uv.lock index bfe4d6d..744b4ae 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, ] +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.3" @@ -100,6 +109,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, +] + +[[package]] +name = "identify" +version = "2.6.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/c4/62963f25a678f6a050fb0505a65e9e726996171e6dbe1547f79619eefb15/identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a", size = 99283, upload-time = "2025-09-06T19:30:52.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e", size = 99172, upload-time = "2025-09-06T19:30:51.759Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -165,6 +201,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -183,6 +228,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -192,6 +246,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -238,6 +308,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -278,6 +383,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-mock" }, { name = "types-beautifulsoup4" }, @@ -294,6 +400,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.18.2" }, + { name = "pre-commit", specifier = ">=4.3.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-mock", specifier = ">=3.12.0" }, { name = "types-beautifulsoup4", specifier = ">=4.12.0.20250516" }, @@ -359,3 +466,17 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599 wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] + +[[package]] +name = "virtualenv" +version = "20.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, +] From 7a850ab228b968e1d916b4b7d8239d6bdd4f0aef Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 18:40:19 -0400 Subject: [PATCH 20/31] fix(test): table rendering --- lua/cp/init.lua | 6 - lua/cp/test_render.lua | 280 ++++++++++++++++++++++++++--------------- 2 files changed, 181 insertions(+), 105 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index a6a249c..81d55a0 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -295,12 +295,6 @@ local function toggle_test_panel(is_debug) actual_lines = { '(not run yet)' } end - local test_render = require('cp.test_render') - local status_bar_text = test_render.render_status_bar(current_test) - if status_bar_text ~= '' then - vim.api.nvim_set_option_value('winbar', status_bar_text, { win = test_windows.actual_win }) - end - if enable_diff then local diff_backend = require('cp.diff') local backend = diff_backend.get_best_backend(config.test_panel.diff_mode) diff --git a/lua/cp/test_render.lua b/lua/cp/test_render.lua index b0ad842..fad2836 100644 --- a/lua/cp/test_render.lua +++ b/lua/cp/test_render.lua @@ -23,7 +23,6 @@ local exit_code_names = { [143] = 'SIGCHLD', } ----Convert test status to CP terminology with colors ---@param test_case TestCase ---@return StatusInfo function M.get_status_info(test_case) @@ -51,140 +50,224 @@ local function format_exit_code(code) return '—' end local signal_name = exit_code_names[code] - if signal_name then - return string.format('%d (%s)', code, signal_name) - else - return tostring(code) - end + return signal_name and string.format('%d (%s)', code, signal_name) or tostring(code) end -local function calculate_column_widths(test_state) - local widths = { num = 3, status = 6, time = 4, exit = 4 } +-- Compute column widths + aggregates +local function compute_cols(test_state) + local w = { num = 3, status = 8, time = 6, exit = 11 } - for i, test_case in ipairs(test_state.test_cases) do - local prefix = i == test_state.current_index and '>' or ' ' - local num_text = string.format('%s%d', prefix, i) - widths.num = math.max(widths.num, #num_text) - - local status_info = M.get_status_info(test_case) - widths.status = math.max(widths.status, #status_info.text) - - local time_text = test_case.time_ms and string.format('%dms', test_case.time_ms) or '—' - widths.time = math.max(widths.time, #time_text) - - local exit_text = format_exit_code(test_case.code) - widths.exit = math.max(widths.exit, #exit_text) + for i, tc in ipairs(test_state.test_cases) do + local prefix = (i == test_state.current_index) and '>' or ' ' + w.num = math.max(w.num, #(prefix .. i)) + w.status = math.max(w.status, #(' ' .. M.get_status_info(tc).text)) + local time_str = tc.time_ms and (string.format('%.2f', tc.time_ms) .. 'ms') or '—' + w.time = math.max(w.time, #time_str) + w.exit = math.max(w.exit, #(' ' .. format_exit_code(tc.code))) end - return widths + w.num = math.max(w.num, #' #') + w.status = math.max(w.status, #' Status') + w.time = math.max(w.time, #' Time') + w.exit = math.max(w.exit, #' Exit Code') + + local sum = w.num + w.status + w.time + w.exit + local inner = sum + 3 -- three inner vertical dividers + local total = inner + 2 -- two outer borders + return { w = w, sum = sum, inner = inner, total = total } end -local function create_separator(widths) - local parts = { - string.rep('─', widths.num), - string.rep('─', widths.status), - string.rep('─', widths.time), - string.rep('─', widths.exit), - } - return table.concat(parts, '┼') +local function center(text, width) + local pad = width - #text + if pad <= 0 then + return text + end + local left = math.floor(pad / 2) + return string.rep(' ', left) .. text .. string.rep(' ', pad - left) +end + +local function top_border(c) + local w = c.w + return '┌' + .. string.rep('─', w.num) + .. '┬' + .. string.rep('─', w.status) + .. '┬' + .. string.rep('─', w.time) + .. '┬' + .. string.rep('─', w.exit) + .. '┐' +end + +local function row_sep(c) + local w = c.w + return '├' + .. string.rep('─', w.num) + .. '┼' + .. string.rep('─', w.status) + .. '┼' + .. string.rep('─', w.time) + .. '┼' + .. string.rep('─', w.exit) + .. '┤' +end + +local function bottom_border(c) + local w = c.w + return '└' + .. string.rep('─', w.num) + .. '┴' + .. string.rep('─', w.status) + .. '┴' + .. string.rep('─', w.time) + .. '┴' + .. string.rep('─', w.exit) + .. '┘' +end + +local function flat_fence_above(c) + local w = c.w + return '├' + .. string.rep('─', w.num) + .. '┴' + .. string.rep('─', w.status) + .. '┴' + .. string.rep('─', w.time) + .. '┴' + .. string.rep('─', w.exit) + .. '┤' +end + +local function flat_fence_below(c) + local w = c.w + return '├' + .. string.rep('─', w.num) + .. '┬' + .. string.rep('─', w.status) + .. '┬' + .. string.rep('─', w.time) + .. '┬' + .. string.rep('─', w.exit) + .. '┤' +end + +local function flat_bottom_border(c) + return '└' .. string.rep('─', c.inner) .. '┘' +end + +local function header_line(c) + local w = c.w + return '│' + .. center('#', w.num) + .. '│' + .. center('Status', w.status) + .. '│' + .. center('Time', w.time) + .. '│' + .. center('Exit Code', w.exit) + .. '│' +end + +local function data_row(c, idx, tc, is_current) + local w = c.w + local prefix = is_current and '>' or ' ' + local status = M.get_status_info(tc) + local time = tc.time_ms and (string.format('%.2f', tc.time_ms) .. 'ms') or '—' + local exit = format_exit_code(tc.code) + + local line = '│' + .. center(prefix .. idx, w.num) + .. '│' + .. center(status.text, w.status) + .. '│' + .. center(time, w.time) + .. '│' + .. center(exit, w.exit) + .. '│' + + local hi + if status.text ~= '' then + local pad = w.status - #status.text + local left = math.floor(pad / 2) + local status_start_col = 1 + w.num + 1 + left + local status_end_col = status_start_col + #status.text + hi = { + col_start = status_start_col, + col_end = status_end_col, + highlight_group = status.highlight_group, + } + end + + return line, hi end ----Render test cases as a clean table ---@param test_state TestPanelState ---@return string[], table[] lines and highlight positions function M.render_test_list(test_state) - local lines = {} - local highlights = {} + local lines, highlights = {}, {} + local c = compute_cols(test_state) - local widths = calculate_column_widths(test_state) - local separator = create_separator(widths) - - local header = string.format( - '%-*s│%-*s│%-*s│%-*s', - widths.num, - ' #', - widths.status, - ' Status', - widths.time, - ' Time', - widths.exit, - ' Exit Code' - ) - - table.insert(lines, header) - table.insert(lines, separator) - - for i, test_case in ipairs(test_state.test_cases) do - local is_current = i == test_state.current_index - local prefix = is_current and '>' or ' ' - local status_info = M.get_status_info(test_case) - - local num_text = string.format('%s%d', prefix, i) - local time_text = test_case.time_ms and string.format('%dms', test_case.time_ms) or '—' - local exit_text = format_exit_code(test_case.code) - - local row = string.format( - '%-*s│ %-*s│%-*s│ %-*s', - widths.num, - num_text, - widths.status - 1, - status_info.text, - widths.time, - time_text, - widths.exit - 1, - exit_text - ) + table.insert(lines, top_border(c)) + table.insert(lines, header_line(c)) + table.insert(lines, row_sep(c)) + for i, tc in ipairs(test_state.test_cases) do + local is_current = (i == test_state.current_index) + local row, hi = data_row(c, i, tc, is_current) table.insert(lines, row) - - if status_info.text ~= '' then - local status_start = widths.num + 2 - local status_end = status_start + #status_info.text - table.insert(highlights, { - line = #lines - 1, - col_start = status_start, - col_end = status_end, - highlight_group = status_info.highlight_group, - }) + if hi then + hi.line = #lines - 1 + table.insert(highlights, hi) end - if is_current and test_case.input and test_case.input ~= '' then - table.insert(lines, separator) - for _, input_line in - ipairs(vim.split(test_case.input, '\n', { plain = true, trimempty = false })) - do - table.insert(lines, input_line) + local has_next = (i < #test_state.test_cases) + local has_input = is_current and tc.input and tc.input ~= '' + + if has_input then + table.insert(lines, flat_fence_above(c)) + + for _, input_line in ipairs(vim.split(tc.input, '\n', { plain = true, trimempty = false })) do + local s = input_line or '' + if #s > c.inner then + s = string.sub(s, 1, c.inner) + end + local pad = c.inner - #s + table.insert(lines, '│' .. s .. string.rep(' ', pad) .. '│') + end + + if has_next then + table.insert(lines, flat_fence_below(c)) + else + table.insert(lines, flat_bottom_border(c)) + end + else + if has_next then + table.insert(lines, row_sep(c)) + else + table.insert(lines, bottom_border(c)) end end - - table.insert(lines, separator) end return lines, highlights end ----Create status bar content for diff pane ---@param test_case TestCase? ---@return string function M.render_status_bar(test_case) if not test_case then return '' end - local parts = {} - if test_case.time_ms then - table.insert(parts, string.format('%.0fms', test_case.time_ms)) + table.insert(parts, string.format('%.2fms', test_case.time_ms)) end - if test_case.code then table.insert(parts, string.format('Exit: %d', test_case.code)) end - return table.concat(parts, ' │ ') end ----Get highlight groups needed for test rendering ---@return table function M.get_highlight_groups() return { @@ -196,11 +279,10 @@ function M.get_highlight_groups() } end ----Setup highlight groups function M.setup_highlights() local groups = M.get_highlight_groups() - for group_name, opts in pairs(groups) do - vim.api.nvim_set_hl(0, group_name, opts) + for name, opts in pairs(groups) do + vim.api.nvim_set_hl(0, name, opts) end end From 2b9e55f07700da0bca658a593a832ad771104698 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 18:46:00 -0400 Subject: [PATCH 21/31] fix(ci): fix the tests --- lua/cp/init.lua | 18 +++++---------- selene.toml | 2 +- spec/test_render_spec.lua | 47 ++++++++++++++++++++++++++------------- 3 files changed, 38 insertions(+), 29 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 81d55a0..a27af41 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -239,18 +239,12 @@ local function toggle_test_panel(is_debug) vim.api.nvim_set_option_value('readonly', was_readonly, { buf = bufnr }) vim.api.nvim_buf_clear_namespace(bufnr, test_list_namespace, 0, -1) - for _, highlight in ipairs(highlights) do - vim.api.nvim_buf_set_extmark( - bufnr, - test_list_namespace, - highlight.line, - highlight.col_start, - { - end_col = highlight.col_end, - hl_group = highlight.highlight_group, - priority = 100, - } - ) + for _, hl in ipairs(highlights) do + 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, + priority = 100, + }) end end diff --git a/selene.toml b/selene.toml index d03ab0a..96cf5ab 100644 --- a/selene.toml +++ b/selene.toml @@ -1 +1 @@ -std = "vim" +std = 'vim' diff --git a/spec/test_render_spec.lua b/spec/test_render_spec.lua index c8f7d91..c68c95a 100644 --- a/spec/test_render_spec.lua +++ b/spec/test_render_spec.lua @@ -53,7 +53,7 @@ describe('cp.test_render', function() end) describe('render_test_list', function() - it('renders test cases with CP terminology', function() + it('renders table with headers and borders', function() local test_state = { test_cases = { { status = 'pass', input = '5' }, @@ -62,11 +62,12 @@ describe('cp.test_render', function() current_index = 1, } local result = test_render.render_test_list(test_state) - assert.equals('> 1. AC', result[1]) - assert.equals(' 2. WA', result[3]) + assert.is_true(result[1]:match('^┌')) + assert.is_true(result[2]:match('│.*#.*│.*Status.*│.*Time.*│.*Exit Code.*│')) + assert.is_true(result[3]:match('^├')) end) - it('shows current test with > prefix', function() + it('shows current test with > prefix in table', function() local test_state = { test_cases = { { status = 'pass', input = '' }, @@ -75,8 +76,14 @@ describe('cp.test_render', function() current_index = 2, } local result = test_render.render_test_list(test_state) - assert.equals(' 1. AC', result[1]) - assert.equals('> 2. AC', result[2]) + local found_current = false + for _, line in ipairs(result) do + if line:match('│.*>2.*│') then + found_current = true + break + end + end + assert.is_true(found_current) end) it('displays input only for current test', function() @@ -88,15 +95,20 @@ describe('cp.test_render', function() current_index = 1, } local result = test_render.render_test_list(test_state) - assert.equals('> 1. AC', result[1]) - assert.equals(' 5 3', result[2]) - assert.equals(' 2. AC', result[3]) + local found_input = false + for _, line in ipairs(result) do + if line:match('│5 3') then + found_input = true + break + end + end + assert.is_true(found_input) end) it('handles empty test cases', function() local test_state = { test_cases = {}, current_index = 1 } local result = test_render.render_test_list(test_state) - assert.equals(0, #result) + assert.equals(3, #result) end) it('preserves input line breaks', function() @@ -107,10 +119,13 @@ describe('cp.test_render', function() current_index = 1, } local result = test_render.render_test_list(test_state) - assert.equals('> 1. AC', result[1]) - assert.equals(' 5', result[2]) - assert.equals(' 3', result[3]) - assert.equals(' 1', result[4]) + local input_lines = {} + for _, line in ipairs(result) do + if line:match('^│[531]') then + table.insert(input_lines, line:match('│([531])')) + end + end + assert.same({ '5', '3', '1' }, input_lines) end) end) @@ -118,7 +133,7 @@ describe('cp.test_render', function() it('formats time and exit code', function() local test_case = { time_ms = 45.7, code = 0 } local result = test_render.render_status_bar(test_case) - assert.equals('46ms │ Exit: 0', result) + assert.equals('45.70ms │ Exit: 0', result) end) it('handles missing time', function() @@ -130,7 +145,7 @@ describe('cp.test_render', function() it('handles missing exit code', function() local test_case = { time_ms = 123 } local result = test_render.render_status_bar(test_case) - assert.equals('123ms', result) + assert.equals('123.00ms', result) end) it('returns empty for nil test case', function() From 8bd570b89e892e4f69cbaa3323adeff32054c459 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 18:48:06 -0400 Subject: [PATCH 22/31] fix(ci): final testh --- spec/test_render_spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/test_render_spec.lua b/spec/test_render_spec.lua index c68c95a..6e29201 100644 --- a/spec/test_render_spec.lua +++ b/spec/test_render_spec.lua @@ -62,7 +62,7 @@ describe('cp.test_render', function() current_index = 1, } local result = test_render.render_test_list(test_state) - assert.is_true(result[1]:match('^┌')) + assert.is_true(result[1]:find('^┌') ~= nil) assert.is_true(result[2]:match('│.*#.*│.*Status.*│.*Time.*│.*Exit Code.*│')) assert.is_true(result[3]:match('^├')) end) From ef3d39c7f4690c1bd52f9baa6f8e6ca852246bbd Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 18:48:10 -0400 Subject: [PATCH 23/31] fix(ci): final testh --- spec/test_render_spec.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/test_render_spec.lua b/spec/test_render_spec.lua index 6e29201..f7ace52 100644 --- a/spec/test_render_spec.lua +++ b/spec/test_render_spec.lua @@ -63,8 +63,8 @@ describe('cp.test_render', function() } local result = test_render.render_test_list(test_state) assert.is_true(result[1]:find('^┌') ~= nil) - assert.is_true(result[2]:match('│.*#.*│.*Status.*│.*Time.*│.*Exit Code.*│')) - assert.is_true(result[3]:match('^├')) + assert.is_true(result[2]:find('│.*#.*│.*Status.*│.*Time.*│.*Exit Code.*│') ~= nil) + assert.is_true(result[3]:find('^├') ~= nil) end) it('shows current test with > prefix in table', function() From dd6bf47684fb4770ad8398ce98b6894d13ef2777 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 18:53:39 -0400 Subject: [PATCH 24/31] feat: :CP test -> :CP run --- doc/cp.txt | 25 +++++++++----------- lua/cp/config.lua | 27 ++++++++-------------- lua/cp/init.lua | 43 ++++++++++++++++------------------- lua/cp/test.lua | 22 +++++++++--------- lua/cp/test_render.lua | 2 +- spec/command_parsing_spec.lua | 12 +++++----- spec/config_spec.lua | 23 +++++-------------- 7 files changed, 64 insertions(+), 90 deletions(-) diff --git a/doc/cp.txt b/doc/cp.txt index 1404d65..8229fbf 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -49,7 +49,7 @@ Setup Commands ~ Action Commands ~ -:CP test [--debug] Toggle test panel for individual test case +:CP run [--debug] Toggle run panel for individual test case debugging. Shows per-test results with redesigned layout for efficient comparison. Use --debug flag to compile with debug flags @@ -112,9 +112,8 @@ Optional configuration with lazy.nvim: > vim.diagnostic.enable(false) end, }, - test_panel = { + run_panel = { diff_mode = "vim", -- "vim" or "git" - toggle_key = "t", -- toggle test panel next_test_key = "", -- navigate to next test case prev_test_key = "", -- navigate to previous test case }, @@ -141,7 +140,7 @@ Optional configuration with lazy.nvim: > during operation. • {scrapers} (`table`) Per-platform scraper control. Default enables all platforms. - • {test_panel} (`TestPanelConfig`) Test panel behavior configuration. + • {run_panel} (`RunPanelConfig`) Test panel behavior configuration. • {diff} (`DiffConfig`) Diff backend configuration. • {filename}? (`function`) Custom filename generation function. `function(contest, contest_id, problem_id, config, language)` @@ -169,12 +168,11 @@ Optional configuration with lazy.nvim: > • {extension} (`string`) File extension (e.g. "cc", "py"). • {executable}? (`string`) Executable name for interpreted languages. -*cp.TestPanelConfig* +*cp.RunPanelConfig* Fields: ~ • {diff_mode} (`string`, default: `"vim"`) Diff backend: "vim" or "git". Git provides character-level precision, vim uses built-in diff. - • {toggle_key} (`string`, default: `"t"`) Key to toggle test panel. • {next_test_key} (`string`, default: `""`) Key to navigate to next test case. • {prev_test_key} (`string`, default: `""`) Key to navigate to previous test case. @@ -295,15 +293,15 @@ Example: Quick setup for single Codeforces problem > :CP test " Test immediately < -TEST PANEL *cp-test* +RUN PANEL *cp-run* -The test panel provides individual test case debugging with a streamlined +The run panel provides individual test case debugging with a streamlined layout optimized for modern screens. Shows test status with competitive programming terminology and efficient space usage. Activation ~ - *:CP-test* -:CP test [--debug] Toggle test panel on/off. When activated, + *:CP-run* +:CP run [--debug] Toggle run panel on/off. When activated, replaces current layout with test interface. Automatically compiles and runs all tests. Use --debug flag to compile with debug symbols @@ -312,7 +310,7 @@ Activation ~ Interface ~ -The test panel uses a redesigned two-pane layout for efficient comparison: +The run panel uses a redesigned two-pane layout for efficient comparison: (note that the diff is indeed highlighted, not the weird amalgamation of characters below) > @@ -338,10 +336,9 @@ Test cases use competitive programming terminology: Keymaps ~ *cp-test-keys* - Navigate to next test case (configurable via test_panel.next_test_key) - Navigate to previous test case (configurable via test_panel.prev_test_key) + Navigate to next test case (configurable via run_panel.next_test_key) + Navigate to previous test case (configurable via run_panel.prev_test_key) q Exit test panel (restore layout) -t Toggle test panel (configurable via test_panel.toggle_key) Diff Modes ~ diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 6c67c95..225aab7 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -31,9 +31,8 @@ ---@field before_debug? fun(ctx: ProblemContext) ---@field setup_code? fun(ctx: ProblemContext) ----@class TestPanelConfig +---@class RunPanelConfig ---@field diff_mode "vim"|"git" Diff backend to use ----@field toggle_key string Key to toggle test panel ---@field next_test_key string Key to navigate to next test case ---@field prev_test_key string Key to navigate to previous test case @@ -51,7 +50,7 @@ ---@field debug boolean ---@field scrapers table ---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string ----@field test_panel TestPanelConfig +---@field run_panel RunPanelConfig ---@field diff DiffConfig ---@class cp.UserConfig @@ -61,7 +60,7 @@ ---@field debug? boolean ---@field scrapers? table ---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string ----@field test_panel? TestPanelConfig +---@field run_panel? RunPanelConfig ---@field diff? DiffConfig local M = {} @@ -79,9 +78,8 @@ M.defaults = { debug = false, scrapers = constants.PLATFORMS, filename = nil, - test_panel = { + run_panel = { diff_mode = 'vim', - toggle_key = 't', next_test_key = '', prev_test_key = '', }, @@ -108,7 +106,7 @@ function M.setup(user_config) debug = { user_config.debug, { 'boolean', 'nil' }, true }, scrapers = { user_config.scrapers, { 'table', 'nil' }, true }, filename = { user_config.filename, { 'function', 'nil' }, true }, - test_panel = { user_config.test_panel, { 'table', 'nil' }, true }, + run_panel = { user_config.run_panel, { 'table', 'nil' }, true }, diff = { user_config.diff, { 'table', 'nil' }, true }, }) @@ -132,31 +130,24 @@ function M.setup(user_config) }) end - if user_config.test_panel then + if user_config.run_panel then vim.validate({ diff_mode = { - user_config.test_panel.diff_mode, + user_config.run_panel.diff_mode, function(value) return vim.tbl_contains({ 'vim', 'git' }, value) end, "diff_mode must be 'vim' or 'git'", }, - toggle_key = { - user_config.test_panel.toggle_key, - function(value) - return type(value) == 'string' and value ~= '' - end, - 'toggle_key must be a non-empty string', - }, next_test_key = { - user_config.test_panel.next_test_key, + user_config.run_panel.next_test_key, function(value) return type(value) == 'string' and value ~= '' end, 'next_test_key must be a non-empty string', }, prev_test_key = { - user_config.test_panel.prev_test_key, + user_config.run_panel.prev_test_key, function(value) return type(value) == 'string' and value ~= '' end, diff --git a/lua/cp/init.lua b/lua/cp/init.lua index a27af41..2e9927b 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -25,7 +25,7 @@ local state = { saved_session = nil, test_cases = nil, test_states = {}, - test_panel_active = false, + run_panel_active = false, } local constants = require('cp.constants') @@ -149,14 +149,14 @@ local function get_current_problem() return filename end -local function toggle_test_panel(is_debug) - if state.test_panel_active then +local function toggle_run_panel(is_debug) + if state.run_panel_active then if state.saved_session then vim.cmd(('source %s'):format(state.saved_session)) vim.fn.delete(state.saved_session) state.saved_session = nil end - state.test_panel_active = false + state.run_panel_active = false logger.log('test panel closed') return end @@ -249,7 +249,7 @@ local function toggle_test_panel(is_debug) end local function update_expected_pane() - local test_state = test_module.get_test_panel_state() + local test_state = test_module.get_run_panel_state() local current_test = test_state.test_cases[test_state.current_index] if not current_test then @@ -262,7 +262,7 @@ local function toggle_test_panel(is_debug) update_buffer_content(test_buffers.expected_buf, expected_lines, {}) local diff_backend = require('cp.diff') - local backend = diff_backend.get_best_backend(config.test_panel.diff_mode) + local backend = diff_backend.get_best_backend(config.run_panel.diff_mode) if backend.name == 'vim' and current_test.status == 'fail' then vim.api.nvim_set_option_value('diff', true, { win = test_windows.expected_win }) @@ -272,7 +272,7 @@ local function toggle_test_panel(is_debug) end local function update_actual_pane() - local test_state = test_module.get_test_panel_state() + local test_state = test_module.get_run_panel_state() local current_test = test_state.test_cases[test_state.current_index] if not current_test then @@ -291,7 +291,7 @@ local function toggle_test_panel(is_debug) if enable_diff then local diff_backend = require('cp.diff') - local backend = diff_backend.get_best_backend(config.test_panel.diff_mode) + local backend = diff_backend.get_best_backend(config.run_panel.diff_mode) if backend.name == 'git' then local diff_result = backend.render(current_test.expected, current_test.actual) @@ -321,14 +321,14 @@ local function toggle_test_panel(is_debug) end end - local function refresh_test_panel() + local function refresh_run_panel() if not test_buffers.tab_buf or not vim.api.nvim_buf_is_valid(test_buffers.tab_buf) then return end local test_render = require('cp.test_render') test_render.setup_highlights() - local test_state = test_module.get_test_panel_state() + 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) @@ -337,7 +337,7 @@ local function toggle_test_panel(is_debug) end local function navigate_test_case(delta) - local test_state = test_module.get_test_panel_state() + local test_state = test_module.get_run_panel_state() if #test_state.test_cases == 0 then return end @@ -349,22 +349,19 @@ local function toggle_test_panel(is_debug) test_state.current_index = 1 end - refresh_test_panel() + refresh_run_panel() end - vim.keymap.set('n', config.test_panel.next_test_key, function() + vim.keymap.set('n', config.run_panel.next_test_key, function() navigate_test_case(1) end, { buffer = test_buffers.tab_buf, silent = true }) - vim.keymap.set('n', config.test_panel.prev_test_key, function() + vim.keymap.set('n', config.run_panel.prev_test_key, function() navigate_test_case(-1) end, { buffer = test_buffers.tab_buf, silent = true }) for _, buf in pairs(test_buffers) do vim.keymap.set('n', 'q', function() - toggle_test_panel() - end, { buffer = buf, silent = true }) - vim.keymap.set('n', config.test_panel.toggle_key, function() - toggle_test_panel() + toggle_run_panel() end, { buffer = buf, silent = true }) end @@ -382,14 +379,14 @@ local function toggle_test_panel(is_debug) test_module.run_all_test_cases(ctx, contest_config) end - refresh_test_panel() + refresh_run_panel() vim.api.nvim_set_current_win(test_windows.tab_win) - state.test_panel_active = true + state.run_panel_active = true state.test_buffers = test_buffers state.test_windows = test_windows - local test_state = test_module.get_test_panel_state() + local test_state = test_module.get_run_panel_state() logger.log(string.format('test panel opened (%d test cases)', #test_state.test_cases)) end @@ -556,8 +553,8 @@ function M.handle_command(opts) end if cmd.type == 'action' then - if cmd.action == 'test' then - toggle_test_panel(cmd.debug) + if cmd.action == 'run' then + toggle_run_panel(cmd.debug) elseif cmd.action == 'next' then navigate_problem(1, cmd.language) elseif cmd.action == 'prev' then diff --git a/lua/cp/test.lua b/lua/cp/test.lua index e4719e6..329f03c 100644 --- a/lua/cp/test.lua +++ b/lua/cp/test.lua @@ -12,7 +12,7 @@ ---@field signal string? ---@field timed_out boolean? ----@class TestPanelState +---@class RunPanelState ---@field test_cases TestCase[] ---@field current_index number ---@field buffer number? @@ -24,8 +24,8 @@ local M = {} local constants = require('cp.constants') local logger = require('cp.log') ----@type TestPanelState -local test_panel_state = { +---@type RunPanelState +local run_panel_state = { test_cases = {}, current_index = 1, buffer = nil, @@ -227,8 +227,8 @@ function M.load_test_cases(ctx, state) test_cases = parse_test_cases_from_files(ctx.input_file, ctx.expected_file) end - test_panel_state.test_cases = test_cases - test_panel_state.current_index = 1 + run_panel_state.test_cases = test_cases + run_panel_state.current_index = 1 logger.log(('loaded %d test case(s)'):format(#test_cases)) return #test_cases > 0 @@ -239,7 +239,7 @@ end ---@param index number ---@return boolean function M.run_test_case(ctx, contest_config, index) - local test_case = test_panel_state.test_cases[index] + local test_case = run_panel_state.test_cases[index] if not test_case then return false end @@ -266,16 +266,16 @@ end ---@return TestCase[] function M.run_all_test_cases(ctx, contest_config) local results = {} - for i, _ in ipairs(test_panel_state.test_cases) do + for i, _ in ipairs(run_panel_state.test_cases) do M.run_test_case(ctx, contest_config, i) - table.insert(results, test_panel_state.test_cases[i]) + table.insert(results, run_panel_state.test_cases[i]) end return results end ----@return TestPanelState -function M.get_test_panel_state() - return test_panel_state +---@return RunPanelState +function M.get_run_panel_state() + return run_panel_state end return M diff --git a/lua/cp/test_render.lua b/lua/cp/test_render.lua index fad2836..9bbe699 100644 --- a/lua/cp/test_render.lua +++ b/lua/cp/test_render.lua @@ -201,7 +201,7 @@ local function data_row(c, idx, tc, is_current) return line, hi end ----@param test_state TestPanelState +---@param test_state RunPanelState ---@return string[], table[] lines and highlight positions function M.render_test_list(test_state) local lines, highlights = {}, {} diff --git a/spec/command_parsing_spec.lua b/spec/command_parsing_spec.lua index ddeaca1..b66927a 100644 --- a/spec/command_parsing_spec.lua +++ b/spec/command_parsing_spec.lua @@ -51,7 +51,7 @@ describe('cp command parsing', function() describe('action commands', function() it('handles test action without error', function() - local opts = { fargs = { 'test' } } + local opts = { fargs = { 'run' } } assert.has_no_errors(function() cp.handle_command(opts) @@ -126,7 +126,7 @@ describe('cp command parsing', function() describe('language flag parsing', function() it('logs error for --lang flag missing value', function() - local opts = { fargs = { 'test', '--lang' } } + local opts = { fargs = { 'run', '--lang' } } cp.handle_command(opts) @@ -169,7 +169,7 @@ describe('cp command parsing', function() describe('debug flag parsing', function() it('handles debug flag without error', function() - local opts = { fargs = { 'test', '--debug' } } + local opts = { fargs = { 'run', '--debug' } } assert.has_no_errors(function() cp.handle_command(opts) @@ -177,7 +177,7 @@ describe('cp command parsing', function() end) it('handles combined language and debug flags', function() - local opts = { fargs = { 'test', '--lang=cpp', '--debug' } } + local opts = { fargs = { 'run', '--lang=cpp', '--debug' } } assert.has_no_errors(function() cp.handle_command(opts) @@ -234,7 +234,7 @@ describe('cp command parsing', function() end) it('handles flag order variations', function() - local opts = { fargs = { '--debug', 'test', '--lang=python' } } + local opts = { fargs = { '--debug', 'run', '--lang=python' } } assert.has_no_errors(function() cp.handle_command(opts) @@ -242,7 +242,7 @@ describe('cp command parsing', function() end) it('handles multiple language flags', function() - local opts = { fargs = { 'test', '--lang=cpp', '--lang=python' } } + local opts = { fargs = { 'run', '--lang=cpp', '--lang=python' } } assert.has_no_errors(function() cp.handle_command(opts) diff --git a/spec/config_spec.lua b/spec/config_spec.lua index a429ae5..f05c07a 100644 --- a/spec/config_spec.lua +++ b/spec/config_spec.lua @@ -74,20 +74,10 @@ describe('cp.config', function() end) end) - describe('test_panel config validation', function() + describe('run_panel config validation', function() it('validates diff_mode values', function() local invalid_config = { - test_panel = { diff_mode = 'invalid' }, - } - - assert.has_error(function() - config.setup(invalid_config) - end) - end) - - it('validates toggle_key is non-empty string', function() - local invalid_config = { - test_panel = { toggle_key = '' }, + run_panel = { diff_mode = 'invalid' }, } assert.has_error(function() @@ -97,7 +87,7 @@ describe('cp.config', function() it('validates next_test_key is non-empty string', function() local invalid_config = { - test_panel = { next_test_key = nil }, + run_panel = { next_test_key = nil }, } assert.has_error(function() @@ -107,7 +97,7 @@ describe('cp.config', function() it('validates prev_test_key is non-empty string', function() local invalid_config = { - test_panel = { prev_test_key = '' }, + run_panel = { prev_test_key = '' }, } assert.has_error(function() @@ -115,11 +105,10 @@ describe('cp.config', function() end) end) - it('accepts valid test_panel config', function() + it('accepts valid run_panel config', function() local valid_config = { - test_panel = { + run_panel = { diff_mode = 'git', - toggle_key = 'x', next_test_key = 'j', prev_test_key = 'k', }, From 5e412e341a111c76f4332cc4c42f29ae6b5d4f2f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 18:54:43 -0400 Subject: [PATCH 25/31] fix(test): test -> run final change --- lua/cp/constants.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index e6cdc95..acabc3c 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -1,7 +1,7 @@ local M = {} M.PLATFORMS = { 'atcoder', 'codeforces', 'cses' } -M.ACTIONS = { 'test', 'next', 'prev' } +M.ACTIONS = { 'run', 'next', 'prev' } M.CPP = 'cpp' M.PYTHON = 'python' From 99340e551b0d70c7d1082ed8f4d518c72f033535 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 19:11:40 -0400 Subject: [PATCH 26/31] fix: permit lowercase snippets --- README.md | 9 -- doc/cp.txt | 60 ++++++----- lua/cp/config.lua | 9 ++ lua/cp/init.lua | 220 +++++++++++++++++++++++++++-------------- lua/cp/snippets.lua | 8 +- spec/snippets_spec.lua | 42 ++++++++ 6 files changed, 234 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 49ffb34..14b5fe9 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,3 @@ follows: - [competitest.nvim](https://github.com/xeluxee/competitest.nvim) - [assistant.nvim](https://github.com/A7Lavinraj/assistant.nvim) - -## TODO - -- general `:CP test` window improvements -- fzf/telescope integration (whichever available) -- finer-tuned problem limits (i.e. per-problem codeforces time, memory) -- notify discord members -- handle infinite output/trimming file to 500 lines (customizable) -- update barrettruth.com to post diff --git a/doc/cp.txt b/doc/cp.txt index 8229fbf..3933a70 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -67,7 +67,7 @@ CONFIGURATION *cp-config* cp.nvim works out of the box. No setup required. -Optional configuration with lazy.nvim: > +Here's an example configuration with lazy.nvim: > { 'barrett-ruth/cp.nvim', cmd = 'CP', @@ -75,7 +75,7 @@ Optional configuration with lazy.nvim: > debug = false, scrapers = { atcoder = true, - codeforces = false, -- disable codeforces scraping + codeforces = false, cses = true, }, contests = { @@ -113,9 +113,10 @@ Optional configuration with lazy.nvim: > end, }, run_panel = { - diff_mode = "vim", -- "vim" or "git" - next_test_key = "", -- navigate to next test case - prev_test_key = "", -- navigate to previous test case + diff_mode = "vim", + next_test_key = "", + prev_test_key = "", + toggle_diff_key = "t", }, diff = { git = { @@ -175,6 +176,7 @@ Optional configuration with lazy.nvim: > Git provides character-level precision, vim uses built-in diff. • {next_test_key} (`string`, default: `""`) Key to navigate to next test case. • {prev_test_key} (`string`, default: `""`) Key to navigate to previous test case. + • {toggle_diff_key} (`string`, default: `"t"`) Key to toggle diff mode between vim and git. *cp.DiffConfig* @@ -271,15 +273,19 @@ Example: Setting up and solving AtCoder contest ABC324 3. Start with problem A: > :CP a + + Or do both at once with: + :CP atcoder abc324 a + < This creates a.cc and scrapes test cases 4. Code your solution, then test: > - :CP test + :CP run < Navigate with j/k, run specific tests with - Exit test panel with q or :CP test when done + Exit test panel with q or :CP run when done 5. If needed, debug with sanitizers: > - :CP test --debug + :CP run --debug < 6. Move to next problem: > :CP next @@ -287,10 +293,6 @@ Example: Setting up and solving AtCoder contest ABC324 6. Continue solving problems with :CP next/:CP prev navigation 7. Submit solutions on AtCoder website - -Example: Quick setup for single Codeforces problem > - :CP codeforces 1933 a " One command setup - :CP test " Test immediately < RUN PANEL *cp-run* @@ -310,19 +312,21 @@ Activation ~ Interface ~ -The run panel uses a redesigned two-pane layout for efficient comparison: +The run panel uses a professional table layout with precise column alignment: (note that the diff is indeed highlighted, not the weird amalgamation of characters below) > - ┌─ Tests ─────────────────────┐ ┌─ Expected vs Actual ───────────────────────┐ - │ AC 1. 12ms │ │ 45ms │ Exit: 0 │ - │ WA > 2. 45ms │ ├────────────────────────────────────────────┤ - │ 5 3 │ │ │ - │ │ │ 4[-2-]{+3+} │ - │ AC 3. 9ms │ │ 100 │ - │ RTE 4. 0ms │ │ hello w[-o-]r{+o+}ld │ - │ │ │ │ - └─────────────────────────────┘ └────────────────────────────────────────────┘ + ┌──────┬────────┬────────┬───────────┐ ┌─ Expected vs Actual ──────────────────┐ + │ # │ Status │ Time │ Exit Code │ │ 45.70ms │ Exit: 0 │ + ├──────┼────────┼────────┼───────────┤ ├────────────────────────────────────────┤ + │ 1 │ AC │12.00ms │ 0 │ │ │ + │ >2 │ WA │45.70ms │ 1 │ │ 4[-2-]{+3+} │ + ├──────┴────────┴────────┴───────────┤ │ 100 │ + │5 3 │ │ hello w[-o-]r{+o+}ld │ + ├──────┬────────┬────────┬───────────┤ │ │ + │ 3 │ AC │ 9.00ms │ 0 │ └────────────────────────────────────────┘ + │ 4 │ RTE │ 0.00ms │139 (SIGUSR2)│ + └──────┴────────┴────────┴───────────┘ < Status Indicators ~ @@ -338,7 +342,8 @@ Keymaps ~ *cp-test-keys* Navigate to next test case (configurable via run_panel.next_test_key) Navigate to previous test case (configurable via run_panel.prev_test_key) -q Exit test panel (restore layout) +t Toggle diff mode between vim and git (configurable via run_panel.toggle_diff_key) +q Exit test panel and restore layout Diff Modes ~ @@ -365,8 +370,8 @@ cp.nvim creates the following file structure upon problem setup: build/ {problem_id}.run " Compiled binary io/ - {problem_id}.cpin " Test input - {problem_id}.cpout " Program output + {problem_id}.n.cpin " nth test input + {problem_id}.n.cpout " nth program output {problem_id}.expected " Expected output The plugin automatically manages this structure and navigation between problems @@ -377,8 +382,9 @@ SNIPPETS *cp-snippets* cp.nvim integrates with LuaSnip for automatic template expansion. Built-in snippets include basic C++ and Python templates for each contest type. -Snippet trigger names must EXACTLY match platform names ("codeforces" for -CodeForces, "cses" for CSES, etc.). +Snippet trigger names must match the following format exactly: + + cp.nvim/{platform} Custom snippets can be added via the `snippets` configuration field. diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 225aab7..2242fa2 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -35,6 +35,7 @@ ---@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 +---@field toggle_diff_key string Key to toggle diff mode ---@class DiffGitConfig ---@field command string Git executable name @@ -82,6 +83,7 @@ M.defaults = { diff_mode = 'vim', next_test_key = '', prev_test_key = '', + toggle_diff_key = 't', }, diff = { git = { @@ -153,6 +155,13 @@ function M.setup(user_config) end, 'prev_test_key must be a non-empty string', }, + toggle_diff_key = { + user_config.run_panel.toggle_diff_key, + function(value) + return type(value) == 'string' and value ~= '' + end, + 'toggle_diff_key must be a non-empty string', + }, }) end diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 2e9927b..d4a212a 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -28,6 +28,9 @@ local state = { run_panel_active = false, } +local current_diff_layout = nil +local current_mode = nil + local constants = require('cp.constants') local platforms = constants.PLATFORMS local actions = constants.ACTIONS @@ -151,6 +154,11 @@ end local function toggle_run_panel(is_debug) if state.run_panel_active then + if current_diff_layout then + current_diff_layout.cleanup() + current_diff_layout = nil + current_mode = nil + end if state.saved_session then vim.cmd(('source %s'):format(state.saved_session)) vim.fn.delete(state.saved_session) @@ -187,41 +195,15 @@ local function toggle_run_panel(is_debug) vim.cmd('silent only') - local tab_buf = vim.api.nvim_create_buf(false, true) - local expected_buf = vim.api.nvim_create_buf(false, true) - local actual_buf = vim.api.nvim_create_buf(false, true) - - -- Set buffer options - for _, buf in ipairs({ tab_buf, expected_buf, actual_buf }) do - vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = buf }) - vim.api.nvim_set_option_value('readonly', true, { buf = buf }) - vim.api.nvim_set_option_value('modifiable', false, { buf = buf }) - end - + local tab_buf = create_buffer_with_options() local main_win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(main_win, tab_buf) - vim.api.nvim_set_option_value('filetype', 'cptest', { buf = tab_buf }) - - vim.cmd.split() - vim.api.nvim_win_set_buf(0, actual_buf) - vim.api.nvim_set_option_value('filetype', 'cptest', { buf = actual_buf }) - - vim.cmd.vsplit() - vim.api.nvim_win_set_buf(0, expected_buf) - vim.api.nvim_set_option_value('filetype', 'cptest', { buf = expected_buf }) - - local expected_win = vim.fn.bufwinid(expected_buf) - local actual_win = vim.fn.bufwinid(actual_buf) local test_windows = { tab_win = main_win, - actual_win = actual_win, - expected_win = expected_win, } local test_buffers = { tab_buf = tab_buf, - expected_buf = expected_buf, - actual_buf = actual_buf, } local highlight = require('cp.highlight') @@ -248,30 +230,93 @@ local function toggle_run_panel(is_debug) end end - local function update_expected_pane() - local test_state = test_module.get_run_panel_state() - local current_test = test_state.test_cases[test_state.current_index] + local function create_buffer_with_options() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = buf }) + vim.api.nvim_set_option_value('readonly', true, { buf = buf }) + vim.api.nvim_set_option_value('modifiable', false, { buf = buf }) + vim.api.nvim_set_option_value('filetype', 'cptest', { buf = buf }) + return buf + end - if not current_test then - return - end + local function create_vim_diff_layout(parent_win, expected_content, actual_content) + local expected_buf = create_buffer_with_options() + local actual_buf = create_buffer_with_options() - local expected_text = current_test.expected - local expected_lines = vim.split(expected_text, '\n', { plain = true, trimempty = true }) + vim.api.nvim_set_current_win(parent_win) + vim.cmd.split() + local actual_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(actual_win, actual_buf) - update_buffer_content(test_buffers.expected_buf, expected_lines, {}) + vim.cmd.vsplit() + local expected_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(expected_win, expected_buf) + + 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(expected_buf, expected_lines, {}) + update_buffer_content(actual_buf, actual_lines, {}) + + vim.api.nvim_set_option_value('diff', true, { win = expected_win }) + vim.api.nvim_set_option_value('diff', true, { win = actual_win }) + vim.api.nvim_win_call(expected_win, function() + vim.cmd.diffthis() + end) + vim.api.nvim_win_call(actual_win, function() + vim.cmd.diffthis() + end) + + return { + buffers = { expected_buf, actual_buf }, + windows = { expected_win, actual_win }, + cleanup = function() + pcall(vim.api.nvim_win_close, expected_win, true) + pcall(vim.api.nvim_win_close, actual_win, true) + pcall(vim.api.nvim_buf_delete, expected_buf, { force = true }) + pcall(vim.api.nvim_buf_delete, actual_buf, { force = true }) + end, + } + end + + local function create_git_diff_layout(parent_win, expected_content, actual_content) + local diff_buf = create_buffer_with_options() + + vim.api.nvim_set_current_win(parent_win) + vim.cmd.split() + local diff_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(diff_win, diff_buf) local diff_backend = require('cp.diff') - local backend = diff_backend.get_best_backend(config.run_panel.diff_mode) + local backend = diff_backend.get_best_backend('git') + local diff_result = backend.render(expected_content, actual_content) - if backend.name == 'vim' and current_test.status == 'fail' then - vim.api.nvim_set_option_value('diff', true, { win = test_windows.expected_win }) + if diff_result.raw_diff and diff_result.raw_diff ~= '' then + highlight.parse_and_apply_diff(diff_buf, diff_result.raw_diff, diff_namespace) else - vim.api.nvim_set_option_value('diff', false, { win = test_windows.expected_win }) + local lines = vim.split(actual_content, '\n', { plain = true, trimempty = true }) + update_buffer_content(diff_buf, lines, {}) + end + + return { + buffers = { diff_buf }, + windows = { diff_win }, + cleanup = function() + pcall(vim.api.nvim_win_close, diff_win, true) + pcall(vim.api.nvim_buf_delete, diff_buf, { force = true }) + end, + } + end + + local function create_diff_layout(mode, parent_win, expected_content, actual_content) + if mode == 'git' then + return create_git_diff_layout(parent_win, expected_content, actual_content) + else + return create_vim_diff_layout(parent_win, expected_content, actual_content) end end - local function update_actual_pane() + local function update_diff_panes() local test_state = test_module.get_run_panel_state() local current_test = test_state.test_cases[test_state.current_index] @@ -279,45 +324,67 @@ local function toggle_run_panel(is_debug) return end - local actual_lines = {} - local enable_diff = false + local expected_content = current_test.expected or '' + local actual_content = current_test.actual or '(not run yet)' + local should_show_diff = current_test.status == 'fail' and current_test.actual - if current_test.actual then - actual_lines = vim.split(current_test.actual, '\n', { plain = true, trimempty = true }) - enable_diff = current_test.status == 'fail' - else - actual_lines = { '(not run yet)' } + if not should_show_diff then + expected_content = expected_content + actual_content = actual_content end - if enable_diff then - local diff_backend = require('cp.diff') - local backend = diff_backend.get_best_backend(config.run_panel.diff_mode) + local desired_mode = should_show_diff and config.run_panel.diff_mode or 'vim' + + if current_diff_layout and current_mode ~= desired_mode then + current_diff_layout.cleanup() + current_diff_layout = nil + current_mode = nil + end + + if not current_diff_layout then + current_diff_layout = + create_diff_layout(desired_mode, main_win, expected_content, actual_content) + current_mode = desired_mode + + for _, buf in ipairs(current_diff_layout.buffers) do + setup_keybindings_for_buffer(buf) + end + else + if desired_mode == 'git' then + local diff_backend = require('cp.diff') + local backend = diff_backend.get_best_backend('git') + local diff_result = backend.render(expected_content, actual_content) - if backend.name == 'git' then - local diff_result = backend.render(current_test.expected, current_test.actual) if diff_result.raw_diff and diff_result.raw_diff ~= '' then highlight.parse_and_apply_diff( - test_buffers.actual_buf, + current_diff_layout.buffers[1], diff_result.raw_diff, diff_namespace ) else - update_buffer_content(test_buffers.actual_buf, actual_lines, {}) + local lines = vim.split(actual_content, '\n', { plain = true, trimempty = true }) + update_buffer_content(current_diff_layout.buffers[1], lines, {}) end else - update_buffer_content(test_buffers.actual_buf, actual_lines, {}) - vim.api.nvim_set_option_value('diff', true, { win = test_windows.actual_win }) - vim.api.nvim_win_call(test_windows.expected_win, function() - vim.cmd.diffthis() - end) - vim.api.nvim_win_call(test_windows.actual_win, function() - vim.cmd.diffthis() - end) + 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, {}) + + if should_show_diff then + vim.api.nvim_set_option_value('diff', true, { win = current_diff_layout.windows[1] }) + vim.api.nvim_set_option_value('diff', true, { win = current_diff_layout.windows[2] }) + vim.api.nvim_win_call(current_diff_layout.windows[1], function() + vim.cmd.diffthis() + end) + vim.api.nvim_win_call(current_diff_layout.windows[2], function() + vim.cmd.diffthis() + end) + else + vim.api.nvim_set_option_value('diff', false, { win = current_diff_layout.windows[1] }) + vim.api.nvim_set_option_value('diff', false, { win = current_diff_layout.windows[2] }) + end end - else - update_buffer_content(test_buffers.actual_buf, actual_lines, {}) - vim.api.nvim_set_option_value('diff', false, { win = test_windows.expected_win }) - vim.api.nvim_set_option_value('diff', false, { win = test_windows.actual_win }) end end @@ -332,8 +399,7 @@ local function toggle_run_panel(is_debug) local tab_lines, tab_highlights = test_render.render_test_list(test_state) update_buffer_content(test_buffers.tab_buf, tab_lines, tab_highlights) - update_expected_pane() - update_actual_pane() + update_diff_panes() end local function navigate_test_case(delta) @@ -352,6 +418,16 @@ local function toggle_run_panel(is_debug) refresh_run_panel() end + local function setup_keybindings_for_buffer(buf) + vim.keymap.set('n', 'q', function() + toggle_run_panel() + end, { buffer = buf, silent = true }) + vim.keymap.set('n', config.run_panel.toggle_diff_key, function() + config.run_panel.diff_mode = config.run_panel.diff_mode == 'vim' and 'git' or 'vim' + refresh_run_panel() + end, { buffer = buf, silent = true }) + end + vim.keymap.set('n', config.run_panel.next_test_key, function() navigate_test_case(1) end, { buffer = test_buffers.tab_buf, silent = true }) @@ -359,11 +435,7 @@ local function toggle_run_panel(is_debug) navigate_test_case(-1) end, { buffer = test_buffers.tab_buf, silent = true }) - for _, buf in pairs(test_buffers) do - vim.keymap.set('n', 'q', function() - toggle_run_panel() - end, { buffer = buf, silent = true }) - end + setup_keybindings_for_buffer(test_buffers.tab_buf) if config.hooks and config.hooks.before_test then config.hooks.before_test(ctx) diff --git a/lua/cp/snippets.lua b/lua/cp/snippets.lua index b7b03ed..e0237c8 100644 --- a/lua/cp/snippets.lua +++ b/lua/cp/snippets.lua @@ -102,7 +102,7 @@ if __name__ == "__main__": local user_overrides = {} for _, snippet in ipairs(config.snippets or {}) do - user_overrides[snippet.trigger] = snippet + user_overrides[snippet.trigger:lower()] = snippet end for language, template_set in pairs(template_definitions) do @@ -110,14 +110,14 @@ if __name__ == "__main__": local filetype = constants.canonical_filetypes[language] for contest, template in pairs(template_set) do - local prefixed_trigger = ('cp.nvim/%s.%s'):format(contest, language) - if not user_overrides[prefixed_trigger] then + local prefixed_trigger = ('cp.nvim/%s.%s'):format(contest:lower(), language) + if not user_overrides[prefixed_trigger:lower()] then table.insert(snippets, s(prefixed_trigger, fmt(template, { i(1) }))) end end for trigger, snippet in pairs(user_overrides) do - local prefix_match = trigger:match('^cp%.nvim/[^.]+%.(.+)$') + local prefix_match = trigger:lower():match('^cp%.nvim/[^.]+%.(.+)$') if prefix_match == language then table.insert(snippets, snippet) end diff --git a/spec/snippets_spec.lua b/spec/snippets_spec.lua index bdddb56..861c102 100644 --- a/spec/snippets_spec.lua +++ b/spec/snippets_spec.lua @@ -211,5 +211,47 @@ describe('cp.snippets', function() assert.equals(1, codeforces_count) end) + + it('handles case-insensitive snippet triggers', function() + local mixed_case_snippet = { + trigger = 'cp.nvim/CodeForces.cpp', + body = 'mixed case template', + } + local upper_case_snippet = { + trigger = 'cp.nvim/ATCODER.cpp', + body = 'upper case template', + } + local config = { + snippets = { mixed_case_snippet, upper_case_snippet }, + } + + snippets.setup(config) + + local cpp_snippets = mock_luasnip.added.cpp or {} + + local has_mixed_case = false + local has_upper_case = false + local default_codeforces_count = 0 + local default_atcoder_count = 0 + + for _, snippet in ipairs(cpp_snippets) do + if snippet.trigger == 'cp.nvim/CodeForces.cpp' then + has_mixed_case = true + assert.equals('mixed case template', snippet.body) + elseif snippet.trigger == 'cp.nvim/ATCODER.cpp' then + has_upper_case = true + assert.equals('upper case template', snippet.body) + elseif snippet.trigger == 'cp.nvim/codeforces.cpp' then + default_codeforces_count = default_codeforces_count + 1 + elseif snippet.trigger == 'cp.nvim/atcoder.cpp' then + default_atcoder_count = default_atcoder_count + 1 + end + end + + assert.is_true(has_mixed_case) + assert.is_true(has_upper_case) + assert.equals(0, default_codeforces_count, 'Default codeforces snippet should be overridden') + assert.equals(0, default_atcoder_count, 'Default atcoder snippet should be overridden') + end) end) end) From 0b14c2bb8711242980e37415207535fb82d34a96 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 19:22:13 -0400 Subject: [PATCH 27/31] fix(ci): format --- lua/cp/highlight.lua | 80 +++++++++++++++++++++++--------------------- lua/cp/init.lua | 24 +++++++------ 2 files changed, 55 insertions(+), 49 deletions(-) diff --git a/lua/cp/highlight.lua b/lua/cp/highlight.lua index 1f10fac..8cf8bf4 100644 --- a/lua/cp/highlight.lua +++ b/lua/cp/highlight.lua @@ -14,44 +14,42 @@ local M = {} ---@param text string Raw git diff output line ---@return string cleaned_text, DiffHighlight[] local function parse_diff_line(text) - local cleaned_text = text - local offset = 0 + local result_text = '' + local highlights = {} + local pos = 1 - -- Pattern for removed text: [-removed text-] - for removed_text in text:gmatch('%[%-(.-)%-%]') do - local start_pos = text:find('%[%-' .. vim.pesc(removed_text) .. '%-%]', 1, false) - if start_pos then - cleaned_text = cleaned_text:gsub('%[%-' .. vim.pesc(removed_text) .. '%-%]', '', 1) - end - end - - -- Reset for added text parsing on the cleaned text - local final_text = cleaned_text - local final_highlights = {} - offset = 0 - - -- Pattern for added text: {+added text+} - for added_text in cleaned_text:gmatch('{%+(.-)%+}') do - local start_pos = final_text:find('{%+' .. vim.pesc(added_text) .. '%+}', 1, false) - if start_pos then - -- Calculate position after previous highlights - local highlight_start = start_pos - offset - 1 -- 0-based for extmarks - local highlight_end = highlight_start + #added_text - - table.insert(final_highlights, { - line = 0, -- Will be set by caller + while pos <= #text do + local removed_start, removed_end, removed_content = text:find('%[%-(.-)%-%]', pos) + if removed_start and removed_start == pos then + local highlight_start = #result_text + result_text = result_text .. removed_content + table.insert(highlights, { + line = 0, col_start = highlight_start, - col_end = highlight_end, - highlight_group = 'CpDiffAdded', + col_end = #result_text, + highlight_group = 'CpDiffRemoved', }) - - -- Remove the marker - final_text = final_text:gsub('{%+' .. vim.pesc(added_text) .. '%+}', added_text, 1) - offset = offset + 4 -- Length of {+ and +} + pos = removed_end + 1 + else + local added_start, added_end, added_content = text:find('{%+(.-)%+}', pos) + if added_start and added_start == pos then + local highlight_start = #result_text + result_text = result_text .. added_content + table.insert(highlights, { + line = 0, + col_start = highlight_start, + col_end = #result_text, + highlight_group = 'CpDiffAdded', + }) + pos = added_end + 1 + else + result_text = result_text .. text:sub(pos, pos) + pos = pos + 1 + end end end - return final_text, final_highlights + return result_text, highlights end ---Parse complete git diff output @@ -97,16 +95,20 @@ function M.parse_git_diff(diff_output) table.insert(all_highlights, highlight) end elseif not line:match('^%-') and not line:match('^\\') then -- Skip removed lines and "\ No newline" messages - -- Unchanged line - remove leading space if present + -- Word-diff content line or unchanged line local clean_line = line:match('^%s') and line:sub(2) or line local parsed_line, line_highlights = parse_diff_line(clean_line) - table.insert(content_lines, parsed_line) - -- Set line numbers for any highlights (shouldn't be any for unchanged lines) - local line_num = #content_lines - for _, highlight in ipairs(line_highlights) do - highlight.line = line_num - 1 -- 0-based for extmarks - table.insert(all_highlights, highlight) + -- Only add non-empty lines + if parsed_line ~= '' then + table.insert(content_lines, parsed_line) + + -- Set line numbers for highlights + local line_num = #content_lines + for _, highlight in ipairs(line_highlights) do + highlight.line = line_num - 1 -- 0-based for extmarks + table.insert(all_highlights, highlight) + end end end end diff --git a/lua/cp/init.lua b/lua/cp/init.lua index d4a212a..014bb76 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -152,6 +152,19 @@ local function get_current_problem() return filename end +local function create_buffer_with_options(filetype) + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = buf }) + vim.api.nvim_set_option_value('readonly', true, { buf = buf }) + vim.api.nvim_set_option_value('modifiable', false, { buf = buf }) + if filetype then + vim.api.nvim_set_option_value('filetype', filetype, { buf = buf }) + end + return buf +end + +local setup_keybindings_for_buffer + local function toggle_run_panel(is_debug) if state.run_panel_active then if current_diff_layout then @@ -230,15 +243,6 @@ local function toggle_run_panel(is_debug) end end - local function create_buffer_with_options() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = buf }) - vim.api.nvim_set_option_value('readonly', true, { buf = buf }) - vim.api.nvim_set_option_value('modifiable', false, { buf = buf }) - vim.api.nvim_set_option_value('filetype', 'cptest', { buf = buf }) - return buf - end - local function create_vim_diff_layout(parent_win, expected_content, actual_content) local expected_buf = create_buffer_with_options() local actual_buf = create_buffer_with_options() @@ -418,7 +422,7 @@ local function toggle_run_panel(is_debug) refresh_run_panel() end - local function setup_keybindings_for_buffer(buf) + setup_keybindings_for_buffer = function(buf) vim.keymap.set('n', 'q', function() toggle_run_panel() end, { buffer = buf, silent = true }) From 5f1e6dff9c7b3673f02d73eafaa13bc5ed79b7a2 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 19:24:34 -0400 Subject: [PATCH 28/31] fix: set file types --- lua/cp/init.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 014bb76..f0e3a63 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -211,6 +211,7 @@ local function toggle_run_panel(is_debug) local tab_buf = create_buffer_with_options() local main_win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(main_win, tab_buf) + vim.api.nvim_set_option_value('filetype', 'cptest', { buf = tab_buf }) local test_windows = { tab_win = main_win, @@ -256,6 +257,9 @@ local function toggle_run_panel(is_debug) local expected_win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(expected_win, expected_buf) + vim.api.nvim_set_option_value('filetype', 'cptest', { buf = expected_buf }) + vim.api.nvim_set_option_value('filetype', 'cptest', { buf = actual_buf }) + local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true }) local actual_lines = vim.split(actual_content, '\n', { plain = true, trimempty = true }) @@ -291,6 +295,8 @@ local function toggle_run_panel(is_debug) local diff_win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(diff_win, diff_buf) + vim.api.nvim_set_option_value('filetype', 'cptest', { buf = diff_buf }) + local diff_backend = require('cp.diff') local backend = diff_backend.get_best_backend('git') local diff_result = backend.render(expected_content, actual_content) From 97c7161c2e0793ef7ddcd7f48b26c35f1ee33635 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 19:25:11 -0400 Subject: [PATCH 29/31] fix: set keybinds for every buffer --- lua/cp/init.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index f0e3a63..c91815a 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -346,9 +346,21 @@ local function toggle_run_panel(is_debug) local desired_mode = should_show_diff and config.run_panel.diff_mode or 'vim' if current_diff_layout and current_mode ~= desired_mode then + local saved_pos = vim.api.nvim_win_get_cursor(0) current_diff_layout.cleanup() current_diff_layout = nil current_mode = nil + + current_diff_layout = + create_diff_layout(desired_mode, main_win, expected_content, actual_content) + current_mode = desired_mode + + for _, buf in ipairs(current_diff_layout.buffers) do + setup_keybindings_for_buffer(buf) + end + + pcall(vim.api.nvim_win_set_cursor, 0, saved_pos) + return end if not current_diff_layout then From b5b37074fb7c773b3f6305d09d030c74da849eb5 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 19:33:28 -0400 Subject: [PATCH 30/31] fix(ci): validate config after merge --- doc/cp.txt | 4 +- lua/cp/config.lua | 117 +++++++++++++++++++++---------------------- lua/cp/init.lua | 4 +- spec/config_spec.lua | 2 +- 4 files changed, 61 insertions(+), 66 deletions(-) diff --git a/doc/cp.txt b/doc/cp.txt index 3933a70..64f4609 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -104,7 +104,7 @@ Here's an example configuration with lazy.nvim: > }, }, hooks = { - before_test = function(ctx) vim.cmd.w() end, + before_run = function(ctx) vim.cmd.w() end, before_debug = function(ctx) ... end, setup_code = function(ctx) vim.wo.foldmethod = "marker" @@ -186,7 +186,7 @@ Here's an example configuration with lazy.nvim: > *cp.Hooks* Fields: ~ - • {before_test}? (`function`) Called before test panel opens. + • {before_run}? (`function`) Called before test panel opens. `function(ctx: ProblemContext)` • {before_debug}? (`function`) Called before debug compilation. `function(ctx: ProblemContext)` diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 2242fa2..d92c267 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -27,7 +27,7 @@ ---@field timeout_ms? number ---@class Hooks ----@field before_test? fun(ctx: ProblemContext) +---@field before_run? fun(ctx: ProblemContext) ---@field before_debug? fun(ctx: ProblemContext) ---@field setup_code? fun(ctx: ProblemContext) @@ -72,7 +72,7 @@ M.defaults = { contests = {}, snippets = {}, hooks = { - before_test = nil, + before_run = nil, before_debug = nil, setup_code = nil, }, @@ -112,65 +112,6 @@ function M.setup(user_config) diff = { user_config.diff, { 'table', 'nil' }, true }, }) - if user_config.hooks then - vim.validate({ - before_test = { - user_config.hooks.before_test, - { 'function', 'nil' }, - true, - }, - before_debug = { - user_config.hooks.before_debug, - { 'function', 'nil' }, - true, - }, - setup_code = { - user_config.hooks.setup_code, - { 'function', 'nil' }, - true, - }, - }) - end - - if user_config.run_panel then - vim.validate({ - diff_mode = { - user_config.run_panel.diff_mode, - function(value) - return vim.tbl_contains({ 'vim', 'git' }, value) - end, - "diff_mode must be 'vim' or 'git'", - }, - next_test_key = { - user_config.run_panel.next_test_key, - function(value) - return type(value) == 'string' and value ~= '' - end, - 'next_test_key must be a non-empty string', - }, - prev_test_key = { - user_config.run_panel.prev_test_key, - function(value) - return type(value) == 'string' and value ~= '' - end, - 'prev_test_key must be a non-empty string', - }, - toggle_diff_key = { - user_config.run_panel.toggle_diff_key, - function(value) - return type(value) == 'string' and value ~= '' - end, - 'toggle_diff_key must be a non-empty string', - }, - }) - end - - if user_config.diff then - vim.validate({ - git = { user_config.diff.git, { 'table', 'nil' }, true }, - }) - end - if user_config.contests then for contest_name, contest_config in pairs(user_config.contests) do for lang_name, lang_config in pairs(contest_config) do @@ -214,6 +155,60 @@ function M.setup(user_config) local config = vim.tbl_deep_extend('force', M.defaults, user_config or {}) + -- Validate merged config values + vim.validate({ + before_run = { + config.hooks.before_run, + { 'function', 'nil' }, + true, + }, + before_debug = { + config.hooks.before_debug, + { 'function', 'nil' }, + true, + }, + setup_code = { + config.hooks.setup_code, + { 'function', 'nil' }, + true, + }, + }) + + vim.validate({ + diff_mode = { + config.run_panel.diff_mode, + function(value) + return vim.tbl_contains({ 'vim', 'git' }, value) + end, + "diff_mode must be 'vim' or 'git'", + }, + next_test_key = { + config.run_panel.next_test_key, + function(value) + return type(value) == 'string' and value ~= '' + end, + 'next_test_key must be a non-empty string', + }, + prev_test_key = { + config.run_panel.prev_test_key, + function(value) + return type(value) == 'string' and value ~= '' + end, + 'prev_test_key must be a non-empty string', + }, + toggle_diff_key = { + config.run_panel.toggle_diff_key, + function(value) + return type(value) == 'string' and value ~= '' + end, + 'toggle_diff_key must be a non-empty string', + }, + }) + + vim.validate({ + git = { config.diff.git, { 'table', 'nil' }, true }, + }) + for _, contest_config in pairs(config.contests) do for lang_name, lang_config in pairs(contest_config) do if type(lang_config) == 'table' and not lang_config.extension then diff --git a/lua/cp/init.lua b/lua/cp/init.lua index c91815a..54b75e4 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -459,8 +459,8 @@ local function toggle_run_panel(is_debug) setup_keybindings_for_buffer(test_buffers.tab_buf) - if config.hooks and config.hooks.before_test then - config.hooks.before_test(ctx) + if config.hooks and config.hooks.before_run then + config.hooks.before_run(ctx) end if is_debug and config.hooks and config.hooks.before_debug then diff --git a/spec/config_spec.lua b/spec/config_spec.lua index f05c07a..cd5b058 100644 --- a/spec/config_spec.lua +++ b/spec/config_spec.lua @@ -66,7 +66,7 @@ describe('cp.config', function() it('validates hook functions', function() local invalid_config = { - hooks = { before_test = 'not_a_function' }, + hooks = { before_run = 'not_a_function' }, } assert.has_error(function() From c7338d01d8b5acc7974002bc950cd04005948db2 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 19:35:35 -0400 Subject: [PATCH 31/31] fix: proper config values --- spec/config_spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/config_spec.lua b/spec/config_spec.lua index cd5b058..5b41617 100644 --- a/spec/config_spec.lua +++ b/spec/config_spec.lua @@ -87,7 +87,7 @@ describe('cp.config', function() it('validates next_test_key is non-empty string', function() local invalid_config = { - run_panel = { next_test_key = nil }, + run_panel = { next_test_key = '' }, } assert.has_error(function()