feat(test): test panel

This commit is contained in:
Barrett Ruth 2025-09-19 12:07:29 -04:00
parent 8ae7fff12b
commit 289e6efe62
5 changed files with 504 additions and 65 deletions

View file

@ -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<string, ContestConfig>
---@field snippets table[]
@ -38,6 +54,8 @@
---@field debug boolean
---@field scrapers table<string, boolean>
---@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<string, PartialContestConfig>
@ -46,6 +64,8 @@
---@field debug? boolean
---@field scrapers? table<string, boolean>
---@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

150
lua/cp/diff.lua Normal file
View file

@ -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<string, DiffBackend>
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

164
lua/cp/highlight.lua Normal file
View file

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

View file

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

102
lua/cp/test_render.lua Normal file
View file

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