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