Merge pull request #39 from barrett-ruth/feat/test-mode-horizontal

feat: panel formatting
This commit is contained in:
Barrett Ruth 2025-09-16 06:48:38 +02:00 committed by GitHub
commit ff3be54b7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 113 additions and 109 deletions

View file

@ -5,13 +5,13 @@ endif
syntax match cpOutputCode /^\[code\]:/
syntax match cpOutputTime /^\[time\]:/
syntax match cpOutputDebug /^\[debug\]:/
syntax match cpOutputMatchesTrue /^\[matches\]:\ze true$/
syntax match cpOutputMatchesFalse /^\[matches\]:\ze false$/
syntax match cpOutputOkTrue /^\[ok\]:\ze true$/
syntax match cpOutputOkFalse /^\[ok\]:\ze false$/
highlight default link cpOutputCode DiagnosticInfo
highlight default link cpOutputTime Comment
highlight default link cpOutputDebug Comment
highlight default link cpOutputMatchesTrue DiffAdd
highlight default link cpOutputMatchesFalse DiffDelete
highlight default link cpOutputOkTrue DiffAdd
highlight default link cpOutputOkFalse DiffDelete
let b:current_syntax = "cp"
let b:current_syntax = "cp"

View file

@ -70,23 +70,6 @@ Navigation Commands ~
:CP prev Navigate to previous problem in current contest.
Stops at first problem (no wrapping).
ERROR HANDLING *cp-errors*
cp.nvim provides clear error messages for common issues:
No Contest Configured ~
When running `:CP run`, `:CP debug`, or `:CP test` without setting up a
contest first, you'll see:
"No contest configured. Use :CP <platform> <contest> <problem> to set up first."
Platform Not Supported ~
For platforms or features not yet implemented:
"test panel not yet supported for codeforces"
Missing Dependencies ~
When required tools are missing:
"uv is not installed. Install it to enable problem scraping: https://docs.astral.sh/uv/"
CONFIGURATION *cp-config*
cp.nvim works out of the box. No setup required.
@ -283,7 +266,11 @@ TEST PANEL *cp-test*
The test panel provides individual test case debugging with a three-pane
layout showing test list, expected output, and actual output side-by-side.
Currently supported for AtCoder and CSES (Codeforces support coming soon).
Currently supported for AtCoder and CSES.
Note: Codeforces is not supported due to the ambiguity of identifying
individual test case output. See https://codeforces.com/blog/entry/138406
for ongoing efforts to resolve this.
Activation ~
*:CP-test*
@ -314,40 +301,14 @@ The test panel uses a three-pane layout for easy comparison: >
└─────────────────────────┘ └─────────────────────────┘
<
Test Status Indicators ~
PASS Test case passed (green highlighting)
FAIL Test case failed (red highlighting with diff)
(empty) Test case not yet executed
Keymaps ~
*cp-test-keys*
j / <Down> Navigate to next test case
k / <Up> Navigate to previous test case
q Exit test panel (restore layout)
Test Case Sources ~
Test cases are loaded in priority order:
1. Individual scraped test cases from cache (AtCoder, CSES)
2. Individual test case files (*.1.cpin, *.2.cpin, etc.)
3. Combined input/output files from io/ directory (fallback)
For AtCoder problems, individual test case files are prefixed with "1\n"
to satisfy template requirements, but this prefix is stripped in the UI
for clean display.
For CSES problems, individual test cases are loaded directly from scraped data.
Execution Details ~
Each test case shows:
• Input data provided to your solution (top pane)
• Expected output from the problem statement (bottom left pane)
• Actual output produced by your solution (bottom right pane)
• Execution time in milliseconds
• Pass/fail status with vim diff highlighting for failures
Test cases are executed individually using the same compilation and
execution pipeline as |:CP-run|, but with isolated input/output for
precise failure analysis. All tests are automatically run when the

View file

