From d40d80c541fa86691687e48ce2d53710cdd8d42d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 12:22:53 -0500 Subject: [PATCH 01/38] fix: race condition & logs --- lua/cp/setup.lua | 2 ++ lua/cp/state.lua | 1 + lua/cp/ui/views.lua | 40 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index d05b417..56519e4 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -348,6 +348,8 @@ function M.navigate_problem(direction, language) return end + logger.log(('navigate_problem: %s -> %s'):format(current_problem_id, problems[new_index].id)) + local active_panel = state.get_active_panel() if active_panel == 'run' then require('cp.ui.views').disable() diff --git a/lua/cp/state.lua b/lua/cp/state.lua index 40eed86..6d99cbf 100644 --- a/lua/cp/state.lua +++ b/lua/cp/state.lua @@ -10,6 +10,7 @@ ---@field output_buf integer ---@field input_buf integer ---@field current_test_index integer? +---@field source_buf integer? ---@class cp.State ---@field get_platform fun(): string? diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index 4609da8..3b40ef3 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -194,14 +194,27 @@ end ---@return integer, integer local function get_or_create_io_buffers() local io_state = state.get_io_view_state() + local solution_win = state.get_solution_win() + local current_source_buf = vim.api.nvim_win_get_buf(solution_win) if io_state then local output_valid = io_state.output_buf and vim.api.nvim_buf_is_valid(io_state.output_buf) local input_valid = io_state.input_buf and vim.api.nvim_buf_is_valid(io_state.input_buf) + local same_source = io_state.source_buf == current_source_buf - if output_valid and input_valid then + if output_valid and input_valid and same_source then return io_state.output_buf, io_state.input_buf end + + if io_state.source_buf then + pcall(vim.api.nvim_del_augroup_by_name, 'cp_io_cleanup_buf' .. io_state.source_buf) + end + if output_valid then + pcall(vim.api.nvim_buf_delete, io_state.output_buf, { force = true }) + end + if input_valid then + pcall(vim.api.nvim_buf_delete, io_state.input_buf, { force = true }) + end end local output_buf = utils.create_buffer_with_options('cpout') @@ -211,10 +224,10 @@ local function get_or_create_io_buffers() output_buf = output_buf, input_buf = input_buf, current_test_index = 1, + source_buf = current_source_buf, }) - local solution_win = state.get_solution_win() - local source_buf = vim.api.nvim_win_get_buf(solution_win) + local source_buf = current_source_buf local group_name = 'cp_io_cleanup_buf' .. source_buf vim.api.nvim_create_augroup(group_name, { clear = true }) @@ -242,23 +255,34 @@ local function get_or_create_io_buffers() vim.api.nvim_create_autocmd({ 'BufWinEnter', 'BufWinLeave' }, { group = group_name, buffer = source_buf, - callback = function() + callback = function(ev) + logger.log(('autocmd %s fired for source_buf=%s'):format(ev.event, source_buf)) vim.schedule(function() local io = state.get_io_view_state() if not io then + logger.log('autocmd scheduled: no io_state, returning') + return + end + + if io.source_buf ~= source_buf then + logger.log(('autocmd scheduled: source_buf mismatch (autocmd=%s, io_state=%s), returning'):format( + source_buf, io.source_buf)) return end local wins = vim.api.nvim_list_wins() for _, win in ipairs(wins) do if vim.api.nvim_win_get_buf(win) == source_buf then + logger.log(('autocmd scheduled: source_buf=%s visible in win=%s, returning'):format(source_buf, win)) return end end + logger.log(('autocmd scheduled: source_buf=%s NOT visible, closing io windows'):format(source_buf)) for _, win in ipairs(wins) do local buf = vim.api.nvim_win_get_buf(win) if buf == io.output_buf or buf == io.input_buf then + logger.log(('autocmd scheduled: closing win=%s with buf=%s'):format(win, buf)) if #vim.api.nvim_list_wins() > 1 then pcall(vim.api.nvim_win_close, win, true) else @@ -358,6 +382,7 @@ local function create_window_layout(output_buf, input_buf) end function M.ensure_io_view() + logger.log('ensure_io_view: starting') local platform, contest_id, problem_id = state.get_platform(), state.get_contest_id(), state.get_problem_id() if not platform or not contest_id or not problem_id then @@ -369,6 +394,7 @@ function M.ensure_io_view() end local source_file = state.get_source_file() + logger.log(('ensure_io_view: source_file=%s'):format(source_file or 'nil')) if source_file then local source_file_abs = vim.fn.fnamemodify(source_file, ':p') for _, win in ipairs(vim.api.nvim_list_wins()) do @@ -376,6 +402,7 @@ function M.ensure_io_view() local buf_name = vim.api.nvim_buf_get_name(buf) if buf_name == source_file_abs then state.set_solution_win(win) + logger.log(('ensure_io_view: found solution_win=%s for buf=%s'):format(win, buf)) break end end @@ -395,17 +422,22 @@ function M.ensure_io_view() end local output_buf, input_buf = get_or_create_io_buffers() + logger.log(('ensure_io_view: got buffers output=%s input=%s'):format(output_buf, input_buf)) if not buffers_are_displayed(output_buf, input_buf) then local solution_win = state.get_solution_win() + logger.log(('ensure_io_view: buffers not displayed, closing non-solution windows (solution_win=%s)'):format(solution_win)) for _, win in ipairs(vim.api.nvim_list_wins()) do if win ~= solution_win then + logger.log(('ensure_io_view: closing win=%s'):format(win)) pcall(vim.api.nvim_win_close, win, true) end end create_window_layout(output_buf, input_buf) + else + logger.log('ensure_io_view: buffers already displayed, skipping layout creation') end local cfg = config_module.get_config() From c8c0da6d6118091b2796a8eb61fc20fa6f769fcf Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 12:27:09 -0500 Subject: [PATCH 02/38] fix(ci): format --- .gitignore | 9 +++++++-- .pre-commit-config.yaml | 1 + lua/cp/ui/views.lua | 17 +---------------- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index f383808..45bc345 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,16 @@ -.venv/ +.venv +venv doc/tags *.log build io debug -venv/ +create + + +.*cache* CLAUDE.md __pycache__ .claude/ + node_modules/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 49fe046..22e6d2e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,6 +25,7 @@ repos: hooks: - id: prettier name: prettier + files: \.(md|,toml,yaml,sh)$ - repo: local hooks: diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index 3b40ef3..29a3f46 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -255,34 +255,27 @@ local function get_or_create_io_buffers() vim.api.nvim_create_autocmd({ 'BufWinEnter', 'BufWinLeave' }, { group = group_name, buffer = source_buf, - callback = function(ev) - logger.log(('autocmd %s fired for source_buf=%s'):format(ev.event, source_buf)) + callback = function() vim.schedule(function() local io = state.get_io_view_state() if not io then - logger.log('autocmd scheduled: no io_state, returning') return end if io.source_buf ~= source_buf then - logger.log(('autocmd scheduled: source_buf mismatch (autocmd=%s, io_state=%s), returning'):format( - source_buf, io.source_buf)) return end local wins = vim.api.nvim_list_wins() for _, win in ipairs(wins) do if vim.api.nvim_win_get_buf(win) == source_buf then - logger.log(('autocmd scheduled: source_buf=%s visible in win=%s, returning'):format(source_buf, win)) return end end - logger.log(('autocmd scheduled: source_buf=%s NOT visible, closing io windows'):format(source_buf)) for _, win in ipairs(wins) do local buf = vim.api.nvim_win_get_buf(win) if buf == io.output_buf or buf == io.input_buf then - logger.log(('autocmd scheduled: closing win=%s with buf=%s'):format(win, buf)) if #vim.api.nvim_list_wins() > 1 then pcall(vim.api.nvim_win_close, win, true) else @@ -382,7 +375,6 @@ local function create_window_layout(output_buf, input_buf) end function M.ensure_io_view() - logger.log('ensure_io_view: starting') local platform, contest_id, problem_id = state.get_platform(), state.get_contest_id(), state.get_problem_id() if not platform or not contest_id or not problem_id then @@ -394,7 +386,6 @@ function M.ensure_io_view() end local source_file = state.get_source_file() - logger.log(('ensure_io_view: source_file=%s'):format(source_file or 'nil')) if source_file then local source_file_abs = vim.fn.fnamemodify(source_file, ':p') for _, win in ipairs(vim.api.nvim_list_wins()) do @@ -402,7 +393,6 @@ function M.ensure_io_view() local buf_name = vim.api.nvim_buf_get_name(buf) if buf_name == source_file_abs then state.set_solution_win(win) - logger.log(('ensure_io_view: found solution_win=%s for buf=%s'):format(win, buf)) break end end @@ -422,22 +412,17 @@ function M.ensure_io_view() end local output_buf, input_buf = get_or_create_io_buffers() - logger.log(('ensure_io_view: got buffers output=%s input=%s'):format(output_buf, input_buf)) if not buffers_are_displayed(output_buf, input_buf) then local solution_win = state.get_solution_win() - logger.log(('ensure_io_view: buffers not displayed, closing non-solution windows (solution_win=%s)'):format(solution_win)) for _, win in ipairs(vim.api.nvim_list_wins()) do if win ~= solution_win then - logger.log(('ensure_io_view: closing win=%s'):format(win)) pcall(vim.api.nvim_win_close, win, true) end end create_window_layout(output_buf, input_buf) - else - logger.log('ensure_io_view: buffers already displayed, skipping layout creation') end local cfg = config_module.get_config() From ba26cee7f9812ed5b9c64bb8e0ede02d66dcac92 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 12:55:35 -0500 Subject: [PATCH 03/38] feat(run): make running entirely asynchronous --- lua/cp/runner/execute.lua | 129 ++++++----- lua/cp/runner/run.lua | 198 ++++++++-------- lua/cp/runner/run_render.lua | 8 +- lua/cp/ui/views.lua | 427 ++++++++++++++++++----------------- 4 files changed, 404 insertions(+), 358 deletions(-) diff --git a/lua/cp/runner/execute.lua b/lua/cp/runner/execute.lua index c1c141e..4cf330b 100644 --- a/lua/cp/runner/execute.lua +++ b/lua/cp/runner/execute.lua @@ -39,24 +39,27 @@ end ---@param compile_cmd string[] ---@param substitutions SubstitutableCommand -function M.compile(compile_cmd, substitutions) +---@param on_complete fun(r: {code: integer, stdout: string}) +function M.compile(compile_cmd, substitutions, on_complete) local cmd = substitute_template(compile_cmd, substitutions) local sh = table.concat(cmd, ' ') .. ' 2>&1' local t0 = vim.uv.hrtime() - local r = vim.system({ 'sh', '-c', sh }, { text = false }):wait() - local dt = (vim.uv.hrtime() - t0) / 1e6 + vim.system({ 'sh', '-c', sh }, { text = false }, function(r) + local dt = (vim.uv.hrtime() - t0) / 1e6 + local ansi = require('cp.ui.ansi') + r.stdout = ansi.bytes_to_string(r.stdout or '') - local ansi = require('cp.ui.ansi') - r.stdout = ansi.bytes_to_string(r.stdout or '') + if r.code == 0 then + logger.log(('Compilation successful in %.1fms.'):format(dt), vim.log.levels.INFO) + else + logger.log(('Compilation failed in %.1fms.'):format(dt)) + end - if r.code == 0 then - logger.log(('Compilation successful in %.1fms.'):format(dt), vim.log.levels.INFO) - else - logger.log(('Compilation failed in %.1fms.'):format(dt)) - end - - return r + vim.schedule(function() + on_complete(r) + end) + end) end local function parse_and_strip_time_v(output) @@ -103,7 +106,8 @@ local function parse_and_strip_time_v(output) return head, peak_mb end -function M.run(cmd, stdin, timeout_ms, memory_mb) +---@param on_complete fun(result: ExecuteResult) +function M.run(cmd, stdin, timeout_ms, memory_mb, on_complete) local time_bin = utils.time_path() local timeout_bin = utils.timeout_path() @@ -117,56 +121,56 @@ function M.run(cmd, stdin, timeout_ms, memory_mb) local sh = prefix .. timeout_prefix .. ('%s -v sh -c %q 2>&1'):format(time_bin, prog) local t0 = vim.uv.hrtime() - local r = vim - .system({ 'sh', '-c', sh }, { - stdin = stdin, - text = true, - }) - :wait() - local dt = (vim.uv.hrtime() - t0) / 1e6 + vim.system({ 'sh', '-c', sh }, { stdin = stdin, text = true }, function(r) + local dt = (vim.uv.hrtime() - t0) / 1e6 - local code = r.code or 0 - local raw = r.stdout or '' - local cleaned, peak_mb = parse_and_strip_time_v(raw) - local tled = code == 124 + local code = r.code or 0 + local raw = r.stdout or '' + local cleaned, peak_mb = parse_and_strip_time_v(raw) + local tled = code == 124 - local signal = nil - if code >= 128 then - signal = constants.signal_codes[code] - end + local signal = nil + if code >= 128 then + signal = constants.signal_codes[code] + end - local lower = (cleaned or ''):lower() - local oom_hint = lower:find('std::bad_alloc', 1, true) - or lower:find('cannot allocate memory', 1, true) - or lower:find('out of memory', 1, true) - or lower:find('oom', 1, true) - or lower:find('enomem', 1, true) - local near_cap = peak_mb >= (0.90 * memory_mb) + local lower = (cleaned or ''):lower() + local oom_hint = lower:find('std::bad_alloc', 1, true) + or lower:find('cannot allocate memory', 1, true) + or lower:find('out of memory', 1, true) + or lower:find('oom', 1, true) + or lower:find('enomem', 1, true) + local near_cap = peak_mb >= (0.90 * memory_mb) - local mled = (peak_mb >= memory_mb) or near_cap or (oom_hint and not tled) + local mled = (peak_mb >= memory_mb) or near_cap or (oom_hint and not tled) - if tled then - logger.log(('Execution timed out in %.1fms.'):format(dt)) - elseif mled then - logger.log(('Execution memory limit exceeded in %.1fms.'):format(dt)) - elseif code ~= 0 then - logger.log(('Execution failed in %.1fms (exit code %d).'):format(dt, code)) - else - logger.log(('Execution successful in %.1fms.'):format(dt)) - end + if tled then + logger.log(('Execution timed out in %.1fms.'):format(dt)) + elseif mled then + logger.log(('Execution memory limit exceeded in %.1fms.'):format(dt)) + elseif code ~= 0 then + logger.log(('Execution failed in %.1fms (exit code %d).'):format(dt, code)) + else + logger.log(('Execution successful in %.1fms.'):format(dt)) + end - return { - stdout = cleaned, - code = code, - time_ms = dt, - tled = tled, - mled = mled, - peak_mb = peak_mb, - signal = signal, - } + vim.schedule(function() + on_complete({ + stdout = cleaned, + code = code, + time_ms = dt, + tled = tled, + mled = mled, + peak_mb = peak_mb, + signal = signal, + }) + end) + end) end -function M.compile_problem(debug) +---@param debug boolean? +---@param on_complete fun(result: {success: boolean, output: string?}) +function M.compile_problem(debug, on_complete) local state = require('cp.state') local config = require('cp.config').get_config() local platform = state.get_platform() @@ -176,17 +180,20 @@ function M.compile_problem(debug) local compile_config = (debug and eff.commands.debug) or eff.commands.build if not compile_config then - return { success = true, output = nil } + on_complete({ success = true, output = nil }) + return end local binary = debug and state.get_debug_file() or state.get_binary_file() local substitutions = { source = state.get_source_file(), binary = binary } - local r = M.compile(compile_config, substitutions) - if r.code ~= 0 then - return { success = false, output = r.stdout or 'unknown error' } - end - return { success = true, output = nil } + M.compile(compile_config, substitutions, function(r) + if r.code ~= 0 then + on_complete({ success = false, output = r.stdout or 'unknown error' }) + else + on_complete({ success = true, output = nil }) + end + end) end return M diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index 80024a8..1fe1b5f 100644 --- a/lua/cp/runner/run.lua +++ b/lua/cp/runner/run.lua @@ -101,8 +101,8 @@ end ---@param test_case RanTestCase ---@param debug boolean? ----@return { status: "pass"|"fail"|"tle"|"mle", actual: string, actual_highlights: Highlight[], error: string, stderr: string, time_ms: number, code: integer, ok: boolean, signal: string, tled: boolean, mled: boolean, rss_mb: number } -local function run_single_test_case(test_case, debug) +---@param on_complete fun(result: { status: "pass"|"fail"|"tle"|"mle", actual: string, actual_highlights: Highlight[], error: string, stderr: string, time_ms: number, code: integer, ok: boolean, signal: string, tled: boolean, mled: boolean, rss_mb: number }) +local function run_single_test_case(test_case, debug, on_complete) local source_file = state.get_source_file() local binary_file = debug and state.get_debug_file() or state.get_binary_file() @@ -117,65 +117,65 @@ local function run_single_test_case(test_case, debug) local timeout_ms = (panel_state.constraints and panel_state.constraints.timeout_ms) or 0 local memory_mb = panel_state.constraints and panel_state.constraints.memory_mb or 0 - local r = execute.run(cmd, stdin_content, timeout_ms, memory_mb) + execute.run(cmd, stdin_content, timeout_ms, memory_mb, function(r) + local ansi = require('cp.ui.ansi') + local out = r.stdout or '' + local highlights = {} + if out ~= '' then + if config.ui.ansi then + local parsed = ansi.parse_ansi_text(out) + out = table.concat(parsed.lines, '\n') + highlights = parsed.highlights + else + out = out:gsub('\027%[[%d;]*[a-zA-Z]', '') + end + end - local ansi = require('cp.ui.ansi') - local out = r.stdout or '' - local highlights = {} - if out ~= '' then - if config.ui.ansi then - local parsed = ansi.parse_ansi_text(out) - out = table.concat(parsed.lines, '\n') - highlights = parsed.highlights + local max_lines = config.ui.panel.max_output_lines + local lines = vim.split(out, '\n') + if #lines > max_lines then + local trimmed = {} + for i = 1, max_lines do + table.insert(trimmed, lines[i]) + end + table.insert(trimmed, string.format('... (output trimmed after %d lines)', max_lines)) + out = table.concat(trimmed, '\n') + end + + local expected = test_case.expected or '' + local ok = normalize_lines(out) == normalize_lines(expected) + + local signal = r.signal + if not signal and r.code and r.code >= 128 then + signal = constants.signal_codes[r.code] + end + + local status + if r.tled then + status = 'tle' + elseif r.mled then + status = 'mle' + elseif ok then + status = 'pass' else - out = out:gsub('\027%[[%d;]*[a-zA-Z]', '') + status = 'fail' end - end - local max_lines = config.ui.panel.max_output_lines - local lines = vim.split(out, '\n') - if #lines > max_lines then - local trimmed = {} - for i = 1, max_lines do - table.insert(trimmed, lines[i]) - end - table.insert(trimmed, string.format('... (output trimmed after %d lines)', max_lines)) - out = table.concat(trimmed, '\n') - end - - local expected = test_case.expected or '' - local ok = normalize_lines(out) == normalize_lines(expected) - - local signal = r.signal - if not signal and r.code and r.code >= 128 then - signal = constants.signal_codes[r.code] - end - - local status - if r.tled then - status = 'tle' - elseif r.mled then - status = 'mle' - elseif ok then - status = 'pass' - else - status = 'fail' - end - - return { - status = status, - actual = out, - actual_highlights = highlights, - error = (r.code ~= 0 and not ok) and out or '', - stderr = '', - time_ms = r.time_ms, - code = r.code, - ok = ok, - signal = signal, - tled = r.tled or false, - mled = r.mled or false, - rss_mb = r.peak_mb or 0, - } + on_complete({ + status = status, + actual = out, + actual_highlights = highlights, + error = (r.code ~= 0 and not ok) and out or '', + stderr = '', + time_ms = r.time_ms, + code = r.code, + ok = ok, + signal = signal, + tled = r.tled or false, + mled = r.mled or false, + rss_mb = r.peak_mb or 0, + }) + end) end ---@return boolean @@ -199,8 +199,8 @@ function M.load_test_cases() end ---@param debug boolean? ----@return RanTestCase? -function M.run_combined_test(debug) +---@param on_complete fun(result: RanTestCase?) +function M.run_combined_test(debug, on_complete) local combined = cache.get_combined_test( state.get_platform() or '', state.get_contest_id() or '', @@ -209,7 +209,8 @@ function M.run_combined_test(debug) if not combined then logger.log('No combined test found', vim.log.levels.ERROR) - return nil + on_complete(nil) + return end local ran_test = { @@ -228,42 +229,45 @@ function M.run_combined_test(debug) selected = true, } - local result = run_single_test_case(ran_test, debug) - return result + run_single_test_case(ran_test, debug, function(result) + on_complete(result) + end) end ---@param index number ---@param debug boolean? ----@return boolean -function M.run_test_case(index, debug) +---@param on_complete fun(success: boolean) +function M.run_test_case(index, debug, on_complete) local tc = panel_state.test_cases[index] if not tc then - return false + on_complete(false) + return end tc.status = 'running' - local r = run_single_test_case(tc, debug) + run_single_test_case(tc, debug, function(r) + tc.status = r.status + tc.actual = r.actual + tc.actual_highlights = r.actual_highlights + tc.error = r.error + tc.stderr = r.stderr + tc.time_ms = r.time_ms + tc.code = r.code + tc.ok = r.ok + tc.signal = r.signal + tc.tled = r.tled + tc.mled = r.mled + tc.rss_mb = r.rss_mb - tc.status = r.status - tc.actual = r.actual - tc.actual_highlights = r.actual_highlights - tc.error = r.error - tc.stderr = r.stderr - tc.time_ms = r.time_ms - tc.code = r.code - tc.ok = r.ok - tc.signal = r.signal - tc.tled = r.tled - tc.mled = r.mled - tc.rss_mb = r.rss_mb - - return true + on_complete(true) + end) end ---@param indices? integer[] ---@param debug boolean? ----@return RanTestCase[] -function M.run_all_test_cases(indices, debug) +---@param on_each? fun(index: integer, total: integer) +---@param on_done fun(results: RanTestCase[]) +function M.run_all_test_cases(indices, debug, on_each, on_done) local to_run = indices if not to_run then to_run = {} @@ -272,20 +276,26 @@ function M.run_all_test_cases(indices, debug) end end - for _, i in ipairs(to_run) do - M.run_test_case(i, debug) + local function run_next(pos) + if pos > #to_run then + logger.log( + ('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', #to_run), + vim.log.levels.INFO, + true + ) + on_done(panel_state.test_cases) + return + end + + M.run_test_case(to_run[pos], debug, function() + if on_each then + on_each(pos, #to_run) + end + run_next(pos + 1) + end) end - logger.log( - ('Finished %s %s test cases.'):format( - debug and 'debugging' or 'running', - #panel_state.test_cases - ), - vim.log.levels.INFO, - true - ) - - return panel_state.test_cases + run_next(1) end ---@return PanelState diff --git a/lua/cp/runner/run_render.lua b/lua/cp/runner/run_render.lua index 714ecd3..344370c 100644 --- a/lua/cp/runner/run_render.lua +++ b/lua/cp/runner/run_render.lua @@ -26,6 +26,12 @@ local exit_code_names = { ---@param ran_test_case RanTestCase ---@return StatusInfo function M.get_status_info(ran_test_case) + if ran_test_case.status == 'pending' then + return { text = 'PEND', highlight_group = 'CpTestNA' } + elseif ran_test_case.status == 'running' then + return { text = 'RUN', highlight_group = 'CpTestNA' } + end + if ran_test_case.ok then return { text = 'AC', highlight_group = 'CpTestAC' } end @@ -34,7 +40,7 @@ function M.get_status_info(ran_test_case) return { text = 'TLE', highlight_group = 'CpTestTLE' } elseif ran_test_case.mled then return { text = 'MLE', highlight_group = 'CpTestMLE' } - elseif ran_test_case.code > 0 and ran_test_case.code >= 128 then + elseif ran_test_case.code and ran_test_case.code >= 128 then return { text = 'RTE', highlight_group = 'CpTestRTE' } elseif ran_test_case.code == 0 and not ran_test_case.ok then return { text = 'WA', highlight_group = 'CpTestWA' } diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index 29a3f46..b333b86 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -464,6 +464,158 @@ function M.ensure_io_view() end end +local function render_io_view_results(io_state, test_indices, mode, combined_result, combined_input) + local run = require('cp.runner.run') + local run_render = require('cp.runner.run_render') + local cfg = config_module.get_config() + + run_render.setup_highlights() + + local input_lines = {} + local output_lines = {} + local verdict_lines = {} + local verdict_highlights = {} + local formatter = cfg.ui.run.format_verdict + local test_state = run.get_panel_state() + + if mode == 'combined' and combined_result then + input_lines = vim.split(combined_input, '\n') + + if combined_result.actual and combined_result.actual ~= '' then + output_lines = vim.split(combined_result.actual, '\n') + end + + local status = run_render.get_status_info(combined_result) + local format_data = { + index = 1, + status = status, + time_ms = combined_result.time_ms or 0, + time_limit_ms = test_state.constraints and test_state.constraints.timeout_ms or 0, + memory_mb = combined_result.rss_mb or 0, + memory_limit_mb = test_state.constraints and test_state.constraints.memory_mb or 0, + exit_code = combined_result.code or 0, + signal = (combined_result.code and combined_result.code >= 128) + and require('cp.constants').signal_codes[combined_result.code] + or nil, + time_actual_width = #string.format('%.2f', combined_result.time_ms or 0), + time_limit_width = #tostring( + test_state.constraints and test_state.constraints.timeout_ms or 0 + ), + mem_actual_width = #string.format('%.0f', combined_result.rss_mb or 0), + mem_limit_width = #string.format( + '%.0f', + test_state.constraints and test_state.constraints.memory_mb or 0 + ), + } + + local verdict_result = formatter(format_data) + table.insert(verdict_lines, verdict_result.line) + + if verdict_result.highlights then + for _, hl in ipairs(verdict_result.highlights) do + table.insert(verdict_highlights, { + line_offset = #verdict_lines - 1, + col_start = hl.col_start, + col_end = hl.col_end, + group = hl.group, + }) + end + end + else + local max_time_actual, max_time_limit, max_mem_actual, max_mem_limit = 0, 0, 0, 0 + + for _, idx in ipairs(test_indices) do + local tc = test_state.test_cases[idx] + max_time_actual = math.max(max_time_actual, #string.format('%.2f', tc.time_ms or 0)) + max_time_limit = math.max( + max_time_limit, + #tostring(test_state.constraints and test_state.constraints.timeout_ms or 0) + ) + max_mem_actual = math.max(max_mem_actual, #string.format('%.0f', tc.rss_mb or 0)) + max_mem_limit = math.max( + max_mem_limit, + #string.format('%.0f', test_state.constraints and test_state.constraints.memory_mb or 0) + ) + end + + local all_outputs = {} + for _, idx in ipairs(test_indices) do + local tc = test_state.test_cases[idx] + for _, line in ipairs(vim.split(tc.input, '\n')) do + table.insert(input_lines, line) + end + if tc.actual then + table.insert(all_outputs, tc.actual) + end + end + + local combined_output = table.concat(all_outputs, '') + if combined_output ~= '' then + for _, line in ipairs(vim.split(combined_output, '\n')) do + table.insert(output_lines, line) + end + end + + for _, idx in ipairs(test_indices) do + local tc = test_state.test_cases[idx] + local status = run_render.get_status_info(tc) + + local format_data = { + index = idx, + status = status, + time_ms = tc.time_ms or 0, + time_limit_ms = test_state.constraints and test_state.constraints.timeout_ms or 0, + memory_mb = tc.rss_mb or 0, + memory_limit_mb = test_state.constraints and test_state.constraints.memory_mb or 0, + exit_code = tc.code or 0, + signal = (tc.code and tc.code >= 128) and require('cp.constants').signal_codes[tc.code] + or nil, + time_actual_width = max_time_actual, + time_limit_width = max_time_limit, + mem_actual_width = max_mem_actual, + mem_limit_width = max_mem_limit, + } + + local result = formatter(format_data) + table.insert(verdict_lines, result.line) + + if result.highlights then + for _, hl in ipairs(result.highlights) do + table.insert(verdict_highlights, { + line_offset = #verdict_lines - 1, + col_start = hl.col_start, + col_end = hl.col_end, + group = hl.group, + }) + end + end + end + end + + if #output_lines > 0 and #verdict_lines > 0 then + table.insert(output_lines, '') + end + + local verdict_start = #output_lines + for _, line in ipairs(verdict_lines) do + table.insert(output_lines, line) + end + + local final_highlights = {} + for _, vh in ipairs(verdict_highlights) do + table.insert(final_highlights, { + line = verdict_start + vh.line_offset, + col_start = vh.col_start, + col_end = vh.col_end, + highlight_group = vh.group, + }) + end + + utils.update_buffer_content(io_state.input_buf, input_lines, nil, nil) + local output_ns = vim.api.nvim_create_namespace('cp_io_view_output') + utils.update_buffer_content(io_state.output_buf, output_lines, final_highlights, output_ns) +end + function M.run_io_view(test_indices_arg, debug, mode) logger.log(('%s tests...'):format(debug and 'Debugging' or 'Running'), vim.log.levels.INFO, true) @@ -544,208 +696,65 @@ function M.run_io_view(test_indices_arg, debug, mode) return end - local config = config_module.get_config() + local cfg = config_module.get_config() - if config.ui.ansi then + if cfg.ui.ansi then require('cp.ui.ansi').setup_highlight_groups() end local execute = require('cp.runner.execute') - local compile_result = execute.compile_problem(debug) - if not compile_result.success then - local ansi = require('cp.ui.ansi') - local output = compile_result.output or '' - local lines, highlights - if config.ui.ansi then - local parsed = ansi.parse_ansi_text(output) - lines = parsed.lines - highlights = parsed.highlights - else - lines = vim.split(output:gsub('\027%[[%d;]*[a-zA-Z]', ''), '\n') - highlights = {} - end - - local ns = vim.api.nvim_create_namespace('cp_io_view_compile_error') - utils.update_buffer_content(io_state.output_buf, lines, highlights, ns) - return - end - - local run_render = require('cp.runner.run_render') - run_render.setup_highlights() - - local input_lines = {} - local output_lines = {} - local verdict_lines = {} - local verdict_highlights = {} - - local formatter = config.ui.run.format_verdict - - if mode == 'combined' then - local combined = cache.get_combined_test(platform, contest_id, problem_id) - - if not combined then - logger.log('No combined test found', vim.log.levels.ERROR) + execute.compile_problem(debug, function(compile_result) + if not vim.api.nvim_buf_is_valid(io_state.output_buf) then return end - run.load_test_cases() + if not compile_result.success then + local ansi = require('cp.ui.ansi') + local output = compile_result.output or '' + local lines, highlights - local result = run.run_combined_test(debug) + if cfg.ui.ansi then + local parsed = ansi.parse_ansi_text(output) + lines = parsed.lines + highlights = parsed.highlights + else + lines = vim.split(output:gsub('\027%[[%d;]*[a-zA-Z]', ''), '\n') + highlights = {} + end - if not result then - logger.log('Failed to run combined test', vim.log.levels.ERROR) + local ns = vim.api.nvim_create_namespace('cp_io_view_compile_error') + utils.update_buffer_content(io_state.output_buf, lines, highlights, ns) return end - input_lines = vim.split(combined.input, '\n') - - if result.actual and result.actual ~= '' then - output_lines = vim.split(result.actual, '\n') - end - - local status = run_render.get_status_info(result) - local test_state = run.get_panel_state() - - ---@type VerdictFormatData - local format_data = { - index = 1, - status = status, - time_ms = result.time_ms or 0, - time_limit_ms = test_state.constraints and test_state.constraints.timeout_ms or 0, - memory_mb = result.rss_mb or 0, - memory_limit_mb = test_state.constraints and test_state.constraints.memory_mb or 0, - exit_code = result.code or 0, - signal = (result.code and result.code >= 128) - and require('cp.constants').signal_codes[result.code] - or nil, - time_actual_width = #string.format('%.2f', result.time_ms or 0), - time_limit_width = #tostring( - test_state.constraints and test_state.constraints.timeout_ms or 0 - ), - mem_actual_width = #string.format('%.0f', result.rss_mb or 0), - mem_limit_width = #string.format( - '%.0f', - test_state.constraints and test_state.constraints.memory_mb or 0 - ), - } - - local verdict_result = formatter(format_data) - table.insert(verdict_lines, verdict_result.line) - - if verdict_result.highlights then - for _, hl in ipairs(verdict_result.highlights) do - table.insert(verdict_highlights, { - line_offset = #verdict_lines - 1, - col_start = hl.col_start, - col_end = hl.col_end, - group = hl.group, - }) - end - end - else - run.run_all_test_cases(test_indices, debug) - local test_state = run.get_panel_state() - - local max_time_actual = 0 - local max_time_limit = 0 - local max_mem_actual = 0 - local max_mem_limit = 0 - - for _, idx in ipairs(test_indices) do - local tc = test_state.test_cases[idx] - max_time_actual = math.max(max_time_actual, #string.format('%.2f', tc.time_ms or 0)) - max_time_limit = math.max( - max_time_limit, - #tostring(test_state.constraints and test_state.constraints.timeout_ms or 0) - ) - max_mem_actual = math.max(max_mem_actual, #string.format('%.0f', tc.rss_mb or 0)) - max_mem_limit = math.max( - max_mem_limit, - #string.format('%.0f', test_state.constraints and test_state.constraints.memory_mb or 0) - ) - end - - local all_outputs = {} - for _, idx in ipairs(test_indices) do - local tc = test_state.test_cases[idx] - - for _, line in ipairs(vim.split(tc.input, '\n')) do - table.insert(input_lines, line) + if mode == 'combined' then + local combined = cache.get_combined_test(platform, contest_id, problem_id) + if not combined then + logger.log('No combined test found', vim.log.levels.ERROR) + return end - if tc.actual then - table.insert(all_outputs, tc.actual) - end - end + run.load_test_cases() - local combined_output = table.concat(all_outputs, '') - if combined_output ~= '' then - for _, line in ipairs(vim.split(combined_output, '\n')) do - table.insert(output_lines, line) - end - end - - for _, idx in ipairs(test_indices) do - local tc = test_state.test_cases[idx] - local status = run_render.get_status_info(tc) - - ---@type VerdictFormatData - local format_data = { - index = idx, - status = status, - time_ms = tc.time_ms or 0, - time_limit_ms = test_state.constraints and test_state.constraints.timeout_ms or 0, - memory_mb = tc.rss_mb or 0, - memory_limit_mb = test_state.constraints and test_state.constraints.memory_mb or 0, - exit_code = tc.code or 0, - signal = (tc.code and tc.code >= 128) and require('cp.constants').signal_codes[tc.code] - or nil, - time_actual_width = max_time_actual, - time_limit_width = max_time_limit, - mem_actual_width = max_mem_actual, - mem_limit_width = max_mem_limit, - } - - local result = formatter(format_data) - table.insert(verdict_lines, result.line) - - if result.highlights then - for _, hl in ipairs(result.highlights) do - table.insert(verdict_highlights, { - line_offset = #verdict_lines - 1, - col_start = hl.col_start, - col_end = hl.col_end, - group = hl.group, - }) + run.run_combined_test(debug, function(result) + if not result then + logger.log('Failed to run combined test', vim.log.levels.ERROR) + return end - end + + if vim.api.nvim_buf_is_valid(io_state.output_buf) then + render_io_view_results(io_state, test_indices, mode, result, combined.input) + end + end) + else + run.run_all_test_cases(test_indices, debug, nil, function() + if vim.api.nvim_buf_is_valid(io_state.output_buf) then + render_io_view_results(io_state, test_indices, mode, nil, nil) + end + end) end - end - - if #output_lines > 0 and #verdict_lines > 0 then - table.insert(output_lines, '') - end - - local verdict_start = #output_lines - for _, line in ipairs(verdict_lines) do - table.insert(output_lines, line) - end - - local final_highlights = {} - for _, vh in ipairs(verdict_highlights) do - table.insert(final_highlights, { - line = verdict_start + vh.line_offset, - col_start = vh.col_start, - col_end = vh.col_end, - highlight_group = vh.group, - }) - end - - utils.update_buffer_content(io_state.input_buf, input_lines, nil, nil) - - local output_ns = vim.api.nvim_create_namespace('cp_io_view_output') - utils.update_buffer_content(io_state.output_buf, output_lines, final_highlights, output_ns) + end) end ---@param panel_opts? PanelOpts @@ -918,30 +927,44 @@ function M.toggle_panel(panel_opts) end) end - local execute = require('cp.runner.execute') - local compile_result = execute.compile_problem(panel_opts and panel_opts.debug) - if compile_result.success then - run.run_all_test_cases(nil, panel_opts and panel_opts.debug) - else - run.handle_compilation_failure(compile_result.output) - end - - refresh_panel() - - vim.schedule(function() - if config.ui.ansi then - require('cp.ui.ansi').setup_highlight_groups() - end - if current_diff_layout then - update_diff_panes() - end - end) - vim.api.nvim_set_current_win(test_windows.tab_win) state.test_buffers = test_buffers state.test_windows = test_windows state.set_active_panel('run') logger.log('test panel opened') + + refresh_panel() + + local function finalize_panel() + vim.schedule(function() + if config.ui.ansi then + require('cp.ui.ansi').setup_highlight_groups() + end + if current_diff_layout then + update_diff_panes() + end + end) + end + + local execute = require('cp.runner.execute') + execute.compile_problem(panel_opts and panel_opts.debug, function(compile_result) + if not test_buffers.tab_buf or not vim.api.nvim_buf_is_valid(test_buffers.tab_buf) then + return + end + + if compile_result.success then + run.run_all_test_cases(nil, panel_opts and panel_opts.debug, function() + refresh_panel() + end, function() + refresh_panel() + finalize_panel() + end) + else + run.handle_compilation_failure(compile_result.output) + refresh_panel() + finalize_panel() + end + end) end return M From 8969dbccf8d286926209a4f001b11f1b7908ac3c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 13:18:11 -0500 Subject: [PATCH 04/38] fix(panel): table rendering --- lua/cp/runner/run_render.lua | 40 ++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/lua/cp/runner/run_render.lua b/lua/cp/runner/run_render.lua index 344370c..f9b96c8 100644 --- a/lua/cp/runner/run_render.lua +++ b/lua/cp/runner/run_render.lua @@ -4,6 +4,10 @@ local M = {} +local function strwidth(s) + return vim.api.nvim_strwidth(s) +end + local exit_code_names = { [128] = 'SIGHUP', [129] = 'SIGINT', @@ -69,24 +73,24 @@ local function compute_cols(test_state) 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 .. ' ')) + w.num = math.max(w.num, strwidth(' ' .. prefix .. i .. ' ')) + w.status = math.max(w.status, strwidth(' ' .. M.get_status_info(tc).text .. ' ')) local time_str = tc.time_ms and string.format('%.2f', tc.time_ms) or '—' - w.time = math.max(w.time, #(' ' .. time_str .. ' ')) - w.timeout = math.max(w.timeout, #(' ' .. timeout_str .. ' ')) + w.time = math.max(w.time, strwidth(' ' .. time_str .. ' ')) + w.timeout = math.max(w.timeout, strwidth(' ' .. timeout_str .. ' ')) local rss_str = (tc.rss_mb and string.format('%.0f', tc.rss_mb)) or '—' - w.rss = math.max(w.rss, #(' ' .. rss_str .. ' ')) - w.memory = math.max(w.memory, #(' ' .. memory_str .. ' ')) - w.exit = math.max(w.exit, #(' ' .. format_exit_code(tc.code) .. ' ')) + w.rss = math.max(w.rss, strwidth(' ' .. rss_str .. ' ')) + w.memory = math.max(w.memory, strwidth(' ' .. memory_str .. ' ')) + w.exit = math.max(w.exit, strwidth(' ' .. format_exit_code(tc.code) .. ' ')) end - w.num = math.max(w.num, #' # ') - w.status = math.max(w.status, #' Status ') - w.time = math.max(w.time, #' Runtime (ms) ') - w.timeout = math.max(w.timeout, #' Time (ms) ') - w.rss = math.max(w.rss, #' RSS (MB) ') - w.memory = math.max(w.memory, #' Mem (MB) ') - w.exit = math.max(w.exit, #' Exit Code ') + w.num = math.max(w.num, strwidth(' # ')) + w.status = math.max(w.status, strwidth(' Status ')) + w.time = math.max(w.time, strwidth(' Runtime (ms) ')) + w.timeout = math.max(w.timeout, strwidth(' Time (ms) ')) + w.rss = math.max(w.rss, strwidth(' RSS (MB) ')) + w.memory = math.max(w.memory, strwidth(' Mem (MB) ')) + w.exit = math.max(w.exit, strwidth(' Exit Code ')) local sum = w.num + w.status + w.time + w.timeout + w.rss + w.memory + w.exit local inner = sum + 6 @@ -95,7 +99,7 @@ local function compute_cols(test_state) end local function center(text, width) - local pad = width - #text + local pad = width - strwidth(text) if pad <= 0 then return text end @@ -107,7 +111,7 @@ local function format_num_column(prefix, idx, width) local num_str = tostring(idx) local content = (#num_str == 1) and (' ' .. prefix .. ' ' .. num_str .. ' ') or (' ' .. prefix .. num_str .. ' ') - local total_pad = width - #content + local total_pad = width - strwidth(content) if total_pad <= 0 then return content end @@ -320,10 +324,10 @@ function M.render_test_list(test_state) 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 + if strwidth(s) > c.inner then s = string.sub(s, 1, c.inner) end - local pad = c.inner - #s + local pad = c.inner - strwidth(s) table.insert(lines, '│' .. s .. string.rep(' ', pad) .. '│') end From 0f513370acb0f01e4b13866a6738db92eb8d4232 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 13:30:32 -0500 Subject: [PATCH 05/38] fix(render): fix table render in partial state --- lua/cp/runner/run_render.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/cp/runner/run_render.lua b/lua/cp/runner/run_render.lua index f9b96c8..f426c86 100644 --- a/lua/cp/runner/run_render.lua +++ b/lua/cp/runner/run_render.lua @@ -31,7 +31,7 @@ local exit_code_names = { ---@return StatusInfo function M.get_status_info(ran_test_case) if ran_test_case.status == 'pending' then - return { text = 'PEND', highlight_group = 'CpTestNA' } + return { text = 'WAIT', highlight_group = 'CpTestNA' } elseif ran_test_case.status == 'running' then return { text = 'RUN', highlight_group = 'CpTestNA' } end From d4c5f08b5faf2c6cf9de41322af8d1c41a263761 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 13:31:07 -0500 Subject: [PATCH 06/38] fix(render): change pending status text to symbol --- lua/cp/runner/run_render.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/cp/runner/run_render.lua b/lua/cp/runner/run_render.lua index f426c86..1b81797 100644 --- a/lua/cp/runner/run_render.lua +++ b/lua/cp/runner/run_render.lua @@ -31,7 +31,7 @@ local exit_code_names = { ---@return StatusInfo function M.get_status_info(ran_test_case) if ran_test_case.status == 'pending' then - return { text = 'WAIT', highlight_group = 'CpTestNA' } + return { text = '...', highlight_group = 'CpTestNA' } elseif ran_test_case.status == 'running' then return { text = 'RUN', highlight_group = 'CpTestNA' } end From 4c5c44742e00e5628b9527613ed384d1211c330d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 14:23:23 -0500 Subject: [PATCH 07/38] feat: refactors --- lua/cp/runner/execute.lua | 2 +- lua/cp/runner/run.lua | 2 +- lua/cp/ui/views.lua | 187 +++++++++++++++++++------------------- 3 files changed, 98 insertions(+), 93 deletions(-) diff --git a/lua/cp/runner/execute.lua b/lua/cp/runner/execute.lua index 4cf330b..a60ffa3 100644 --- a/lua/cp/runner/execute.lua +++ b/lua/cp/runner/execute.lua @@ -142,7 +142,7 @@ function M.run(cmd, stdin, timeout_ms, memory_mb, on_complete) or lower:find('enomem', 1, true) local near_cap = peak_mb >= (0.90 * memory_mb) - local mled = (peak_mb >= memory_mb) or near_cap or (oom_hint and not tled) + local mled = (peak_mb >= memory_mb) or near_cap or (oom_hint ~= nil and not tled) if tled then logger.log(('Execution timed out in %.1fms.'):format(dt)) diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index 1fe1b5f..36a560c 100644 --- a/lua/cp/runner/run.lua +++ b/lua/cp/runner/run.lua @@ -101,7 +101,7 @@ end ---@param test_case RanTestCase ---@param debug boolean? ----@param on_complete fun(result: { status: "pass"|"fail"|"tle"|"mle", actual: string, actual_highlights: Highlight[], error: string, stderr: string, time_ms: number, code: integer, ok: boolean, signal: string, tled: boolean, mled: boolean, rss_mb: number }) +---@param on_complete fun(result: { status: "pass"|"fail"|"tle"|"mle", actual: string, actual_highlights: Highlight[], error: string, stderr: string, time_ms: number, code: integer, ok: boolean, signal: string?, tled: boolean, mled: boolean, rss_mb: number }) local function run_single_test_case(test_case, debug, on_complete) local source_file = state.get_source_file() diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index b333b86..4e06bd3 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -81,114 +81,119 @@ function M.toggle_interactive(interactor_cmd) local execute = require('cp.runner.execute') local run = require('cp.runner.run') - local compile_result = execute.compile_problem() - if not compile_result.success then - run.handle_compilation_failure(compile_result.output) - return - end - local binary = state.get_binary_file() - if not binary or binary == '' then - logger.log('No binary produced.', vim.log.levels.ERROR) - return - end - - local cmdline - if interactor_cmd and interactor_cmd ~= '' then - local interactor = interactor_cmd - if not interactor:find('/') then - interactor = './' .. interactor - end - if vim.fn.executable(interactor) ~= 1 then - logger.log( - ("Interactor '%s' is not executable."):format(interactor_cmd), - vim.log.levels.ERROR - ) - if state.saved_interactive_session then - vim.cmd.source(state.saved_interactive_session) - vim.fn.delete(state.saved_interactive_session) - state.saved_interactive_session = nil - end - return - end - local orchestrator = vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p') - cmdline = table.concat({ - 'uv', - 'run', - vim.fn.shellescape(orchestrator), - vim.fn.shellescape(interactor), - vim.fn.shellescape(binary), - }, ' ') - else - cmdline = vim.fn.shellescape(binary) - end - - vim.cmd.terminal(cmdline) - local term_buf = vim.api.nvim_get_current_buf() - local term_win = vim.api.nvim_get_current_win() - - local cleaned = false - local function cleanup() - if cleaned then - return - end - cleaned = true - if term_buf and vim.api.nvim_buf_is_valid(term_buf) then - local job = vim.b[term_buf] and vim.b[term_buf].terminal_job_id or nil - if job then - pcall(vim.fn.jobstop, job) - end - end + local function restore_session() if state.saved_interactive_session then vim.cmd.source(state.saved_interactive_session) vim.fn.delete(state.saved_interactive_session) state.saved_interactive_session = nil end - state.interactive_buf = nil - state.interactive_win = nil - state.set_active_panel(nil) end - vim.api.nvim_create_autocmd({ 'BufWipeout', 'BufUnload' }, { - buffer = term_buf, - callback = cleanup, - }) + execute.compile_problem(false, function(compile_result) + if not compile_result.success then + run.handle_compilation_failure(compile_result.output) + restore_session() + return + end - vim.api.nvim_create_autocmd('WinClosed', { - callback = function() + local binary = state.get_binary_file() + if not binary or binary == '' then + logger.log('No binary produced.', vim.log.levels.ERROR) + restore_session() + return + end + + local cmdline + if interactor_cmd and interactor_cmd ~= '' then + local interactor = interactor_cmd + if not interactor:find('/') then + interactor = './' .. interactor + end + if vim.fn.executable(interactor) ~= 1 then + logger.log( + ("Interactor '%s' is not executable."):format(interactor_cmd), + vim.log.levels.ERROR + ) + restore_session() + return + end + local orchestrator = + vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p') + cmdline = table.concat({ + 'uv', + 'run', + vim.fn.shellescape(orchestrator), + vim.fn.shellescape(interactor), + vim.fn.shellescape(binary), + }, ' ') + else + cmdline = vim.fn.shellescape(binary) + end + + vim.cmd.terminal(cmdline) + local term_buf = vim.api.nvim_get_current_buf() + local term_win = vim.api.nvim_get_current_win() + + local cleaned = false + local function cleanup() if cleaned then return end - local any = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == term_buf then - any = true - break + cleaned = true + if term_buf and vim.api.nvim_buf_is_valid(term_buf) then + local job = vim.b[term_buf] and vim.b[term_buf].terminal_job_id or nil + if job then + pcall(vim.fn.jobstop, job) end end - if not any then - cleanup() - end - end, - }) + restore_session() + state.interactive_buf = nil + state.interactive_win = nil + state.set_active_panel(nil) + end - vim.api.nvim_create_autocmd('TermClose', { - buffer = term_buf, - callback = function() - vim.b[term_buf].cp_interactive_exited = true - end, - }) + vim.api.nvim_create_autocmd({ 'BufWipeout', 'BufUnload' }, { + buffer = term_buf, + callback = cleanup, + }) - vim.keymap.set('t', '', function() - cleanup() - end, { buffer = term_buf, silent = true }) - vim.keymap.set('n', '', function() - cleanup() - end, { buffer = term_buf, silent = true }) + vim.api.nvim_create_autocmd('WinClosed', { + callback = function() + if cleaned then + return + end + local any = false + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == term_buf then + any = true + break + end + end + if not any then + cleanup() + end + end, + }) - state.interactive_buf = term_buf - state.interactive_win = term_win - state.set_active_panel('interactive') + vim.api.nvim_create_autocmd('TermClose', { + buffer = term_buf, + callback = function() + vim.b[term_buf].cp_interactive_exited = true + end, + }) + + vim.keymap.set('t', '', function() + cleanup() + end, { buffer = term_buf, silent = true }) + vim.keymap.set('n', '', function() + cleanup() + end, { buffer = term_buf, silent = true }) + + state.interactive_buf = term_buf + state.interactive_win = term_win + state.set_active_panel('interactive') + end) end ---@return integer, integer From fb7888b83c27d3308fd278c638b3b0672c6f9c75 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 14:27:41 -0500 Subject: [PATCH 08/38] feat(highlight): use default highlights --- lua/cp/runner/run_render.lua | 14 ++++++-------- lua/cp/ui/highlight.lua | 4 ++-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/lua/cp/runner/run_render.lua b/lua/cp/runner/run_render.lua index 1b81797..2dfb45b 100644 --- a/lua/cp/runner/run_render.lua +++ b/lua/cp/runner/run_render.lua @@ -367,14 +367,12 @@ end ---@return table function M.get_highlight_groups() return { - CpTestAC = { fg = '#10b981' }, - CpTestWA = { fg = '#ef4444' }, - CpTestTLE = { fg = '#f59e0b' }, - CpTestMLE = { fg = '#f59e0b' }, - CpTestRTE = { fg = '#8b5cf6' }, - CpTestNA = { fg = '#6b7280' }, - CpDiffRemoved = { fg = '#ef4444', bg = '#1f1f1f' }, - CpDiffAdded = { fg = '#10b981', bg = '#1f1f1f' }, + CpTestAC = { link = 'DiagnosticOk' }, + CpTestWA = { link = 'DiagnosticError' }, + CpTestTLE = { link = 'DiagnosticWarn' }, + CpTestMLE = { link = 'DiagnosticWarn' }, + CpTestRTE = { link = 'DiagnosticHint' }, + CpTestNA = { link = 'Comment' }, } end diff --git a/lua/cp/ui/highlight.lua b/lua/cp/ui/highlight.lua index 02bf1ae..a0dd17d 100644 --- a/lua/cp/ui/highlight.lua +++ b/lua/cp/ui/highlight.lua @@ -26,7 +26,7 @@ local function parse_diff_line(text) line = 0, col_start = highlight_start, col_end = #result_text, - highlight_group = 'CpDiffRemoved', + highlight_group = 'DiffDelete', }) pos = removed_end + 1 else @@ -38,7 +38,7 @@ local function parse_diff_line(text) line = 0, col_start = highlight_start, col_end = #result_text, - highlight_group = 'CpDiffAdded', + highlight_group = 'DiffAdd', }) pos = added_end + 1 else From 873ddee0d41782119bbb2ff35eec38f43478040b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 14:30:22 -0500 Subject: [PATCH 09/38] fix(doc): feature-parity --- doc/cp.nvim.txt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index f99b07b..570beb5 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -784,12 +784,15 @@ HIGHLIGHT GROUPS *cp-highlights* Test Status Groups ~ - CpTestAC Green foreground for AC status - CpTestWA Red foreground for WA status - CpTestTLE Orange foreground for TLE status - CpTestMLE Orange foreground for MLE status - CpTestRTE Purple foreground for RTE status - CpTestNA Gray foreground for remaining state +All test status groups link to builtin highlight groups, automatically adapting +to your colorscheme: + + CpTestAC Links to DiagnosticOk (AC status) + CpTestWA Links to DiagnosticError (WA status) + CpTestTLE Links to DiagnosticWarn (TLE status) + CpTestMLE Links to DiagnosticWarn (MLE status) + CpTestRTE Links to DiagnosticHint (RTE status) + CpTestNA Links to Comment (pending/unknown status) ANSI Color Groups ~ From 5293515acaba5cf28b172d18b9a016da49190c1e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 14:44:08 -0500 Subject: [PATCH 10/38] feat(scrapers): refactor --- scrapers/base.py | 101 +++++++++++++++++--------------- scrapers/codeforces.py | 130 +++++++++-------------------------------- scrapers/cses.py | 73 +---------------------- 3 files changed, 83 insertions(+), 221 deletions(-) diff --git a/scrapers/base.py b/scrapers/base.py index 6409c9a..4b685d0 100644 --- a/scrapers/base.py +++ b/scrapers/base.py @@ -1,9 +1,8 @@ +import asyncio +import sys from abc import ABC, abstractmethod -from typing import Any, Awaitable, Callable, ParamSpec, cast -from .models import ContestListResult, MetadataResult, TestsResult - -P = ParamSpec("P") +from .models import CombinedTest, ContestListResult, MetadataResult, TestsResult class BaseScraper(ABC): @@ -20,57 +19,65 @@ class BaseScraper(ABC): @abstractmethod async def stream_tests_for_category_async(self, category_id: str) -> None: ... - def _create_metadata_error( - self, error_msg: str, contest_id: str = "" - ) -> MetadataResult: - return MetadataResult( - success=False, - error=f"{self.platform_name}: {error_msg}", - contest_id=contest_id, - problems=[], - url="", - ) + def _usage(self) -> str: + name = self.platform_name + return f"Usage: {name}.py metadata | tests | contests" - def _create_tests_error( - self, error_msg: str, problem_id: str = "", url: str = "" - ) -> TestsResult: - from .models import CombinedTest + def _metadata_error(self, msg: str) -> MetadataResult: + return MetadataResult(success=False, error=msg, url="") + def _tests_error(self, msg: str) -> TestsResult: return TestsResult( success=False, - error=f"{self.platform_name}: {error_msg}", - problem_id=problem_id, + error=msg, + problem_id="", combined=CombinedTest(input="", expected=""), tests=[], timeout_ms=0, memory_mb=0, - interactive=False, ) - def _create_contests_error(self, error_msg: str) -> ContestListResult: - return ContestListResult( - success=False, - error=f"{self.platform_name}: {error_msg}", - contests=[], - ) + def _contests_error(self, msg: str) -> ContestListResult: + return ContestListResult(success=False, error=msg) - async def _safe_execute( - self, - operation: str, - func: Callable[P, Awaitable[Any]], - *args: P.args, - **kwargs: P.kwargs, - ): - try: - return await func(*args, **kwargs) - except Exception as e: - if operation == "metadata": - contest_id = cast(str, args[0]) if args else "" - return self._create_metadata_error(str(e), contest_id) - elif operation == "tests": - problem_id = cast(str, args[1]) if len(args) > 1 else "" - return self._create_tests_error(str(e), problem_id) - elif operation == "contests": - return self._create_contests_error(str(e)) - else: - raise + async def _run_cli_async(self, args: list[str]) -> int: + if len(args) < 2: + print(self._metadata_error(self._usage()).model_dump_json()) + return 1 + + mode = args[1] + + match mode: + case "metadata": + if len(args) != 3: + print(self._metadata_error(self._usage()).model_dump_json()) + return 1 + result = await self.scrape_contest_metadata(args[2]) + print(result.model_dump_json()) + return 0 if result.success else 1 + + case "tests": + if len(args) != 3: + print(self._tests_error(self._usage()).model_dump_json()) + return 1 + await self.stream_tests_for_category_async(args[2]) + return 0 + + case "contests": + if len(args) != 2: + print(self._contests_error(self._usage()).model_dump_json()) + return 1 + result = await self.scrape_contest_list() + print(result.model_dump_json()) + return 0 if result.success else 1 + + case _: + print( + self._metadata_error( + f"Unknown mode: {mode}. {self._usage()}" + ).model_dump_json() + ) + return 1 + + def run_cli(self) -> None: + sys.exit(asyncio.run(self._run_cli_async(sys.argv))) diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index 24f55f6..840616f 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -4,7 +4,6 @@ import asyncio import json import logging import re -import sys from typing import Any import requests @@ -13,13 +12,11 @@ from scrapling.fetchers import Fetcher from .base import BaseScraper from .models import ( - CombinedTest, ContestListResult, ContestSummary, MetadataResult, ProblemSummary, TestCase, - TestsResult, ) # suppress scrapling logging - https://github.com/D4Vinci/Scrapling/issues/31) @@ -209,49 +206,46 @@ class CodeforcesScraper(BaseScraper): return "codeforces" async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult: - async def impl(cid: str) -> MetadataResult: - problems = await asyncio.to_thread(_scrape_contest_problems_sync, cid) + try: + problems = await asyncio.to_thread( + _scrape_contest_problems_sync, contest_id + ) if not problems: - return self._create_metadata_error( - f"No problems found for contest {cid}", cid + return self._metadata_error( + f"No problems found for contest {contest_id}" ) return MetadataResult( success=True, error="", - contest_id=cid, + contest_id=contest_id, problems=problems, url=f"https://codeforces.com/contest/{contest_id}/problem/%s", ) - - return await self._safe_execute("metadata", impl, contest_id) + except Exception as e: + return self._metadata_error(str(e)) async def scrape_contest_list(self) -> ContestListResult: - async def impl() -> ContestListResult: - try: - r = requests.get(API_CONTEST_LIST_URL, timeout=TIMEOUT_SECONDS) - r.raise_for_status() - data = r.json() - if data.get("status") != "OK": - return self._create_contests_error("Invalid API response") + try: + r = requests.get(API_CONTEST_LIST_URL, timeout=TIMEOUT_SECONDS) + r.raise_for_status() + data = r.json() + if data.get("status") != "OK": + return self._contests_error("Invalid API response") - contests: list[ContestSummary] = [] - for c in data["result"]: - if c.get("phase") != "FINISHED": - continue - cid = str(c["id"]) - name = c["name"] - contests.append( - ContestSummary(id=cid, name=name, display_name=name) - ) + contests: list[ContestSummary] = [] + for c in data["result"]: + if c.get("phase") != "FINISHED": + continue + cid = str(c["id"]) + name = c["name"] + contests.append(ContestSummary(id=cid, name=name, display_name=name)) - if not contests: - return self._create_contests_error("No contests found") + if not contests: + return self._contests_error("No contests found") - return ContestListResult(success=True, error="", contests=contests) - except Exception as e: - return self._create_contests_error(str(e)) - - return await self._safe_execute("contests", impl) + return ContestListResult(success=True, error="", contests=contests) + except Exception as e: + return self._contests_error(str(e)) async def stream_tests_for_category_async(self, category_id: str) -> None: html = await asyncio.to_thread(_fetch_problems_html, category_id) @@ -281,73 +275,5 @@ class CodeforcesScraper(BaseScraper): ) -async def main_async() -> int: - if len(sys.argv) < 2: - result = MetadataResult( - success=False, - error="Usage: codeforces.py metadata OR codeforces.py tests OR codeforces.py contests", - url="", - ) - print(result.model_dump_json()) - return 1 - - mode: str = sys.argv[1] - scraper = CodeforcesScraper() - - if mode == "metadata": - if len(sys.argv) != 3: - result = MetadataResult( - success=False, - error="Usage: codeforces.py metadata ", - url="", - ) - print(result.model_dump_json()) - return 1 - contest_id = sys.argv[2] - result = await scraper.scrape_contest_metadata(contest_id) - print(result.model_dump_json()) - return 0 if result.success else 1 - - if mode == "tests": - if len(sys.argv) != 3: - tests_result = TestsResult( - success=False, - error="Usage: codeforces.py tests ", - problem_id="", - combined=CombinedTest(input="", expected=""), - tests=[], - timeout_ms=0, - memory_mb=0, - ) - print(tests_result.model_dump_json()) - return 1 - contest_id = sys.argv[2] - await scraper.stream_tests_for_category_async(contest_id) - return 0 - - if mode == "contests": - if len(sys.argv) != 2: - contest_result = ContestListResult( - success=False, error="Usage: codeforces.py contests" - ) - print(contest_result.model_dump_json()) - return 1 - contest_result = await scraper.scrape_contest_list() - print(contest_result.model_dump_json()) - return 0 if contest_result.success else 1 - - result = MetadataResult( - success=False, - error="Unknown mode. Use 'metadata ', 'tests ', or 'contests'", - url="", - ) - print(result.model_dump_json()) - return 1 - - -def main() -> None: - sys.exit(asyncio.run(main_async())) - - if __name__ == "__main__": - main() + CodeforcesScraper().run_cli() diff --git a/scrapers/cses.py b/scrapers/cses.py index 620cb7f..5440b34 100644 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -3,20 +3,17 @@ import asyncio import json import re -import sys from typing import Any import httpx from .base import BaseScraper from .models import ( - CombinedTest, ContestListResult, ContestSummary, MetadataResult, ProblemSummary, TestCase, - TestsResult, ) BASE_URL = "https://cses.fi" @@ -261,73 +258,5 @@ class CSESScraper(BaseScraper): print(json.dumps(payload), flush=True) -async def main_async() -> int: - if len(sys.argv) < 2: - result = MetadataResult( - success=False, - error="Usage: cses.py metadata OR cses.py tests OR cses.py contests", - url="", - ) - print(result.model_dump_json()) - return 1 - - mode: str = sys.argv[1] - scraper = CSESScraper() - - if mode == "metadata": - if len(sys.argv) != 3: - result = MetadataResult( - success=False, - error="Usage: cses.py metadata ", - url="", - ) - print(result.model_dump_json()) - return 1 - category_id = sys.argv[2] - result = await scraper.scrape_contest_metadata(category_id) - print(result.model_dump_json()) - return 0 if result.success else 1 - - if mode == "tests": - if len(sys.argv) != 3: - tests_result = TestsResult( - success=False, - error="Usage: cses.py tests ", - problem_id="", - combined=CombinedTest(input="", expected=""), - tests=[], - timeout_ms=0, - memory_mb=0, - ) - print(tests_result.model_dump_json()) - return 1 - category = sys.argv[2] - await scraper.stream_tests_for_category_async(category) - return 0 - - if mode == "contests": - if len(sys.argv) != 2: - contest_result = ContestListResult( - success=False, error="Usage: cses.py contests" - ) - print(contest_result.model_dump_json()) - return 1 - contest_result = await scraper.scrape_contest_list() - print(contest_result.model_dump_json()) - return 0 if contest_result.success else 1 - - result = MetadataResult( - success=False, - error=f"Unknown mode: {mode}. Use 'metadata ', 'tests ', or 'contests'", - url="", - ) - print(result.model_dump_json()) - return 1 - - -def main() -> None: - sys.exit(asyncio.run(main_async())) - - if __name__ == "__main__": - main() + CSESScraper().run_cli() From d5c6783124eaa24e9cb1ae67382fe4c94032f3af Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 15:43:40 -0500 Subject: [PATCH 11/38] feat(scrapers): refactor --- scrapers/codechef.py | 117 +++++++++---------------------------------- tests/conftest.py | 18 ++++--- 2 files changed, 35 insertions(+), 100 deletions(-) diff --git a/scrapers/codechef.py b/scrapers/codechef.py index 1680e83..c9e402c 100644 --- a/scrapers/codechef.py +++ b/scrapers/codechef.py @@ -10,13 +10,11 @@ from scrapling.fetchers import Fetcher from .base import BaseScraper from .models import ( - CombinedTest, ContestListResult, ContestSummary, MetadataResult, ProblemSummary, TestCase, - TestsResult, ) BASE_URL = "https://www.codechef.com" @@ -62,42 +60,40 @@ class CodeChefScraper(BaseScraper): return "codechef" async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult: - async with httpx.AsyncClient() as client: - try: + try: + async with httpx.AsyncClient() as client: data = await fetch_json( client, API_CONTEST.format(contest_id=contest_id) ) - except httpx.HTTPStatusError as e: - return self._create_metadata_error( - f"Failed to fetch contest {contest_id}: {e}", contest_id + if not data.get("problems"): + return self._metadata_error( + f"No problems found for contest {contest_id}" ) - if not data.get("problems"): - return self._create_metadata_error( - f"No problems found for contest {contest_id}", contest_id - ) - problems = [] - for problem_code, problem_data in data["problems"].items(): - if problem_data.get("category_name") == "main": - problems.append( - ProblemSummary( - id=problem_code, - name=problem_data.get("name", problem_code), + problems = [] + for problem_code, problem_data in data["problems"].items(): + if problem_data.get("category_name") == "main": + problems.append( + ProblemSummary( + id=problem_code, + name=problem_data.get("name", problem_code), + ) ) - ) - return MetadataResult( - success=True, - error="", - contest_id=contest_id, - problems=problems, - url=f"{BASE_URL}/{contest_id}", - ) + return MetadataResult( + success=True, + error="", + contest_id=contest_id, + problems=problems, + url=f"{BASE_URL}/{contest_id}", + ) + except Exception as e: + return self._metadata_error(f"Failed to fetch contest {contest_id}: {e}") async def scrape_contest_list(self) -> ContestListResult: async with httpx.AsyncClient() as client: try: data = await fetch_json(client, API_CONTESTS_ALL) except httpx.HTTPStatusError as e: - return self._create_contests_error(f"Failed to fetch contests: {e}") + return self._contests_error(f"Failed to fetch contests: {e}") all_contests = data.get("future_contests", []) + data.get( "past_contests", [] ) @@ -110,7 +106,7 @@ class CodeChefScraper(BaseScraper): num = int(match.group(1)) max_num = max(max_num, num) if max_num == 0: - return self._create_contests_error("No Starters contests found") + return self._contests_error("No Starters contests found") contests = [] sem = asyncio.Semaphore(CONNECTIONS) @@ -252,68 +248,5 @@ class CodeChefScraper(BaseScraper): print(json.dumps(payload), flush=True) -async def main_async() -> int: - if len(sys.argv) < 2: - result = MetadataResult( - success=False, - error="Usage: codechef.py metadata OR codechef.py tests OR codechef.py contests", - url="", - ) - print(result.model_dump_json()) - return 1 - mode: str = sys.argv[1] - scraper = CodeChefScraper() - if mode == "metadata": - if len(sys.argv) != 3: - result = MetadataResult( - success=False, - error="Usage: codechef.py metadata ", - url="", - ) - print(result.model_dump_json()) - return 1 - contest_id = sys.argv[2] - result = await scraper.scrape_contest_metadata(contest_id) - print(result.model_dump_json()) - return 0 if result.success else 1 - if mode == "tests": - if len(sys.argv) != 3: - tests_result = TestsResult( - success=False, - error="Usage: codechef.py tests ", - problem_id="", - combined=CombinedTest(input="", expected=""), - tests=[], - timeout_ms=0, - memory_mb=0, - ) - print(tests_result.model_dump_json()) - return 1 - contest_id = sys.argv[2] - await scraper.stream_tests_for_category_async(contest_id) - return 0 - if mode == "contests": - if len(sys.argv) != 2: - contest_result = ContestListResult( - success=False, error="Usage: codechef.py contests" - ) - print(contest_result.model_dump_json()) - return 1 - contest_result = await scraper.scrape_contest_list() - print(contest_result.model_dump_json()) - return 0 if contest_result.success else 1 - result = MetadataResult( - success=False, - error=f"Unknown mode: {mode}. Use 'metadata ', 'tests ', or 'contests'", - url="", - ) - print(result.model_dump_json()) - return 1 - - -def main() -> None: - sys.exit(asyncio.run(main_async())) - - if __name__ == "__main__": - main() + CodeChefScraper().run_cli() diff --git a/tests/conftest.py b/tests/conftest.py index 63e6108..bd84941 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -232,6 +232,13 @@ def run_scraper_offline(fixture_text): case _: raise AssertionError(f"Unknown scraper: {scraper_name}") + scraper_classes = { + "cses": "CSESScraper", + "atcoder": "AtcoderScraper", + "codeforces": "CodeforcesScraper", + "codechef": "CodeChefScraper", + } + def _run(scraper_name: str, mode: str, *args: str): mod_path = ROOT / "scrapers" / f"{scraper_name}.py" ns = _load_scraper_module(mod_path, scraper_name) @@ -249,16 +256,11 @@ def run_scraper_offline(fixture_text): httpx.AsyncClient.get = offline_fetches["__offline_get_async"] # type: ignore[assignment] fetchers.Fetcher.get = offline_fetches["Fetcher.get"] # type: ignore[assignment] - main_async = getattr(ns, "main_async") - assert callable(main_async), f"main_async not found in {scraper_name}" + scraper_class = getattr(ns, scraper_classes[scraper_name]) + scraper = scraper_class() argv = [str(mod_path), mode, *args] - old_argv = sys.argv - sys.argv = argv - try: - rc, out = _capture_stdout(main_async()) - finally: - sys.argv = old_argv + rc, out = _capture_stdout(scraper._run_cli_async(argv)) json_lines: list[Any] = [] for line in (_line for _line in out.splitlines() if _line.strip()): From 83514c453eb9b7e773b18f2ac2645b3a6f56a9ae Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 15:48:26 -0500 Subject: [PATCH 12/38] fix(ci): remove unused import --- scrapers/codechef.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapers/codechef.py b/scrapers/codechef.py index c9e402c..0687c1e 100644 --- a/scrapers/codechef.py +++ b/scrapers/codechef.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 + import asyncio import json import re -import sys from typing import Any import httpx From 89c1a3c683fb5b5cb73fee6afc27c82bb8df22cb Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 15:56:34 -0500 Subject: [PATCH 13/38] fix(ci): more fixes --- scrapers/atcoder.py | 36 ++++++++++++------------------------ scrapers/codeforces.py | 10 +++++----- tests/conftest.py | 8 ++++---- 3 files changed, 21 insertions(+), 33 deletions(-) diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 66b95aa..1b946dd 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -266,43 +266,31 @@ class AtcoderScraper(BaseScraper): return "atcoder" async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult: - async def impl(cid: str) -> MetadataResult: - try: - rows = await asyncio.to_thread(_scrape_tasks_sync, cid) - except requests.HTTPError as e: - if e.response is not None and e.response.status_code == 404: - return self._create_metadata_error( - f"No problems found for contest {cid}", cid - ) - raise - + try: + rows = await asyncio.to_thread(_scrape_tasks_sync, contest_id) problems = _to_problem_summaries(rows) if not problems: - return self._create_metadata_error( - f"No problems found for contest {cid}", cid + return self._metadata_error( + f"No problems found for contest {contest_id}" ) - return MetadataResult( success=True, error="", - contest_id=cid, + contest_id=contest_id, problems=problems, url=f"https://atcoder.jp/contests/{contest_id}/tasks/{contest_id}_%s", ) - - return await self._safe_execute("metadata", impl, contest_id) + except Exception as e: + return self._metadata_error(str(e)) async def scrape_contest_list(self) -> ContestListResult: - async def impl() -> ContestListResult: - try: - contests = await _fetch_all_contests_async() - except Exception as e: - return self._create_contests_error(str(e)) + try: + contests = await _fetch_all_contests_async() if not contests: - return self._create_contests_error("No contests found") + return self._contests_error("No contests found") return ContestListResult(success=True, error="", contests=contests) - - return await self._safe_execute("contests", impl) + except Exception as e: + return self._contests_error(str(e)) async def stream_tests_for_category_async(self, category_id: str) -> None: rows = await asyncio.to_thread(_scrape_tasks_sync, category_id) diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index 840616f..cf172b8 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -86,14 +86,14 @@ def _extract_samples(block: Tag) -> tuple[list[TestCase], bool]: if not st: return [], False - input_pres: list[Tag] = [ # type: ignore[misc] - inp.find("pre") # type: ignore[misc] - for inp in st.find_all("div", class_="input") # type: ignore[union-attr] + input_pres: list[Tag] = [ + inp.find("pre") + for inp in st.find_all("div", class_="input") if isinstance(inp, Tag) and inp.find("pre") ] output_pres: list[Tag] = [ - out.find("pre") # type: ignore[misc] - for out in st.find_all("div", class_="output") # type: ignore[union-attr] + out.find("pre") + for out in st.find_all("div", class_="output") if isinstance(out, Tag) and out.find("pre") ] input_pres = [p for p in input_pres if isinstance(p, Tag)] diff --git a/tests/conftest.py b/tests/conftest.py index bd84941..aaefec8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -245,16 +245,16 @@ def run_scraper_offline(fixture_text): offline_fetches = _make_offline_fetches(scraper_name) if scraper_name == "codeforces": - fetchers.Fetcher.get = offline_fetches["Fetcher.get"] # type: ignore[assignment] + fetchers.Fetcher.get = offline_fetches["Fetcher.get"] requests.get = offline_fetches["requests.get"] elif scraper_name == "atcoder": ns._fetch = offline_fetches["_fetch"] ns._get_async = offline_fetches["_get_async"] elif scraper_name == "cses": - httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"] # type: ignore[assignment] + httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"] elif scraper_name == "codechef": - httpx.AsyncClient.get = offline_fetches["__offline_get_async"] # type: ignore[assignment] - fetchers.Fetcher.get = offline_fetches["Fetcher.get"] # type: ignore[assignment] + httpx.AsyncClient.get = offline_fetches["__offline_get_async"] + fetchers.Fetcher.get = offline_fetches["Fetcher.get"] scraper_class = getattr(ns, scraper_classes[scraper_name]) scraper = scraper_class() From 282d7013270c230bb518da7d8368cccfbc07880c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 16:10:00 -0500 Subject: [PATCH 14/38] fix: minor log msg tweak --- lua/cp/setup.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 56519e4..fce1a0c 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -82,7 +82,7 @@ local function start_tests(platform, contest_id, problems) return not vim.tbl_isempty(cache.get_test_cases(platform, contest_id, p.id)) end, problems) if cached_len ~= #problems then - logger.log(('Fetching problem test data... (%d/%d)'):format(cached_len, #problems)) + logger.log(('Fetching %s/%s problem tests...'):format(cached_len, #problems)) scraper.scrape_all_tests(platform, contest_id, function(ev) local cached_tests = {} if not ev.interactive and vim.tbl_isempty(ev.tests) then From 0b21d02f243ddf63bb84ee4ee33f42d8ae0c457b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 16:42:16 -0500 Subject: [PATCH 15/38] fix(runner): save buffer before compile --- lua/cp/runner/execute.lua | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lua/cp/runner/execute.lua b/lua/cp/runner/execute.lua index a60ffa3..5c89c8a 100644 --- a/lua/cp/runner/execute.lua +++ b/lua/cp/runner/execute.lua @@ -177,6 +177,16 @@ function M.compile_problem(debug, on_complete) local language = state.get_language() or config.platforms[platform].default_language local eff = config.runtime.effective[platform][language] + local source_file = state.get_source_file() + if source_file then + local buf = vim.fn.bufnr(source_file) + if buf ~= -1 and vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].modified then + vim.api.nvim_buf_call(buf, function() + vim.cmd.write({ mods = { silent = true, noautocmd = true } }) + end) + end + end + local compile_config = (debug and eff.commands.debug) or eff.commands.build if not compile_config then From 9af359eb0117f227e012ef839a7d17f47cd0e517 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 16:47:42 -0500 Subject: [PATCH 16/38] feat(layout): cleanup mode labels --- lua/cp/ui/layouts.lua | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/lua/cp/ui/layouts.lua b/lua/cp/ui/layouts.lua index 4e737d3..6fe7391 100644 --- a/lua/cp/ui/layouts.lua +++ b/lua/cp/ui/layouts.lua @@ -3,6 +3,12 @@ local M = {} local helpers = require('cp.helpers') local utils = require('cp.utils') +local MODE_LABELS = { + none = 'none', + vim = 'vim', + git = 'git', +} + local function create_none_diff_layout(parent_win, expected_content, actual_content) local expected_buf = utils.create_buffer_with_options() local actual_buf = utils.create_buffer_with_options() @@ -21,8 +27,9 @@ local function create_none_diff_layout(parent_win, expected_content, actual_cont vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf }) vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf }) - vim.api.nvim_set_option_value('winbar', 'Expected', { win = expected_win }) - vim.api.nvim_set_option_value('winbar', 'Actual', { win = actual_win }) + local label = MODE_LABELS.none + vim.api.nvim_set_option_value('winbar', ('expected [%s]'):format(label), { win = expected_win }) + vim.api.nvim_set_option_value('winbar', ('actual [%s]'):format(label), { win = actual_win }) local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true }) local actual_lines = vim.split(actual_content, '\n', { plain = true }) @@ -33,6 +40,7 @@ local function create_none_diff_layout(parent_win, expected_content, actual_cont return { buffers = { expected_buf, actual_buf }, windows = { expected_win, actual_win }, + mode = 'none', cleanup = function() pcall(vim.api.nvim_win_close, expected_win, true) pcall(vim.api.nvim_win_close, actual_win, true) @@ -60,8 +68,9 @@ local function create_vim_diff_layout(parent_win, expected_content, actual_conte vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf }) vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf }) - vim.api.nvim_set_option_value('winbar', 'Expected', { win = expected_win }) - vim.api.nvim_set_option_value('winbar', 'Actual', { win = actual_win }) + local label = MODE_LABELS.vim + vim.api.nvim_set_option_value('winbar', ('Expected (%s)'):format(label), { win = expected_win }) + vim.api.nvim_set_option_value('winbar', ('Actual (%s)'):format(label), { win = actual_win }) local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true }) local actual_lines = vim.split(actual_content, '\n', { plain = true }) @@ -83,6 +92,7 @@ local function create_vim_diff_layout(parent_win, expected_content, actual_conte return { buffers = { expected_buf, actual_buf }, windows = { expected_win, actual_win }, + mode = 'vim', cleanup = function() pcall(vim.api.nvim_win_close, expected_win, true) pcall(vim.api.nvim_win_close, actual_win, true) @@ -103,7 +113,8 @@ local function create_git_diff_layout(parent_win, expected_content, actual_conte vim.api.nvim_win_set_buf(diff_win, diff_buf) vim.api.nvim_set_option_value('filetype', 'cp', { buf = diff_buf }) - vim.api.nvim_set_option_value('winbar', 'Expected vs Actual', { win = diff_win }) + local label = MODE_LABELS.git + vim.api.nvim_set_option_value('winbar', ('Diff (%s)'):format(label), { win = diff_win }) local diff_backend = require('cp.ui.diff') local backend = diff_backend.get_best_backend('git') @@ -121,6 +132,7 @@ local function create_git_diff_layout(parent_win, expected_content, actual_conte return { buffers = { diff_buf }, windows = { diff_win }, + mode = 'git', cleanup = function() pcall(vim.api.nvim_win_close, diff_win, true) pcall(vim.api.nvim_buf_delete, diff_buf, { force = true }) @@ -143,6 +155,7 @@ local function create_single_layout(parent_win, content) return { buffers = { buf }, windows = { win }, + mode = 'single', cleanup = function() pcall(vim.api.nvim_win_close, win, true) pcall(vim.api.nvim_buf_delete, buf, { force = true }) From ee38da50745c0ae7029139a08ee979c88db2b36e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 16:47:50 -0500 Subject: [PATCH 17/38] feat(layout): change formatting --- lua/cp/ui/layouts.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/cp/ui/layouts.lua b/lua/cp/ui/layouts.lua index 6fe7391..365ec78 100644 --- a/lua/cp/ui/layouts.lua +++ b/lua/cp/ui/layouts.lua @@ -69,8 +69,8 @@ local function create_vim_diff_layout(parent_win, expected_content, actual_conte vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf }) vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf }) local label = MODE_LABELS.vim - vim.api.nvim_set_option_value('winbar', ('Expected (%s)'):format(label), { win = expected_win }) - vim.api.nvim_set_option_value('winbar', ('Actual (%s)'):format(label), { win = actual_win }) + vim.api.nvim_set_option_value('winbar', ('expected [%s]'):format(label), { win = expected_win }) + vim.api.nvim_set_option_value('winbar', ('actual [%s]'):format(label), { win = actual_win }) local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true }) local actual_lines = vim.split(actual_content, '\n', { plain = true }) From 3348ac3e51017f4ff9ff3704c245000b1269b311 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 16:48:04 -0500 Subject: [PATCH 18/38] feat: improve formatting --- lua/cp/ui/layouts.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/cp/ui/layouts.lua b/lua/cp/ui/layouts.lua index 365ec78..387f0ec 100644 --- a/lua/cp/ui/layouts.lua +++ b/lua/cp/ui/layouts.lua @@ -114,7 +114,7 @@ local function create_git_diff_layout(parent_win, expected_content, actual_conte vim.api.nvim_set_option_value('filetype', 'cp', { buf = diff_buf }) local label = MODE_LABELS.git - vim.api.nvim_set_option_value('winbar', ('Diff (%s)'):format(label), { win = diff_win }) + vim.api.nvim_set_option_value('winbar', ('diff [%s]'):format(label), { win = diff_win }) local diff_backend = require('cp.ui.diff') local backend = diff_backend.get_best_backend('git') From d89a40b21f0058496db1a8e80ce744c13643e9fc Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 17:18:52 -0500 Subject: [PATCH 19/38] feat: update git formatting --- doc/cp.nvim.txt | 21 ++++++++++++------- lua/cp/config.lua | 49 ++++++++++++++++++++++++++++++++++--------- lua/cp/ui/layouts.lua | 47 +++++++++++++++++++++++++---------------- lua/cp/ui/views.lua | 6 +++--- 4 files changed, 84 insertions(+), 39 deletions(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 570beb5..866346f 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -258,7 +258,7 @@ Here's an example configuration with lazy.nvim: prev_test_key = '', -- or nil to disable }, panel = { - diff_mode = 'vim', + diff_modes = { 'side-by-side', 'git', 'vim' }, max_output_lines = 50, }, diff = { @@ -378,8 +378,10 @@ run CSES problems with Rust using the single schema: *cp.PanelConfig* Fields: ~ - {diff_mode} (string, default: "none") Diff backend: "none", - "vim", or "git". + {diff_modes} (string[], default: {'side-by-side', 'git', 'vim'}) + List of diff modes to cycle through with 't' key. + First element is the default mode. + Valid modes: 'side-by-side', 'git', 'vim'. {max_output_lines} (number, default: 50) Maximum lines of test output. *cp.DiffConfig* @@ -851,17 +853,20 @@ PANEL KEYMAPS *cp-panel-keys* Navigate to next test case Navigate to previous test case -t Cycle through diff modes: none → git → vim +t Cycle through configured diff modes (see |cp.PanelConfig|) q Exit panel and restore layout Exit interactive terminal and restore layout Diff Modes ~ -Three diff backends are available: +Three diff modes are available: - none Nothing - vim Built-in vim diff (default, always available) - git Character-level git word-diff (requires git, more precise) + side-by-side Expected and actual output shown side-by-side (default) + vim Built-in vim diff (always available) + git Character-level git word-diff (requires git, more precise) + +Configure which modes to cycle through via |cp.PanelConfig|.diff_modes. +The first element is used as the default mode. The git backend shows character-level changes with [-removed-] and {+added+} markers. diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 78f321f..ca5b027 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -18,7 +18,7 @@ ---@field overrides? table ---@class PanelConfig ----@field diff_mode "none"|"vim"|"git" +---@field diff_modes string[] ---@field max_output_lines integer ---@class DiffGitConfig @@ -173,7 +173,7 @@ M.defaults = { add_test_key = 'ga', save_and_exit_key = 'q', }, - panel = { diff_mode = 'none', max_output_lines = 50 }, + panel = { diff_modes = { 'side-by-side', 'git', 'vim' }, max_output_lines = 50 }, diff = { git = { args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' }, @@ -313,15 +313,26 @@ function M.setup(user_config) setup_io_output = { cfg.hooks.setup_io_output, { 'function', 'nil' }, true }, }) + local layouts = require('cp.ui.layouts') + local valid_modes_str = table.concat(vim.tbl_keys(layouts.DIFF_MODES), ',') + if type(cfg.ui.panel.diff_modes) == 'table' then + local invalid = {} + for _, mode in ipairs(cfg.ui.panel.diff_modes) do + if not layouts.DIFF_MODES[mode] then + table.insert(invalid, mode) + end + end + if #invalid > 0 then + error( + ('invalid diff modes [%s] - must be one of: {%s}'):format( + table.concat(invalid, ','), + valid_modes_str + ) + ) + end + end vim.validate({ ansi = { cfg.ui.ansi, 'boolean' }, - diff_mode = { - cfg.ui.panel.diff_mode, - function(v) - return vim.tbl_contains({ 'none', 'vim', 'git' }, v) - end, - "diff_mode must be 'none', 'vim', or 'git'", - }, max_output_lines = { cfg.ui.panel.max_output_lines, function(v) @@ -383,6 +394,13 @@ function M.setup(user_config) end, 'nil or non-empty string', }, + picker = { + cfg.ui.picker, + function(v) + return v == nil or v == 'telescope' or v == 'fzf-lua' + end, + "nil, 'telescope', or 'fzf-lua'", + }, }) for id, lang in pairs(cfg.languages) do @@ -443,7 +461,18 @@ function M.get_language_for_platform(platform_id, language_id) } end - local effective = cfg.runtime.effective[platform_id][language_id] + local platform_effective = cfg.runtime.effective[platform_id] + if not platform_effective then + return { + valid = false, + error = string.format( + 'No runtime config for platform %s (plugin not initialized)', + platform_id + ), + } + end + + local effective = platform_effective[language_id] if not effective then return { valid = false, diff --git a/lua/cp/ui/layouts.lua b/lua/cp/ui/layouts.lua index 387f0ec..9b40f49 100644 --- a/lua/cp/ui/layouts.lua +++ b/lua/cp/ui/layouts.lua @@ -3,13 +3,13 @@ local M = {} local helpers = require('cp.helpers') local utils = require('cp.utils') -local MODE_LABELS = { - none = 'none', +M.DIFF_MODES = { + ['side-by-side'] = 'side-by-side', vim = 'vim', git = 'git', } -local function create_none_diff_layout(parent_win, expected_content, actual_content) +local function create_side_by_side_layout(parent_win, expected_content, actual_content) local expected_buf = utils.create_buffer_with_options() local actual_buf = utils.create_buffer_with_options() helpers.clearcol(expected_buf) @@ -27,9 +27,13 @@ local function create_none_diff_layout(parent_win, expected_content, actual_cont vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf }) vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf }) - local label = MODE_LABELS.none - vim.api.nvim_set_option_value('winbar', ('expected [%s]'):format(label), { win = expected_win }) - vim.api.nvim_set_option_value('winbar', ('actual [%s]'):format(label), { win = actual_win }) + local label = M.DIFF_MODES['side-by-side'] + vim.api.nvim_set_option_value( + 'winbar', + ('expected (diff: %s)'):format(label), + { win = expected_win } + ) + vim.api.nvim_set_option_value('winbar', ('actual (diff: %s)'):format(label), { win = actual_win }) local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true }) local actual_lines = vim.split(actual_content, '\n', { plain = true }) @@ -40,7 +44,7 @@ local function create_none_diff_layout(parent_win, expected_content, actual_cont return { buffers = { expected_buf, actual_buf }, windows = { expected_win, actual_win }, - mode = 'none', + mode = 'side-by-side', cleanup = function() pcall(vim.api.nvim_win_close, expected_win, true) pcall(vim.api.nvim_win_close, actual_win, true) @@ -68,9 +72,13 @@ local function create_vim_diff_layout(parent_win, expected_content, actual_conte vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf }) vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf }) - local label = MODE_LABELS.vim - vim.api.nvim_set_option_value('winbar', ('expected [%s]'):format(label), { win = expected_win }) - vim.api.nvim_set_option_value('winbar', ('actual [%s]'):format(label), { win = actual_win }) + local label = M.DIFF_MODES.vim + vim.api.nvim_set_option_value( + 'winbar', + ('expected (diff: %s)'):format(label), + { win = expected_win } + ) + vim.api.nvim_set_option_value('winbar', ('actual (diff: %s)'):format(label), { win = actual_win }) local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true }) local actual_lines = vim.split(actual_content, '\n', { plain = true }) @@ -113,8 +121,8 @@ local function create_git_diff_layout(parent_win, expected_content, actual_conte vim.api.nvim_win_set_buf(diff_win, diff_buf) vim.api.nvim_set_option_value('filetype', 'cp', { buf = diff_buf }) - local label = MODE_LABELS.git - vim.api.nvim_set_option_value('winbar', ('diff [%s]'):format(label), { win = diff_win }) + local label = M.DIFF_MODES.git + vim.api.nvim_set_option_value('winbar', ('diff: %s'):format(label), { win = diff_win }) local diff_backend = require('cp.ui.diff') local backend = diff_backend.get_best_backend('git') @@ -166,12 +174,14 @@ end function M.create_diff_layout(mode, parent_win, expected_content, actual_content) if mode == 'single' then return create_single_layout(parent_win, actual_content) - elseif mode == 'none' then - return create_none_diff_layout(parent_win, expected_content, actual_content) + elseif mode == 'side-by-side' then + return create_side_by_side_layout(parent_win, expected_content, actual_content) elseif mode == 'git' then return create_git_diff_layout(parent_win, expected_content, actual_content) - else + elseif mode == 'vim' then return create_vim_diff_layout(parent_win, expected_content, actual_content) + else + return create_side_by_side_layout(parent_win, expected_content, actual_content) end end @@ -204,12 +214,13 @@ function M.update_diff_panes( actual_content = actual_content end - local desired_mode = is_compilation_failure and 'single' or config.ui.panel.diff_mode + local default_mode = config.ui.panel.diff_modes[1] + local desired_mode = is_compilation_failure and 'single' or (current_mode or default_mode) local highlight = require('cp.ui.highlight') local diff_namespace = highlight.create_namespace() local ansi_namespace = vim.api.nvim_create_namespace('cp_ansi_highlights') - if current_diff_layout and current_mode ~= desired_mode then + if current_diff_layout and current_diff_layout.mode ~= desired_mode then local saved_pos = vim.api.nvim_win_get_cursor(0) current_diff_layout.cleanup() current_diff_layout = nil @@ -264,7 +275,7 @@ function M.update_diff_panes( ansi_namespace ) end - elseif desired_mode == 'none' then + elseif desired_mode == 'side-by-side' then local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true }) local actual_lines = vim.split(actual_content, '\n', { plain = true }) utils.update_buffer_content(current_diff_layout.buffers[1], expected_lines, {}) diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index 4e06bd3..4b03b68 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -900,15 +900,15 @@ function M.toggle_panel(panel_opts) M.toggle_panel() end, { buffer = buf, silent = true }) vim.keymap.set('n', 't', function() - local modes = { 'none', 'git', 'vim' } + local modes = config.ui.panel.diff_modes local current_idx = 1 for i, mode in ipairs(modes) do - if config.ui.panel.diff_mode == mode then + if current_mode == mode then current_idx = i break end end - config.ui.panel.diff_mode = modes[(current_idx % #modes) + 1] + current_mode = modes[(current_idx % #modes) + 1] refresh_panel() end, { buffer = buf, silent = true }) vim.keymap.set('n', '', function() From 0a1cea9b434dbeb0bee3e05e7c5aba16049442d5 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 17:25:03 -0500 Subject: [PATCH 20/38] feat: debug --- lua/cp/config.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index ca5b027..ab99b50 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -305,6 +305,7 @@ function M.setup(user_config) vim.validate({ hooks = { cfg.hooks, { 'table' } }, ui = { cfg.ui, { 'table' } }, + debug = { cfg.debug, { 'boolean', 'nil' }, true }, open_url = { cfg.open_url, { 'boolean', 'nil' }, true }, before_run = { cfg.hooks.before_run, { 'function', 'nil' }, true }, before_debug = { cfg.hooks.before_debug, { 'function', 'nil' }, true }, From 3f677137de2de028c9d3fe52f230f76e418f93ac Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 17:27:15 -0500 Subject: [PATCH 21/38] fix(config): one of validation --- lua/cp/config.lua | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index ab99b50..e77b5a9 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -315,25 +315,23 @@ function M.setup(user_config) }) local layouts = require('cp.ui.layouts') - local valid_modes_str = table.concat(vim.tbl_keys(layouts.DIFF_MODES), ',') - if type(cfg.ui.panel.diff_modes) == 'table' then - local invalid = {} - for _, mode in ipairs(cfg.ui.panel.diff_modes) do - if not layouts.DIFF_MODES[mode] then - table.insert(invalid, mode) - end - end - if #invalid > 0 then - error( - ('invalid diff modes [%s] - must be one of: {%s}'):format( - table.concat(invalid, ','), - valid_modes_str - ) - ) - end - end vim.validate({ ansi = { cfg.ui.ansi, 'boolean' }, + diff_modes = { + cfg.ui.panel.diff_modes, + function(v) + if type(v) ~= 'table' then + return false + end + for _, mode in ipairs(v) do + if not layouts.DIFF_MODES[mode] then + return false + end + end + return true + end, + ('one of {%s}'):format(table.concat(vim.tbl_keys(layouts.DIFF_MODES), ',')), + }, max_output_lines = { cfg.ui.panel.max_output_lines, function(v) From 383b327442d4b1394a1620121400573e8bc29c87 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 17:32:21 -0500 Subject: [PATCH 22/38] fix(config): validate scraper names better --- lua/cp/config.lua | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index e77b5a9..4679301 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -307,6 +307,22 @@ function M.setup(user_config) ui = { cfg.ui, { 'table' } }, debug = { cfg.debug, { 'boolean', 'nil' }, true }, open_url = { cfg.open_url, { 'boolean', 'nil' }, true }, + filename = { cfg.filename, { 'function', 'nil' }, true }, + scrapers = { + cfg.scrapers, + function(v) + if type(v) ~= 'table' then + return false + end + for _, s in ipairs(v) do + if not vim.tbl_contains(constants.PLATFORMS, s) then + return false + end + end + return true + end, + ('one of {%s}'):format(table.concat(constants.PLATFORMS, ',')), + }, before_run = { cfg.hooks.before_run, { 'function', 'nil' }, true }, before_debug = { cfg.hooks.before_debug, { 'function', 'nil' }, true }, setup_code = { cfg.hooks.setup_code, { 'function', 'nil' }, true }, @@ -340,6 +356,14 @@ function M.setup(user_config) 'positive integer', }, git = { cfg.ui.diff.git, { 'table' } }, + git_args = { cfg.ui.diff.git.args, is_string_list, 'string[]' }, + width = { + cfg.ui.run.width, + function(v) + return type(v) == 'number' and v > 0 and v <= 1 + end, + 'number/decimal between 0 and 1', + }, next_test_key = { cfg.ui.run.next_test_key, function(v) From d496509fce77440b6cde47d26957d85871beb18c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 17:33:16 -0500 Subject: [PATCH 23/38] feat(config): improve config parsing phrasing --- lua/cp/config.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 4679301..dec8878 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -362,7 +362,7 @@ function M.setup(user_config) function(v) return type(v) == 'number' and v > 0 and v <= 1 end, - 'number/decimal between 0 and 1', + 'decimal between 0 and 1', }, next_test_key = { cfg.ui.run.next_test_key, From 0b5c0f0c405dd009def4b6de8e430af38aaeb8e8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 18:04:56 -0500 Subject: [PATCH 24/38] fix(ci): only run luarocks build on successful ci --- .github/workflows/ci.yaml | 111 ++++++++++++++++++++++++++++++++ .github/workflows/luarocks.yaml | 21 +++--- 2 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..fea1ba2 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,111 @@ +name: ci +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + lua: ${{ steps.changes.outputs.lua }} + python: ${{ steps.changes.outputs.python }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + lua: + - 'lua/**' + - 'spec/**' + - 'plugin/**' + - 'after/**' + - 'ftdetect/**' + - '*.lua' + - '.luarc.json' + - 'stylua.toml' + - 'selene.toml' + python: + - 'scripts/**' + - 'scrapers/**' + - 'tests/**' + - 'pyproject.toml' + - 'uv.lock' + + lua-format: + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.lua == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: JohnnyMorganz/stylua-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: 2.1.0 + args: --check . + + lua-lint: + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.lua == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: NTBBloodbath/selene-action@v1.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + args: --display-style quiet . + + lua-typecheck: + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.lua == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: mrcjkb/lua-typecheck-action@v0 + with: + checklevel: Warning + directories: lua + configpath: .luarc.json + + python-format: + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.python == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - run: uv tool install ruff + - run: ruff format --check . + + python-lint: + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.python == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - run: uv tool install ruff + - run: ruff check . + + python-typecheck: + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.python == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - run: uv sync --dev + - run: uvx ty check . + + python-test: + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.python == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - run: uv sync --dev + - run: uv run camoufox fetch + - run: uv run pytest tests/ -v diff --git a/.github/workflows/luarocks.yaml b/.github/workflows/luarocks.yaml index c64568f..b9bf08f 100644 --- a/.github/workflows/luarocks.yaml +++ b/.github/workflows/luarocks.yaml @@ -1,18 +1,17 @@ -name: Release - +name: luarocks on: - push: - tags: - - '*' - workflow_dispatch: - + workflow_run: + workflows: ["ci"] + types: + - completed jobs: - publish-luarocks: - name: Publish to LuaRocks + publish: + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Publish to LuaRocks - uses: nvim-neorocks/luarocks-tag-release@v7 + with: + ref: ${{ github.event.workflow_run.head_sha }} + - uses: nvim-neorocks/luarocks-tag-release@v7 env: LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }} From bd25f1db0beea2bc1fd150a046accce10723224b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 18:09:57 -0500 Subject: [PATCH 25/38] fix(ci): only run on tag push --- .github/workflows/luarocks.yaml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/luarocks.yaml b/.github/workflows/luarocks.yaml index b9bf08f..58af36c 100644 --- a/.github/workflows/luarocks.yaml +++ b/.github/workflows/luarocks.yaml @@ -1,17 +1,21 @@ name: luarocks + on: - workflow_run: - workflows: ["ci"] - types: - - completed + push: + tags: + - "v*" + jobs: + ci: + uses: ./.github/workflows/ci.yml + publish: - if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }} + needs: ci runs-on: ubuntu-latest + steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.event.workflow_run.head_sha }} + - uses: nvim-neorocks/luarocks-tag-release@v7 env: LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }} From 6966e8e101c02159e6ca2c2857b7715dc7ae52fc Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 18:14:54 -0500 Subject: [PATCH 26/38] feat: misc tests --- .github/workflows/luarocks.yaml | 2 +- .github/workflows/quality.yaml | 2 +- .github/workflows/test.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/luarocks.yaml b/.github/workflows/luarocks.yaml index 58af36c..95eaf0f 100644 --- a/.github/workflows/luarocks.yaml +++ b/.github/workflows/luarocks.yaml @@ -7,7 +7,7 @@ on: jobs: ci: - uses: ./.github/workflows/ci.yml + uses: ./.github/workflows/ci.yaml publish: needs: ci diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml index f6f27bc..731e74b 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -1,4 +1,4 @@ -name: Code Quality +name: quality on: pull_request: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4c1cc1f..ed7be0c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,4 +1,4 @@ -name: Tests +name: tests on: pull_request: From de1295d361dbadc9f5089e7c1879bf11ac0540f1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 18:19:49 -0500 Subject: [PATCH 27/38] fix ci --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fea1ba2..bf6bcea 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,5 +1,6 @@ name: ci on: + workflow_call: pull_request: branches: [main] push: From c06d81959702fc6616dfda678346c2429c13993a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 1 Feb 2026 17:01:29 -0500 Subject: [PATCH 28/38] fix(ci): fix rockspec url --- cp.nvim-scm-1.rockspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cp.nvim-scm-1.rockspec b/cp.nvim-scm-1.rockspec index e38d924..8152b7b 100644 --- a/cp.nvim-scm-1.rockspec +++ b/cp.nvim-scm-1.rockspec @@ -2,7 +2,7 @@ rockspec_format = '3.0' package = 'cp.nvim' version = 'scm-1' -source = { url = 'git://github.com/barrett-ruth/cp.nvim' } +source = { url = 'git://github.com/barrettruth/cp.nvim' } build = { type = 'builtin' } test_dependencies = { From 5cd6f75419466e4a10e0c4aaa18593fb49ddfebb Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 1 Feb 2026 17:11:51 -0500 Subject: [PATCH 29/38] fix username too --- doc/cp.nvim.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 866346f..1371ef5 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -208,7 +208,7 @@ CONFIGURATION *cp-config* Here's an example configuration with lazy.nvim: >lua { - 'barrett-ruth/cp.nvim', + 'barrettruth/cp.nvim', cmd = 'CP', build = 'uv sync', opts = { From c8f735617a4696d7cf024cb96b1e293568155828 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 2 Feb 2026 13:13:08 -0500 Subject: [PATCH 30/38] misc bugfixes --- lua/cp/runner/execute.lua | 2 ++ lua/cp/scraper.lua | 2 +- lua/cp/ui/views.lua | 31 +++++++++++++++++++++++++++++++ lua/cp/utils.lua | 4 ++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/lua/cp/runner/execute.lua b/lua/cp/runner/execute.lua index 5c89c8a..76d055a 100644 --- a/lua/cp/runner/execute.lua +++ b/lua/cp/runner/execute.lua @@ -194,6 +194,8 @@ function M.compile_problem(debug, on_complete) return end + require('cp.utils').ensure_dirs() + local binary = debug and state.get_debug_file() or state.get_binary_file() local substitutions = { source = state.get_source_file(), binary = binary } diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 3c0af30..c42d8be 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -186,7 +186,7 @@ function M.scrape_all_tests(platform, contest_id, callback) return end vim.schedule(function() - vim.system({ 'mkdir', '-p', 'build', 'io' }):wait() + require('cp.utils').ensure_dirs() local config = require('cp.config') local base_name = config.default_filename(contest_id, ev.problem_id) for i, t in ipairs(ev.tests) do diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index 4b03b68..c1d25fc 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -13,6 +13,7 @@ local utils = require('cp.utils') local current_diff_layout = nil local current_mode = nil +local io_view_running = false function M.disable() local active_panel = state.get_active_panel() @@ -390,6 +391,8 @@ function M.ensure_io_view() return end + require('cp.utils').ensure_dirs() + local source_file = state.get_source_file() if source_file then local source_file_abs = vim.fn.fnamemodify(source_file, ':p') @@ -622,6 +625,12 @@ local function render_io_view_results(io_state, test_indices, mode, combined_res end function M.run_io_view(test_indices_arg, debug, mode) + if io_view_running then + logger.log('Tests already running', vim.log.levels.WARN) + return + end + io_view_running = true + logger.log(('%s tests...'):format(debug and 'Debugging' or 'Running'), vim.log.levels.INFO, true) mode = mode or 'combined' @@ -633,6 +642,7 @@ function M.run_io_view(test_indices_arg, debug, mode) 'No platform/contest/problem configured. Use :CP [...] first.', vim.log.levels.ERROR ) + io_view_running = false return end @@ -640,6 +650,7 @@ function M.run_io_view(test_indices_arg, debug, mode) local contest_data = cache.get_contest_data(platform, contest_id) if not contest_data or not contest_data.index_map then logger.log('No test cases available.', vim.log.levels.ERROR) + io_view_running = false return end @@ -656,11 +667,13 @@ function M.run_io_view(test_indices_arg, debug, mode) local combined = cache.get_combined_test(platform, contest_id, problem_id) if not combined then logger.log('No combined test available', vim.log.levels.ERROR) + io_view_running = false return end else if not run.load_test_cases() then logger.log('No test cases available', vim.log.levels.ERROR) + io_view_running = false return end end @@ -681,6 +694,7 @@ function M.run_io_view(test_indices_arg, debug, mode) ), vim.log.levels.WARN ) + io_view_running = false return end end @@ -698,6 +712,7 @@ function M.run_io_view(test_indices_arg, debug, mode) local io_state = state.get_io_view_state() if not io_state then + io_view_running = false return end @@ -711,6 +726,7 @@ function M.run_io_view(test_indices_arg, debug, mode) execute.compile_problem(debug, function(compile_result) if not vim.api.nvim_buf_is_valid(io_state.output_buf) then + io_view_running = false return end @@ -730,6 +746,7 @@ function M.run_io_view(test_indices_arg, debug, mode) local ns = vim.api.nvim_create_namespace('cp_io_view_compile_error') utils.update_buffer_content(io_state.output_buf, lines, highlights, ns) + io_view_running = false return end @@ -737,6 +754,7 @@ function M.run_io_view(test_indices_arg, debug, mode) local combined = cache.get_combined_test(platform, contest_id, problem_id) if not combined then logger.log('No combined test found', vim.log.levels.ERROR) + io_view_running = false return end @@ -745,18 +763,21 @@ function M.run_io_view(test_indices_arg, debug, mode) run.run_combined_test(debug, function(result) if not result then logger.log('Failed to run combined test', vim.log.levels.ERROR) + io_view_running = false return end if vim.api.nvim_buf_is_valid(io_state.output_buf) then render_io_view_results(io_state, test_indices, mode, result, combined.input) end + io_view_running = false end) else run.run_all_test_cases(test_indices, debug, nil, function() if vim.api.nvim_buf_is_valid(io_state.output_buf) then render_io_view_results(io_state, test_indices, mode, nil, nil) end + io_view_running = false end) end end) @@ -859,6 +880,9 @@ function M.toggle_panel(panel_opts) end local function refresh_panel() + if state.get_active_panel() ~= 'run' then + return + end if not test_buffers.tab_buf or not vim.api.nvim_buf_is_valid(test_buffers.tab_buf) then return end @@ -884,6 +908,10 @@ function M.toggle_panel(panel_opts) vim.cmd.normal({ 'zz', bang = true }) end) end + + if test_windows.tab_win and vim.api.nvim_win_is_valid(test_windows.tab_win) then + vim.api.nvim_set_current_win(test_windows.tab_win) + end end local function navigate_test_case(delta) @@ -942,6 +970,9 @@ function M.toggle_panel(panel_opts) local function finalize_panel() vim.schedule(function() + if state.get_active_panel() ~= 'run' then + return + end if config.ui.ansi then require('cp.ui.ansi').setup_highlight_groups() end diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index c2f353a..654c2ef 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -262,4 +262,8 @@ function M.cwd_executables() return out end +function M.ensure_dirs() + vim.system({ 'mkdir', '-p', 'build', 'io' }):wait() +end + return M From f184a7874ae353d545367ac24076fd6cd3359900 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Feb 2026 01:38:13 -0500 Subject: [PATCH 31/38] feat: update docs --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 7d439f9..cc17d80 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,15 @@ https://github.com/user-attachments/assets/e81d8dfb-578f-4a79-9989-210164fc0148 - **Language agnostic**: Works with any language - **Diff viewer**: Compare expected vs actual output with 3 diff modes +## Installation + +Install using your package manager of choice or with +[luarocks](https://luarocks.org/modules/barrettruth/cp.nvim): + +``` +luarocks install cp.nvim +``` + ## Optional Dependencies - [uv](https://docs.astral.sh/uv/) for problem scraping From f9f993db0cbbe01ca63e79f290ec990e360d3438 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Feb 2026 01:39:26 -0500 Subject: [PATCH 32/38] fix: pre-commit syntax error --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 22e6d2e..410cfc2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,7 @@ repos: hooks: - id: prettier name: prettier - files: \.(md|,toml,yaml,sh)$ + files: \.(md,toml,yaml,sh)$ - repo: local hooks: From 01efc7c3441b7219909adb580c77a5531d2b29dc Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Feb 2026 01:41:35 -0500 Subject: [PATCH 33/38] fix(ci): prettier format --- .github/workflows/luarocks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/luarocks.yaml b/.github/workflows/luarocks.yaml index 95eaf0f..5be8b55 100644 --- a/.github/workflows/luarocks.yaml +++ b/.github/workflows/luarocks.yaml @@ -3,7 +3,7 @@ name: luarocks on: push: tags: - - "v*" + - 'v*' jobs: ci: From 08fb654d2341de8259e8e5c74ad8e636322b47d7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Feb 2026 01:43:13 -0500 Subject: [PATCH 34/38] format yml too in pre-commit --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 410cfc2..971ffbe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,7 @@ repos: hooks: - id: prettier name: prettier - files: \.(md,toml,yaml,sh)$ + files: \.(md|toml|ya?ml|sh)$ - repo: local hooks: From 11b8365aacdc59befe4c35bca97b35ac1aa96a6e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Feb 2026 01:49:47 -0500 Subject: [PATCH 35/38] via, not main --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cc17d80..cf82417 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ https://github.com/user-attachments/assets/e81d8dfb-578f-4a79-9989-210164fc0148 ## Installation -Install using your package manager of choice or with +Install using your package manager of choice or via [luarocks](https://luarocks.org/modules/barrettruth/cp.nvim): ``` From 1a7e9517baea7492db90c8b782efeb9479508ea1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Feb 2026 01:50:22 -0500 Subject: [PATCH 36/38] force --- new | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 new diff --git a/new b/new new file mode 100644 index 0000000..e69de29 From ec487aa489dfdf330cb08b6b5f696fd62b1b02c9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Feb 2026 16:12:47 -0500 Subject: [PATCH 37/38] feat: config update to viom.g --- doc/cp.nvim.txt | 115 +++++++++++++++++++++++------------------------- lua/cp/init.lua | 22 ++++----- 2 files changed, 66 insertions(+), 71 deletions(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 1371ef5..d6d1d73 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -205,71 +205,66 @@ Debug Builds ~ ============================================================================== CONFIGURATION *cp-config* -Here's an example configuration with lazy.nvim: +Configuration is done via `vim.g.cp_config`. Set this before using the plugin: >lua - { - 'barrettruth/cp.nvim', - cmd = 'CP', - build = 'uv sync', - opts = { - languages = { - cpp = { - extension = 'cc', - commands = { - build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}', - '-fdiagnostics-color=always' }, - run = { '{binary}' }, - debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined', - '{source}', '-o', '{binary}' }, - }, - }, - python = { - extension = 'py', - commands = { - run = { 'python', '{source}' }, - debug = { 'python', '{source}' }, - }, + vim.g.cp_config = { + languages = { + cpp = { + extension = 'cc', + commands = { + build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}', + '-fdiagnostics-color=always' }, + run = { '{binary}' }, + debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined', + '{source}', '-o', '{binary}' }, }, }, - platforms = { - cses = { - enabled_languages = { 'cpp', 'python' }, - default_language = 'cpp', - overrides = { - cpp = { extension = 'cpp', commands = { build = { ... } } } - }, - }, - atcoder = { - enabled_languages = { 'cpp', 'python' }, - default_language = 'cpp', - }, - codeforces = { - enabled_languages = { 'cpp', 'python' }, - default_language = 'cpp', + python = { + extension = 'py', + commands = { + run = { 'python', '{source}' }, + debug = { 'python', '{source}' }, }, }, - open_url = true, - debug = false, - ui = { - ansi = true, - run = { - width = 0.3, - next_test_key = '', -- or nil to disable - prev_test_key = '', -- or nil to disable + }, + platforms = { + cses = { + enabled_languages = { 'cpp', 'python' }, + default_language = 'cpp', + overrides = { + cpp = { extension = 'cpp', commands = { build = { ... } } } }, - panel = { - diff_modes = { 'side-by-side', 'git', 'vim' }, - max_output_lines = 50, - }, - diff = { - git = { - args = { 'diff', '--no-index', '--word-diff=plain', - '--word-diff-regex=.', '--no-prefix' }, - }, - }, - picker = 'telescope', }, - } + atcoder = { + enabled_languages = { 'cpp', 'python' }, + default_language = 'cpp', + }, + codeforces = { + enabled_languages = { 'cpp', 'python' }, + default_language = 'cpp', + }, + }, + open_url = true, + debug = false, + ui = { + ansi = true, + run = { + width = 0.3, + next_test_key = '', -- or nil to disable + prev_test_key = '', -- or nil to disable + }, + panel = { + diff_modes = { 'side-by-side', 'git', 'vim' }, + max_output_lines = 50, + }, + diff = { + git = { + args = { 'diff', '--no-index', '--word-diff=plain', + '--word-diff-regex=.', '--no-prefix' }, + }, + }, + picker = 'telescope', + }, } < @@ -279,7 +274,7 @@ the default; per-platform overrides can tweak 'extension' or 'commands'. For example, to run CodeForces contests with Python by default: >lua - { + vim.g.cp_config = { platforms = { codeforces = { default_language = 'python', @@ -290,7 +285,7 @@ For example, to run CodeForces contests with Python by default: Any language is supported provided the proper configuration. For example, to run CSES problems with Rust using the single schema: >lua - { + vim.g.cp_config = { languages = { rust = { extension = 'rs', diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 64a997d..fac3044 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -11,25 +11,25 @@ if vim.fn.has('nvim-0.10.0') == 0 then return {} end -local user_config = {} -local config = nil local initialized = false +local function ensure_initialized() + if initialized then + return + end + local user_config = vim.g.cp_config or {} + local config = config_module.setup(user_config) + config_module.set_current_config(config) + initialized = true +end + ---@return nil function M.handle_command(opts) + ensure_initialized() local commands = require('cp.commands') commands.handle_command(opts) end -function M.setup(opts) - opts = opts or {} - user_config = opts - config = config_module.setup(user_config) - config_module.set_current_config(config) - - initialized = true -end - function M.is_initialized() return initialized end From dc635d5167e465df06e8ffe0212e4ff029541b35 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Feb 2026 21:07:01 -0500 Subject: [PATCH 38/38] chore: add issue templates --- .github/ISSUE_TEMPLATE/bug_report.yaml | 78 +++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yaml | 5 ++ .github/ISSUE_TEMPLATE/feature_request.yaml | 30 ++++++++ 3 files changed, 113 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yaml create mode 100644 .github/ISSUE_TEMPLATE/config.yaml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yaml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..5742799 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,78 @@ +name: Bug Report +description: Report a bug +title: 'bug: ' +labels: [bug] +body: + - type: checkboxes + attributes: + label: Prerequisites + options: + - label: + I have searched [existing + issues](https://github.com/barrettruth/cp.nvim/issues) + required: true + - label: I have updated to the latest version + required: true + + - type: textarea + attributes: + label: 'Neovim version' + description: 'Output of `nvim --version`' + render: text + validations: + required: true + + - type: input + attributes: + label: 'Operating system' + placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04' + validations: + required: true + + - type: textarea + attributes: + label: Description + description: What happened? What did you expect? + validations: + required: true + + - type: textarea + attributes: + label: Steps to reproduce + description: Minimal steps to trigger the bug + value: | + 1. + 2. + 3. + validations: + required: true + + - type: textarea + attributes: + label: 'Health check' + description: 'Output of `:checkhealth cp`' + render: text + + - type: textarea + attributes: + label: Minimal reproduction + description: | + Save the script below as `repro.lua`, edit if needed, and run: + ``` + nvim -u repro.lua + ``` + Confirm the bug reproduces with this config before submitting. + render: lua + value: | + vim.env.LAZY_STDPATH = '.repro' + load(vim.fn.system('curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua'))() + require('lazy.nvim').setup({ + spec = { + { + 'barrett-ruth/cp.nvim', + opts = {}, + }, + }, + }) + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..12ef1b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions + url: https://github.com/barrettruth/cp.nvim/discussions + about: Ask questions and discuss ideas diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000..39c6692 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,30 @@ +name: Feature Request +description: Suggest a feature +title: 'feat: ' +labels: [enhancement] +body: + - type: checkboxes + attributes: + label: Prerequisites + options: + - label: + I have searched [existing + issues](https://github.com/barrettruth/cp.nvim/issues) + required: true + + - type: textarea + attributes: + label: Problem + description: What problem does this solve? + validations: + required: true + + - type: textarea + attributes: + label: Proposed solution + validations: + required: true + + - type: textarea + attributes: + label: Alternatives considered