From ba26cee7f9812ed5b9c64bb8e0ede02d66dcac92 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 27 Jan 2026 12:55:35 -0500 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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