@ -21,4 +21,23 @@ M.canonical_filetypes = {
[M.PYTHON] = "python",
}
---@type table<number, string>
M.signal_codes = {
[128] = "SIGILL",
[130] = "SIGINT",
[131] = "SIGQUIT",
[132] = "SIGILL",
[133] = "SIGTRAP",
[134] = "SIGABRT",
[135] = "SIGBUS",
[136] = "SIGFPE",
[137] = "SIGKILL",
[138] = "SIGUSR1",
[139] = "SIGSEGV",
[140] = "SIGUSR2",
[141] = "SIGPIPE",
[142] = "SIGALRM",
[143] = "SIGTERM",
}
return M

View file

@ -64,24 +64,6 @@ local function build_command(cmd_template, executable, substitutions)
return cmd
end
local signal_codes = {
[128] = "SIGILL",
[130] = "SIGINT",
[131] = "SIGQUIT",
[132] = "SIGILL",
[133] = "SIGTRAP",
[134] = "SIGABRT",
[135] = "SIGBUS",
[136] = "SIGFPE",
[137] = "SIGKILL",
[138] = "SIGUSR1",
[139] = "SIGSEGV",
[140] = "SIGUSR2",
[141] = "SIGPIPE",
[142] = "SIGALRM",
[143] = "SIGTERM",
}
local function ensure_directories()
vim.system({ "mkdir", "-p", "build", "io" }):wait()
end
@ -176,7 +158,7 @@ local function format_output(exec_result, expected_file, is_debug)
if exec_result.timed_out then
table.insert(metadata_lines, "[code]: 124 (TIMEOUT)")
elseif exec_result.code >= 128 then
local signal_name = signal_codes[exec_result.code] or "SIGNAL"
local signal_name = constants.signal_codes[exec_result.code] or "SIGNAL"
table.insert(metadata_lines, ("[code]: %d (%s)"):format(exec_result.code, signal_name))
else
table.insert(metadata_lines, ("[code]: %d"):format(exec_result.code))
@ -193,17 +175,17 @@ local function format_output(exec_result, expected_file, is_debug)
table.remove(actual_lines)
end
local matches = #actual_lines == #expected_content
if matches then
local ok = #actual_lines == #expected_content
if ok then
for i, line in ipairs(actual_lines) do
if line ~= expected_content[i] then
matches = false
ok = false
break
end
end
end
table.insert(metadata_lines, ("[matches]: %s"):format(matches and "true" or "false"))
table.insert(metadata_lines, ("[ok]: %s"):format(ok and "true" or "false"))
end
return table.concat(output_lines, "") .. "\n" .. table.concat(metadata_lines, "\n")

View file

@ -248,23 +248,23 @@ local function toggle_test_panel()
local expected_buf = vim.api.nvim_create_buf(false, true)
local actual_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(tab_buf, "bufhidden", "wipe")
vim.api.nvim_buf_set_option(expected_buf, "bufhidden", "wipe")
vim.api.nvim_buf_set_option(actual_buf, "bufhidden", "wipe")
vim.api.nvim_set_option_value("bufhidden", "wipe", { buf = tab_buf })
vim.api.nvim_set_option_value("bufhidden", "wipe", { buf = expected_buf })
vim.api.nvim_set_option_value("bufhidden", "wipe", { buf = actual_buf })
local main_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(main_win, tab_buf)
vim.api.nvim_buf_set_option(tab_buf, "filetype", "cptest")
vim.api.nvim_set_option_value("filetype", "cptest", { buf = tab_buf })
vim.cmd("split")
local content_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(content_win, actual_buf)
vim.api.nvim_buf_set_option(actual_buf, "filetype", "cptest")
vim.api.nvim_set_option_value("filetype", "cptest", { buf = actual_buf })
vim.cmd("vsplit")
local expected_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(expected_win, expected_buf)
vim.api.nvim_buf_set_option(expected_buf, "filetype", "cptest")
vim.api.nvim_set_option_value("filetype", "cptest", { buf = expected_buf })
local test_windows = {
tab_win = main_win,
@ -281,10 +281,45 @@ local function toggle_test_panel()
local test_state = test_module.get_test_panel_state()
local tab_lines = {}
local max_status_width = 0
local max_code_width = 0
local max_time_width = 0
for _, test_case in ipairs(test_state.test_cases) do
local status_text = test_case.status == "pending" and "" or string.upper(test_case.status)
max_status_width = math.max(max_status_width, #status_text)
if test_case.code then
max_code_width = math.max(max_code_width, #tostring(test_case.code))
end
if test_case.time_ms then
local time_text = string.format("%.0fms", test_case.time_ms)
max_time_width = math.max(max_time_width, #time_text)
end
end
for i, test_case in ipairs(test_state.test_cases) do
local status_text = test_case.status == "pending" and "?" or string.upper(test_case.status)
local prefix = i == test_state.current_index and "> " or " "
local tab = string.format("%s%d. %s", prefix, i, status_text)
local tab = string.format("%s%d.", prefix, i)
if test_case.ok ~= nil then
tab = tab .. string.format(" [ok:%-5s]", tostring(test_case.ok))
end
if test_case.code then
tab = tab .. string.format(" [code:%-" .. max_code_width .. "s]", tostring(test_case.code))
end
if test_case.time_ms then
local time_text = string.format("%.0fms", test_case.time_ms)
tab = tab .. string.format(" [time:%-" .. max_time_width .. "s]", time_text)
end
if test_case.signal then
tab = tab .. string.format(" [%s]", test_case.signal)
end
table.insert(tab_lines, tab)
end
@ -292,7 +327,6 @@ local function toggle_test_panel()
if current_test then
table.insert(tab_lines, "")
table.insert(tab_lines, "Input:")
table.insert(tab_lines, "")
for _, line in ipairs(vim.split(current_test.input, "\n", { plain = true, trimempty = true })) do
table.insert(tab_lines, line)
end
@ -310,8 +344,6 @@ local function toggle_test_panel()
end
local expected_lines = {}
table.insert(expected_lines, "Expected:")
table.insert(expected_lines, "")
local expected_text = current_test.expected
for _, line in ipairs(vim.split(expected_text, "\n", { plain = true, trimempty = true })) do
@ -332,25 +364,22 @@ local function toggle_test_panel()
local actual_lines = {}
if current_test.actual then
table.insert(actual_lines, "Actual:")
table.insert(actual_lines, "")
for _, line in ipairs(vim.split(current_test.actual, "\n", { plain = true, trimempty = true })) do
table.insert(actual_lines, line)
end
if current_test.status == "fail" then
vim.api.nvim_win_set_option(test_windows.expected_win, "diff", true)
vim.api.nvim_win_set_option(test_windows.actual_win, "diff", true)
vim.api.nvim_set_option_value("diff", true, { win = test_windows.expected_win })
vim.api.nvim_set_option_value("diff", true, { win = test_windows.actual_win })
else
vim.api.nvim_win_set_option(test_windows.expected_win, "diff", false)
vim.api.nvim_win_set_option(test_windows.actual_win, "diff", false)
vim.api.nvim_set_option_value("diff", false, { win = test_windows.expected_win })
vim.api.nvim_set_option_value("diff", false, { win = test_windows.actual_win })
end
else
table.insert(actual_lines, "Actual:")
table.insert(actual_lines, "(not run yet)")
vim.api.nvim_win_set_option(test_windows.expected_win, "diff", false)
vim.api.nvim_win_set_option(test_windows.actual_win, "diff", false)
vim.api.nvim_set_option_value("diff", false, { win = test_windows.expected_win })
vim.api.nvim_set_option_value("diff", false, { win = test_windows.actual_win })
end
vim.api.nvim_buf_set_lines(test_buffers.actual_buf, 0, -1, false, actual_lines)

View file

@ -259,10 +259,6 @@ function M.scrape_problem(ctx)
local input_content = test_case.input:gsub("\r", "")
local expected_content = test_case.output:gsub("\r", "")
if ctx.contest == "atcoder" then
input_content = "1\n" .. input_content
end
vim.fn.writefile(vim.split(input_content, "\n", true), input_file)
vim.fn.writefile(vim.split(expected_content, "\n", true), expected_file)
end

View file

@ -7,6 +7,10 @@
---@field time_ms number?
---@field error string?
---@field selected boolean
---@field code number?
---@field ok boolean?
---@field signal string?
---@field timed_out boolean?
---@class TestPanelState
---@field test_cases TestCase[]
@ -18,6 +22,7 @@
local M = {}
local logger = require("cp.log")
local constants = require("cp.constants")
---@type TestPanelState
local test_panel_state = {
@ -89,10 +94,6 @@ local function parse_test_cases_from_files(input_file, expected_file)
local input_content = table.concat(vim.fn.readfile(individual_input_file), "\n")
local expected_content = table.concat(vim.fn.readfile(individual_expected_file), "\n")
if input_content:match("^1\n") then
input_content = input_content:gsub("^1\n", "")
end
table.insert(test_cases, create_test_case(i, input_content, expected_content))
i = i + 1
else
@ -115,7 +116,6 @@ end
---@return table
local function run_single_test_case(ctx, contest_config, test_case)
local language = vim.fn.fnamemodify(ctx.source_file, ":e")
local constants = require("cp.constants")
local language_name = constants.filetype_to_language[language] or contest_config.default_language
local language_config = contest_config[language_name]
@ -170,9 +170,14 @@ local function run_single_test_case(ctx, contest_config, test_case)
local run_cmd = build_command(language_config.run, language_config.executable, substitutions)
local stdin_content = test_case.input .. "\n"
if ctx.contest == "atcoder" then
stdin_content = "1\n" .. stdin_content
end
local start_time = vim.uv.hrtime()
local result = vim.system(run_cmd, {
stdin = test_case.input .. "\n",
stdin = stdin_content,
timeout = contest_config.timeout_ms or 2000,
text = true,
}):wait()
@ -180,22 +185,32 @@ local function run_single_test_case(ctx, contest_config, test_case)
local actual_output = (result.stdout or ""):gsub("\n$", "")
local expected_output = test_case.expected:gsub("\n$", "")
local matches = actual_output == expected_output
local ok = actual_output == expected_output
local status
if result.code == 143 or result.code == 124 then
local timed_out = result.code == 143 or result.code == 124
if timed_out then
status = "timeout"
elseif result.code == 0 and matches then
elseif result.code == 0 and ok then
status = "pass"
else
status = "fail"
end
local signal = nil
if result.code >= 128 then
signal = constants.signal_codes[result.code]
end
return {
status = status,
actual = actual_output,
error = result.code ~= 0 and result.stderr or nil,
time_ms = execution_time,
code = result.code,
ok = ok,
signal = signal,
timed_out = timed_out,
}
end
@ -235,6 +250,10 @@ function M.run_test_case(ctx, contest_config, index)
test_case.actual = result.actual
test_case.error = result.error
test_case.time_ms = result.time_ms
test_case.code = result.code
test_case.ok = result.ok
test_case.signal = result.signal
test_case.timed_out = result.timed_out
return true
end

View file

@ -2,7 +2,7 @@
neovim plugin for competitive programming.
https://private-user-images.githubusercontent.com/62671086/489116291-391976d1-c2f4-49e6-a79d-13ff05e9be86.mp4?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NTc3NDQ1ODEsIm5iZiI6MTc1Nzc0NDI4MSwicGF0aCI6Ii82MjY3MTA4Ni80ODkxMTYyOTEtMzkxOTc2ZDEtYzJmNC00OWU2LWE3OWQtMTNmZjA1ZTliZTg2Lm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA5MTMlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwOTEzVDA2MTgwMVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWI0Zjc0YmQzNWIzNGZkM2VjZjM3NGM0YmZmM2I3MmJkZGQ0YTczYjIxMTFiODc3MjQyMzY3ODc2ZTUxZDRkMzkmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.MBK5q_Zxr0gWuzfjwmSbB7P7dtWrATrT5cDOosdPRuQ
https://github.com/user-attachments/assets/cb142535-fba0-4280-8f11-66ad1ca50ca9
[video config](https://github.com/barrett-ruth/dots/blob/main/nvim/lua/plugins/cp.lua)
@ -68,6 +68,4 @@ follows:
- finer-tuned problem limits (i.e. per-problem codeforces time, memory)
- better highlighting
- test case management
- new video with functionality, notify discord members
- note that codeforces support is scuffed: https://codeforces.com/blog/entry/146423
- notify discord members