commit
f34b9554f7
14 changed files with 695 additions and 70 deletions
7
after/ftplugin/cptest.lua
Normal file
7
after/ftplugin/cptest.lua
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
vim.opt_local.number = false
|
||||
vim.opt_local.relativenumber = false
|
||||
vim.opt_local.statuscolumn = ""
|
||||
vim.opt_local.signcolumn = "no"
|
||||
vim.opt_local.foldcolumn = "0"
|
||||
vim.opt_local.wrap = true
|
||||
vim.opt_local.linebreak = true
|
||||
91
doc/cp.txt
91
doc/cp.txt
|
|
@ -55,6 +55,10 @@ Action Commands ~
|
|||
:CP debug Compile with debug flags and run current problem.
|
||||
Includes sanitizers and debug symbols.
|
||||
|
||||
:CP test Toggle test panel for individual test case
|
||||
debugging. Shows per-test results with
|
||||
vim-native navigation and execution controls.
|
||||
|
||||
Navigation Commands ~
|
||||
|
||||
:CP next Navigate to next problem in current contest.
|
||||
|
|
@ -130,7 +134,7 @@ Optional configuration with lazy.nvim: >
|
|||
• {filename}? (`function`) Custom filename generation function.
|
||||
`function(contest, contest_id, problem_id, config, language)`
|
||||
Should return full filename with extension.
|
||||
(default: uses problem_id or contest_id)
|
||||
(default: concats contest_id and problem id)
|
||||
|
||||
*cp.ContestConfig*
|
||||
|
||||
|
|
@ -235,10 +239,15 @@ Example: Setting up and solving AtCoder contest ABC324
|
|||
4. Code your solution, then test: >
|
||||
:CP run
|
||||
<
|
||||
5. If needed, debug: >
|
||||
5. If test fails, debug individual test cases: >
|
||||
:CP test
|
||||
< Navigate with j/k, run specific tests with <enter>
|
||||
Exit test panel with q or :CP test when done
|
||||
|
||||
6. If needed, compile with debug flags: >
|
||||
:CP debug
|
||||
<
|
||||
6. Move to next problem: >
|
||||
7. Move to next problem: >
|
||||
:CP next
|
||||
< This automatically sets up problem B
|
||||
|
||||
|
|
@ -250,6 +259,82 @@ Example: Quick setup for single Codeforces problem >
|
|||
:CP run " Test immediately
|
||||
<
|
||||
|
||||
TEST PANEL *cp-test*
|
||||
|
||||
The test panel provides individual test case debugging for competitive
|
||||
programming problems, particularly useful for Codeforces where multiple
|
||||
test cases are combined into single input/output files.
|
||||
|
||||
Activation ~
|
||||
*:CP-test*
|
||||
:CP test Toggle test panel on/off. When activated,
|
||||
replaces current layout with test interface.
|
||||
Toggle again to restore original layout.
|
||||
|
||||
Interface ~
|
||||
|
||||
The test panel displays a list of test cases with their status and details
|
||||
for the currently selected test case: >
|
||||
|
||||
┌─ Test Cases ───────────────────────────────────────────────┐
|
||||
│ 1 ✓ PASS 12ms │
|
||||
│ 2 ✗ FAIL 45ms │
|
||||
│> 3 ✓ PASS 8ms <-- current selection │
|
||||
│ 4 ? PENDING │
|
||||
│ │
|
||||
│ ── Test 3 ── │
|
||||
│ Input: │ Expected: │ Actual: │
|
||||
│ 5 3 │ 8 │ 8 │
|
||||
│ │ │ │
|
||||
│ │
|
||||
│ j/k: navigate <space>: toggle <enter>: run a: run all │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
<
|
||||
|
||||
Test Status Indicators ~
|
||||
|
||||
✓ PASS Test case passed (green)
|
||||
✗ FAIL Test case failed (red)
|
||||
? PENDING Test case not yet executed (yellow)
|
||||
⟳ RUNNING Test case currently executing (blue)
|
||||
|
||||
Keymaps ~
|
||||
*cp-test-keys*
|
||||
j / <Down> Navigate to next test case
|
||||
k / <Up> Navigate to previous test case
|
||||
<space> Toggle selection of current test case
|
||||
<enter> Run selected test cases
|
||||
a Run all test cases
|
||||
r Re-run failed test cases only
|
||||
c Clear all test results
|
||||
q / <esc> Exit test panel (restore layout)
|
||||
|
||||
Test Case Sources ~
|
||||
|
||||
Test cases are loaded in priority order:
|
||||
1. Individual scraped test cases (preferred for Codeforces)
|
||||
2. Combined input/output files from io/ directory (fallback)
|
||||
|
||||
For Codeforces problems, the plugin attempts to parse individual test
|
||||
cases from the scraped contest data, enabling precise debugging of
|
||||
specific test case failures.
|
||||
|
||||
For AtCoder and CSES problems, which typically provide single test
|
||||
cases, the combined input/output approach is used.
|
||||
|
||||
Execution Details ~
|
||||
|
||||
Each test case shows:
|
||||
• Input data provided to your solution
|
||||
• Expected output from the problem statement
|
||||
• Actual output produced by your solution
|
||||
• Execution time in milliseconds
|
||||
• Error messages (if execution failed)
|
||||
|
||||
Test cases are executed individually using the same compilation and
|
||||
execution pipeline as |:CP-run|, but with isolated input/output for
|
||||
precise failure analysis.
|
||||
|
||||
FILE STRUCTURE *cp-files*
|
||||
|
||||
cp.nvim creates the following file structure upon problem setup:
|
||||
|
|
|
|||
|
|
@ -5,14 +5,15 @@
|
|||
---@field problems Problem[]
|
||||
---@field scraped_at string
|
||||
---@field expires_at? number
|
||||
---@field test_cases? TestCase[]
|
||||
---@field test_cases? CachedTestCase[]
|
||||
---@field test_cases_cached_at? number
|
||||
|
||||
---@class Problem
|
||||
---@field id string
|
||||
---@field name? string
|
||||
|
||||
---@class TestCase
|
||||
---@class CachedTestCase
|
||||
---@field index? number
|
||||
---@field input string
|
||||
---@field output string
|
||||
|
||||
|
|
@ -146,7 +147,7 @@ end
|
|||
---@param platform string
|
||||
---@param contest_id string
|
||||
---@param problem_id? string
|
||||
---@return TestCase[]?
|
||||
---@return CachedTestCase[]?
|
||||
function M.get_test_cases(platform, contest_id, problem_id)
|
||||
vim.validate({
|
||||
platform = { platform, "string" },
|
||||
|
|
@ -164,7 +165,7 @@ end
|
|||
---@param platform string
|
||||
---@param contest_id string
|
||||
---@param problem_id? string
|
||||
---@param test_cases TestCase[]
|
||||
---@param test_cases CachedTestCase[]
|
||||
function M.set_test_cases(platform, contest_id, problem_id, test_cases)
|
||||
vim.validate({
|
||||
platform = { platform, "string" },
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
|
||||
|
||||
local M = {}
|
||||
local languages = require("cp.languages")
|
||||
local constants = require("cp.constants")
|
||||
|
||||
---@type cp.Config
|
||||
M.defaults = {
|
||||
|
|
@ -83,9 +83,21 @@ function M.setup(user_config)
|
|||
|
||||
if user_config.hooks then
|
||||
vim.validate({
|
||||
before_run = { user_config.hooks.before_run, { "function", "nil" }, true },
|
||||
before_debug = { user_config.hooks.before_debug, { "function", "nil" }, true },
|
||||
setup_code = { user_config.hooks.setup_code, { "function", "nil" }, true },
|
||||
before_run = {
|
||||
user_config.hooks.before_run,
|
||||
{ "function", "nil" },
|
||||
true,
|
||||
},
|
||||
before_debug = {
|
||||
user_config.hooks.before_debug,
|
||||
{ "function", "nil" },
|
||||
true,
|
||||
},
|
||||
setup_code = {
|
||||
user_config.hooks.setup_code,
|
||||
{ "function", "nil" },
|
||||
true,
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
|
|
@ -94,14 +106,14 @@ function M.setup(user_config)
|
|||
for lang_name, lang_config in pairs(contest_config) do
|
||||
if type(lang_config) == "table" and lang_config.extension then
|
||||
if
|
||||
not vim.tbl_contains(vim.tbl_keys(languages.filetype_to_language), lang_config.extension)
|
||||
not vim.tbl_contains(vim.tbl_keys(constants.filetype_to_language), lang_config.extension)
|
||||
then
|
||||
error(
|
||||
("Invalid extension '%s' for language '%s' in contest '%s'. Valid extensions: %s"):format(
|
||||
lang_config.extension,
|
||||
lang_name,
|
||||
contest_name,
|
||||
table.concat(vim.tbl_keys(languages.filetype_to_language), ", ")
|
||||
table.concat(vim.tbl_keys(constants.filetype_to_language), ", ")
|
||||
)
|
||||
)
|
||||
end
|
||||
|
|
@ -125,7 +137,7 @@ local function default_filename(contest_id, problem_id)
|
|||
})
|
||||
|
||||
if problem_id then
|
||||
return problem_id:lower()
|
||||
return (contest_id .. problem_id):lower()
|
||||
else
|
||||
return contest_id:lower()
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
local M = {}
|
||||
|
||||
M.PLATFORMS = { "atcoder", "codeforces", "cses" }
|
||||
M.ACTIONS = { "run", "debug", "test", "next", "prev" }
|
||||
|
||||
M.CPP = "cpp"
|
||||
M.PYTHON = "python"
|
||||
|
||||
|
|
@ -8,8 +8,8 @@
|
|||
local M = {}
|
||||
local logger = require("cp.log")
|
||||
|
||||
local languages = require("cp.languages")
|
||||
local filetype_to_language = languages.filetype_to_language
|
||||
local constants = require("cp.constants")
|
||||
local filetype_to_language = constants.filetype_to_language
|
||||
|
||||
---@param source_file string
|
||||
---@param contest_config table
|
||||
|
|
@ -89,7 +89,7 @@ end
|
|||
---@param language_config table
|
||||
---@param substitutions table<string, string>
|
||||
---@return {code: integer, stderr: string}
|
||||
local function compile_generic(language_config, substitutions)
|
||||
function M.compile_generic(language_config, substitutions)
|
||||
vim.validate({
|
||||
language_config = { language_config, "table" },
|
||||
substitutions = { substitutions, "table" },
|
||||
|
|
@ -210,8 +210,40 @@ local function format_output(exec_result, expected_file, is_debug)
|
|||
end
|
||||
|
||||
---@param ctx ProblemContext
|
||||
---@param contest_config table
|
||||
---@param is_debug boolean
|
||||
---@param contest_config ContestConfig
|
||||
---@return boolean success
|
||||
function M.compile_problem(ctx, contest_config)
|
||||
vim.validate({
|
||||
ctx = { ctx, "table" },
|
||||
contest_config = { contest_config, "table" },
|
||||
})
|
||||
|
||||
local language = get_language_from_file(ctx.source_file, contest_config)
|
||||
local language_config = contest_config[language]
|
||||
|
||||
if not language_config then
|
||||
logger.log("No configuration for language: " .. language, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
local substitutions = {
|
||||
source = ctx.source_file,
|
||||
binary = ctx.binary_file,
|
||||
version = tostring(language_config.version),
|
||||
}
|
||||
|
||||
if language_config.compile then
|
||||
local compile_result = M.compile_generic(language_config, substitutions)
|
||||
if compile_result.code ~= 0 then
|
||||
logger.log("compilation failed: " .. (compile_result.stderr or "unknown error"), vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
logger.log("compilation successful")
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
function M.run_problem(ctx, contest_config, is_debug)
|
||||
vim.validate({
|
||||
ctx = { ctx, "table" },
|
||||
|
|
@ -237,7 +269,7 @@ function M.run_problem(ctx, contest_config, is_debug)
|
|||
|
||||
local compile_cmd = is_debug and language_config.debug or language_config.compile
|
||||
if compile_cmd then
|
||||
local compile_result = compile_generic(language_config, substitutions)
|
||||
local compile_result = M.compile_generic(language_config, substitutions)
|
||||
if compile_result.code ~= 0 then
|
||||
vim.fn.writefile({ compile_result.stderr }, ctx.output_file)
|
||||
return
|
||||
|
|
|
|||
174
lua/cp/init.lua
174
lua/cp/init.lua
|
|
@ -27,10 +27,12 @@ local state = {
|
|||
saved_session = nil,
|
||||
test_cases = nil,
|
||||
test_states = {},
|
||||
test_panel_active = false,
|
||||
}
|
||||
|
||||
local platforms = { "atcoder", "codeforces", "cses" }
|
||||
local actions = { "run", "debug", "next", "prev" }
|
||||
local constants = require("cp.constants")
|
||||
local platforms = constants.PLATFORMS
|
||||
local actions = constants.ACTIONS
|
||||
|
||||
local function set_platform(platform)
|
||||
if not vim.tbl_contains(platforms, platform) then
|
||||
|
|
@ -93,11 +95,15 @@ local function setup_problem(contest_id, problem_id, language)
|
|||
end
|
||||
|
||||
vim.cmd.e(scrape_ctx.source_file)
|
||||
local source_buf = vim.api.nvim_get_current_buf()
|
||||
|
||||
if vim.api.nvim_buf_get_lines(0, 0, -1, true)[1] == "" then
|
||||
if vim.api.nvim_buf_get_lines(source_buf, 0, -1, true)[1] == "" then
|
||||
local has_luasnip, luasnip = pcall(require, "luasnip")
|
||||
if has_luasnip then
|
||||
local prefixed_trigger = ("cp.nvim/%s.%s"):format(state.platform, language)
|
||||
local filetype = vim.api.nvim_get_option_value("filetype", { buf = source_buf })
|
||||
local language_name = constants.filetype_to_language[filetype]
|
||||
local canonical_language = constants.canonical_filetypes[language_name] or language_name
|
||||
local prefixed_trigger = ("cp.nvim/%s.%s"):format(state.platform, canonical_language)
|
||||
|
||||
vim.api.nvim_buf_set_lines(0, 0, -1, false, { prefixed_trigger })
|
||||
vim.api.nvim_win_set_cursor(0, { 1, #prefixed_trigger })
|
||||
|
|
@ -123,12 +129,12 @@ local function setup_problem(contest_id, problem_id, language)
|
|||
config.hooks.setup_code(ctx)
|
||||
end
|
||||
|
||||
local source_buf = vim.api.nvim_get_current_buf()
|
||||
local src_buf = vim.api.nvim_get_current_buf()
|
||||
local input_buf = vim.fn.bufnr(ctx.input_file, true)
|
||||
local output_buf = vim.fn.bufnr(ctx.output_file, true)
|
||||
|
||||
local tile_fn = config.tile or window.default_tile
|
||||
tile_fn(source_buf, input_buf, output_buf)
|
||||
tile_fn(src_buf, input_buf, output_buf)
|
||||
|
||||
logger.log(("switched to problem %s"):format(ctx.problem_name))
|
||||
end
|
||||
|
|
@ -192,6 +198,140 @@ local function debug_problem()
|
|||
end)
|
||||
end
|
||||
|
||||
local function toggle_test_panel()
|
||||
if state.test_panel_active then
|
||||
if state.saved_session then
|
||||
vim.cmd(("source %s"):format(state.saved_session))
|
||||
vim.fn.delete(state.saved_session)
|
||||
state.saved_session = nil
|
||||
end
|
||||
state.test_panel_active = false
|
||||
logger.log("test panel closed")
|
||||
return
|
||||
end
|
||||
|
||||
if state.platform == "codeforces" then
|
||||
logger.log("test panel not yet supported for codeforces", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
local problem_id = get_current_problem()
|
||||
if not problem_id then
|
||||
return
|
||||
end
|
||||
|
||||
local ctx = problem.create_context(state.platform, state.contest_id, state.problem_id, config)
|
||||
local test_module = require("cp.test")
|
||||
|
||||
if not test_module.load_test_cases(ctx, state) then
|
||||
logger.log("no test cases found", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
state.saved_session = vim.fn.tempname()
|
||||
vim.cmd(("mksession! %s"):format(state.saved_session))
|
||||
|
||||
vim.cmd("silent only")
|
||||
|
||||
local test_buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_set_current_buf(test_buf)
|
||||
vim.bo.filetype = "cptest"
|
||||
vim.bo.bufhidden = "wipe"
|
||||
|
||||
local function refresh_test_panel()
|
||||
if not test_buf or not vim.api.nvim_buf_is_valid(test_buf) then
|
||||
return
|
||||
end
|
||||
|
||||
local test_state = test_module.get_test_panel_state()
|
||||
local test_lines = {}
|
||||
|
||||
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 line = string.format("%s%d %s", prefix, i, status_text)
|
||||
table.insert(test_lines, line)
|
||||
end
|
||||
|
||||
if test_state.test_cases[test_state.current_index] then
|
||||
local current_test = test_state.test_cases[test_state.current_index]
|
||||
table.insert(test_lines, "")
|
||||
table.insert(test_lines, string.format("── Test %d ──", test_state.current_index))
|
||||
|
||||
table.insert(test_lines, "Input:")
|
||||
for _, line in ipairs(vim.split(current_test.input, "\n", { plain = true, trimempty = true })) do
|
||||
table.insert(test_lines, line)
|
||||
end
|
||||
|
||||
table.insert(test_lines, "Expected:")
|
||||
for _, line in ipairs(vim.split(current_test.expected, "\n", { plain = true, trimempty = true })) do
|
||||
table.insert(test_lines, line)
|
||||
end
|
||||
|
||||
if current_test.actual then
|
||||
table.insert(test_lines, "Actual:")
|
||||
for _, line in ipairs(vim.split(current_test.actual, "\n", { plain = true, trimempty = true })) do
|
||||
table.insert(test_lines, line)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(test_lines, "")
|
||||
table.insert(test_lines, "[j/k] Navigate [Enter] Run all tests [q] Close")
|
||||
|
||||
vim.api.nvim_buf_set_lines(test_buf, 0, -1, false, test_lines)
|
||||
end
|
||||
|
||||
local function navigate_test_case(delta)
|
||||
local test_state = test_module.get_test_panel_state()
|
||||
if #test_state.test_cases == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
test_state.current_index = test_state.current_index + delta
|
||||
if test_state.current_index < 1 then
|
||||
test_state.current_index = #test_state.test_cases
|
||||
elseif test_state.current_index > #test_state.test_cases then
|
||||
test_state.current_index = 1
|
||||
end
|
||||
|
||||
refresh_test_panel()
|
||||
end
|
||||
|
||||
local function run_all_tests()
|
||||
local problem_ctx = problem.create_context(state.platform, state.contest_id, state.problem_id, config)
|
||||
local contest_config = config.contests[state.platform]
|
||||
local test_state = test_module.get_test_panel_state()
|
||||
|
||||
if test_state.test_cases and #test_state.test_cases > 0 then
|
||||
if not execute.compile_problem(problem_ctx, contest_config) then
|
||||
return
|
||||
end
|
||||
test_module.run_all_test_cases(problem_ctx, contest_config)
|
||||
refresh_test_panel()
|
||||
end
|
||||
end
|
||||
|
||||
vim.keymap.set("n", "j", function()
|
||||
navigate_test_case(1)
|
||||
end, { buffer = test_buf, silent = true })
|
||||
vim.keymap.set("n", "k", function()
|
||||
navigate_test_case(-1)
|
||||
end, { buffer = test_buf, silent = true })
|
||||
vim.keymap.set("n", "<CR>", function()
|
||||
run_all_tests()
|
||||
end, { buffer = test_buf, silent = true })
|
||||
vim.keymap.set("n", "q", function()
|
||||
toggle_test_panel()
|
||||
end, { buffer = test_buf, silent = true })
|
||||
|
||||
refresh_test_panel()
|
||||
|
||||
state.test_panel_active = true
|
||||
local test_state = test_module.get_test_panel_state()
|
||||
logger.log(string.format("test panel opened (%d test cases)", #test_state.test_cases))
|
||||
end
|
||||
|
||||
---@param delta number 1 for next, -1 for prev
|
||||
---@param language? string
|
||||
local function navigate_problem(delta, language)
|
||||
|
|
@ -286,12 +426,26 @@ local function parse_command(args)
|
|||
|
||||
if vim.tbl_contains(platforms, first) then
|
||||
if #filtered_args == 1 then
|
||||
return { type = "platform_only", platform = first, language = language }
|
||||
return {
|
||||
type = "platform_only",
|
||||
platform = first,
|
||||
language = language,
|
||||
}
|
||||
elseif #filtered_args == 2 then
|
||||
if first == "cses" then
|
||||
return { type = "cses_problem", platform = first, problem = filtered_args[2], language = language }
|
||||
return {
|
||||
type = "cses_problem",
|
||||
platform = first,
|
||||
problem = filtered_args[2],
|
||||
language = language,
|
||||
}
|
||||
else
|
||||
return { type = "contest_setup", platform = first, contest = filtered_args[2], language = language }
|
||||
return {
|
||||
type = "contest_setup",
|
||||
platform = first,
|
||||
contest = filtered_args[2],
|
||||
language = language,
|
||||
}
|
||||
end
|
||||
elseif #filtered_args == 3 then
|
||||
return {
|
||||
|
|
@ -326,6 +480,8 @@ function M.handle_command(opts)
|
|||
run_problem()
|
||||
elseif cmd.action == "debug" then
|
||||
debug_problem()
|
||||
elseif cmd.action == "test" then
|
||||
toggle_test_panel()
|
||||
elseif cmd.action == "next" then
|
||||
navigate_problem(1, cmd.language)
|
||||
elseif cmd.action == "prev" then
|
||||
|
|
|
|||
|
|
@ -79,9 +79,24 @@ function M.scrape_contest_metadata(platform, contest_id)
|
|||
|
||||
local args
|
||||
if platform == "cses" then
|
||||
args = { "uv", "run", "--directory", plugin_path, scraper_path, "metadata" }
|
||||
args = {
|
||||
"uv",
|
||||
"run",
|
||||
"--directory",
|
||||
plugin_path,
|
||||
scraper_path,
|
||||
"metadata",
|
||||
}
|
||||
else
|
||||
args = { "uv", "run", "--directory", plugin_path, scraper_path, "metadata", contest_id }
|
||||
args = {
|
||||
"uv",
|
||||
"run",
|
||||
"--directory",
|
||||
plugin_path,
|
||||
scraper_path,
|
||||
"metadata",
|
||||
contest_id,
|
||||
}
|
||||
end
|
||||
|
||||
local result = vim.system(args, {
|
||||
|
|
@ -133,10 +148,34 @@ function M.scrape_problem(ctx)
|
|||
ensure_io_directory()
|
||||
|
||||
if vim.fn.filereadable(ctx.input_file) == 1 and vim.fn.filereadable(ctx.expected_file) == 1 then
|
||||
local base_name = vim.fn.fnamemodify(ctx.input_file, ":r")
|
||||
local test_cases = {}
|
||||
local i = 1
|
||||
|
||||
while true do
|
||||
local input_file = base_name .. "." .. i .. ".cpin"
|
||||
local expected_file = base_name .. "." .. i .. ".cpout"
|
||||
|
||||
if vim.fn.filereadable(input_file) == 1 and vim.fn.filereadable(expected_file) == 1 then
|
||||
local input_content = table.concat(vim.fn.readfile(input_file), "\n")
|
||||
local expected_content = table.concat(vim.fn.readfile(expected_file), "\n")
|
||||
|
||||
table.insert(test_cases, {
|
||||
index = i,
|
||||
input = input_content,
|
||||
output = expected_content,
|
||||
})
|
||||
i = i + 1
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
success = true,
|
||||
problem_id = ctx.problem_name,
|
||||
test_count = 1,
|
||||
test_count = #test_cases,
|
||||
test_cases = test_cases,
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -161,9 +200,26 @@ function M.scrape_problem(ctx)
|
|||
|
||||
local args
|
||||
if ctx.contest == "cses" then
|
||||
args = { "uv", "run", "--directory", plugin_path, scraper_path, "tests", ctx.contest_id }
|
||||
args = {
|
||||
"uv",
|
||||
"run",
|
||||
"--directory",
|
||||
plugin_path,
|
||||
scraper_path,
|
||||
"tests",
|
||||
ctx.contest_id,
|
||||
}
|
||||
else
|
||||
args = { "uv", "run", "--directory", plugin_path, scraper_path, "tests", ctx.contest_id, ctx.problem_id }
|
||||
args = {
|
||||
"uv",
|
||||
"run",
|
||||
"--directory",
|
||||
plugin_path,
|
||||
scraper_path,
|
||||
"tests",
|
||||
ctx.contest_id,
|
||||
ctx.problem_id,
|
||||
}
|
||||
end
|
||||
|
||||
local result = vim.system(args, {
|
||||
|
|
@ -194,8 +250,41 @@ function M.scrape_problem(ctx)
|
|||
end
|
||||
|
||||
if data.test_cases and #data.test_cases > 0 then
|
||||
local combined_input = data.test_cases[1].input:gsub("\r", "")
|
||||
local combined_output = data.test_cases[1].output:gsub("\r", "")
|
||||
local base_name = vim.fn.fnamemodify(ctx.input_file, ":r")
|
||||
|
||||
for i, test_case in ipairs(data.test_cases) do
|
||||
local input_file = base_name .. "." .. i .. ".cpin"
|
||||
local expected_file = base_name .. "." .. i .. ".cpout"
|
||||
|
||||
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
|
||||
|
||||
local combined_input = data.combined and data.combined.input:gsub("\r", "")
|
||||
or table.concat(
|
||||
vim.tbl_map(function(tc)
|
||||
return tc.input
|
||||
end, data.test_cases),
|
||||
"\n"
|
||||
)
|
||||
local combined_output = data.combined and data.combined.output:gsub("\r", "")
|
||||
or table.concat(
|
||||
vim.tbl_map(function(tc)
|
||||
return tc.output
|
||||
end, data.test_cases),
|
||||
"\n"
|
||||
)
|
||||
|
||||
if ctx.contest == "atcoder" then
|
||||
combined_input = tostring(#data.test_cases) .. "\n" .. combined_input
|
||||
end
|
||||
|
||||
vim.fn.writefile(vim.split(combined_input, "\n", true), ctx.input_file)
|
||||
vim.fn.writefile(vim.split(combined_output, "\n", true), ctx.expected_file)
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ function M.setup(config)
|
|||
|
||||
local s, i, fmt = ls.snippet, ls.insert_node, require("luasnip.extras.fmt").fmt
|
||||
|
||||
local languages = require("cp.languages")
|
||||
local filetype_to_language = languages.filetype_to_language
|
||||
local constants = require("cp.constants")
|
||||
local filetype_to_language = constants.filetype_to_language
|
||||
|
||||
local language_to_filetype = {}
|
||||
for ext, lang in pairs(filetype_to_language) do
|
||||
|
|
@ -107,7 +107,7 @@ if __name__ == "__main__":
|
|||
|
||||
for language, template_set in pairs(template_definitions) do
|
||||
local snippets = {}
|
||||
local filetype = languages.canonical_filetypes[language]
|
||||
local filetype = constants.canonical_filetypes[language]
|
||||
|
||||
for contest, template in pairs(template_set) do
|
||||
local prefixed_trigger = ("cp.nvim/%s.%s"):format(contest, language)
|
||||
|
|
|
|||
255
lua/cp/test.lua
Normal file
255
lua/cp/test.lua
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
---@class TestCase
|
||||
---@field index number
|
||||
---@field input string
|
||||
---@field expected string
|
||||
---@field status "pending"|"pass"|"fail"|"running"|"timeout"
|
||||
---@field actual string?
|
||||
---@field time_ms number?
|
||||
---@field error string?
|
||||
---@field selected boolean
|
||||
|
||||
---@class TestPanelState
|
||||
---@field test_cases TestCase[]
|
||||
---@field current_index number
|
||||
---@field buffer number?
|
||||
---@field namespace number?
|
||||
---@field is_active boolean
|
||||
---@field saved_layout table?
|
||||
|
||||
local M = {}
|
||||
local logger = require("cp.log")
|
||||
|
||||
---@type TestPanelState
|
||||
local test_panel_state = {
|
||||
test_cases = {},
|
||||
current_index = 1,
|
||||
buffer = nil,
|
||||
namespace = nil,
|
||||
is_active = false,
|
||||
saved_layout = nil,
|
||||
}
|
||||
|
||||
---@param index number
|
||||
---@param input string
|
||||
---@param expected string
|
||||
---@return TestCase
|
||||
local function create_test_case(index, input, expected)
|
||||
return {
|
||||
index = index,
|
||||
input = input,
|
||||
expected = expected,
|
||||
status = "pending",
|
||||
actual = nil,
|
||||
time_ms = nil,
|
||||
error = nil,
|
||||
selected = true,
|
||||
}
|
||||
end
|
||||
|
||||
---@param platform string
|
||||
---@param contest_id string
|
||||
---@param problem_id string?
|
||||
---@return TestCase[]
|
||||
local function parse_test_cases_from_cache(platform, contest_id, problem_id)
|
||||
local cache = require("cp.cache")
|
||||
cache.load()
|
||||
local cached_test_cases = cache.get_test_cases(platform, contest_id, problem_id)
|
||||
|
||||
if not cached_test_cases or #cached_test_cases == 0 then
|
||||
return {}
|
||||
end
|
||||
|
||||
local test_cases = {}
|
||||
|
||||
for i, test_case in ipairs(cached_test_cases) do
|
||||
local index = test_case.index or i
|
||||
table.insert(test_cases, create_test_case(index, test_case.input, test_case.output))
|
||||
end
|
||||
|
||||
return test_cases
|
||||
end
|
||||
|
||||
---@param input_file string
|
||||
---@param expected_file string
|
||||
---@return TestCase[]
|
||||
local function parse_test_cases_from_files(input_file, expected_file)
|
||||
if vim.fn.filereadable(input_file) == 0 or vim.fn.filereadable(expected_file) == 0 then
|
||||
return {}
|
||||
end
|
||||
|
||||
local base_name = vim.fn.fnamemodify(input_file, ":r")
|
||||
local test_cases = {}
|
||||
local i = 1
|
||||
|
||||
while true do
|
||||
local individual_input_file = base_name .. "." .. i .. ".cpin"
|
||||
local individual_expected_file = base_name .. "." .. i .. ".cpout"
|
||||
|
||||
if vim.fn.filereadable(individual_input_file) == 1 and vim.fn.filereadable(individual_expected_file) == 1 then
|
||||
local input_content = table.concat(vim.fn.readfile(individual_input_file), "\n")
|
||||
local expected_content = table.concat(vim.fn.readfile(individual_expected_file), "\n")
|
||||
|
||||
table.insert(test_cases, create_test_case(i, input_content, expected_content))
|
||||
i = i + 1
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if #test_cases == 0 then
|
||||
local input_content = table.concat(vim.fn.readfile(input_file), "\n")
|
||||
local expected_content = table.concat(vim.fn.readfile(expected_file), "\n")
|
||||
return { create_test_case(1, input_content, expected_content) }
|
||||
end
|
||||
|
||||
return test_cases
|
||||
end
|
||||
|
||||
---@param ctx ProblemContext
|
||||
---@param contest_config ContestConfig
|
||||
---@param test_case TestCase
|
||||
---@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]
|
||||
|
||||
if not language_config then
|
||||
return {
|
||||
status = "fail",
|
||||
actual = "",
|
||||
error = "No language configuration",
|
||||
time_ms = 0,
|
||||
}
|
||||
end
|
||||
|
||||
local function substitute_template(cmd_template, substitutions)
|
||||
local result = {}
|
||||
for _, arg in ipairs(cmd_template) do
|
||||
local substituted = arg
|
||||
for key, value in pairs(substitutions) do
|
||||
substituted = substituted:gsub("{" .. key .. "}", value)
|
||||
end
|
||||
table.insert(result, substituted)
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function build_command(cmd_template, executable, substitutions)
|
||||
local cmd = substitute_template(cmd_template, substitutions)
|
||||
if executable then
|
||||
table.insert(cmd, 1, executable)
|
||||
end
|
||||
return cmd
|
||||
end
|
||||
|
||||
local substitutions = {
|
||||
source = ctx.source_file,
|
||||
binary = ctx.binary_file,
|
||||
version = tostring(language_config.version or ""),
|
||||
}
|
||||
|
||||
if language_config.compile and vim.fn.filereadable(ctx.binary_file) == 0 then
|
||||
logger.log("binary not found, compiling first...")
|
||||
local compile_cmd = substitute_template(language_config.compile, substitutions)
|
||||
local compile_result = vim.system(compile_cmd, { text = true }):wait()
|
||||
if compile_result.code ~= 0 then
|
||||
return {
|
||||
status = "fail",
|
||||
actual = "",
|
||||
error = "Compilation failed: " .. (compile_result.stderr or "Unknown error"),
|
||||
time_ms = 0,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
local run_cmd = build_command(language_config.run, language_config.executable, substitutions)
|
||||
|
||||
local start_time = vim.uv.hrtime()
|
||||
local result = vim.system(run_cmd, {
|
||||
stdin = test_case.input .. "\n",
|
||||
timeout = contest_config.timeout_ms or 2000,
|
||||
text = true,
|
||||
}):wait()
|
||||
local execution_time = (vim.uv.hrtime() - start_time) / 1000000
|
||||
|
||||
local actual_output = (result.stdout or ""):gsub("\n$", "")
|
||||
local expected_output = test_case.expected:gsub("\n$", "")
|
||||
local matches = actual_output == expected_output
|
||||
|
||||
local status
|
||||
if result.code == 143 or result.code == 124 then
|
||||
status = "timeout"
|
||||
elseif result.code == 0 and matches then
|
||||
status = "pass"
|
||||
else
|
||||
status = "fail"
|
||||
end
|
||||
|
||||
return {
|
||||
status = status,
|
||||
actual = actual_output,
|
||||
error = result.code ~= 0 and result.stderr or nil,
|
||||
time_ms = execution_time,
|
||||
}
|
||||
end
|
||||
|
||||
---@param ctx ProblemContext
|
||||
---@param state table
|
||||
---@return boolean
|
||||
function M.load_test_cases(ctx, state)
|
||||
local test_cases = parse_test_cases_from_cache(state.platform, state.contest_id, state.problem_id)
|
||||
|
||||
if #test_cases == 0 then
|
||||
test_cases = parse_test_cases_from_files(ctx.input_file, ctx.expected_file)
|
||||
end
|
||||
|
||||
test_panel_state.test_cases = test_cases
|
||||
test_panel_state.current_index = 1
|
||||
|
||||
logger.log(("loaded %d test case(s)"):format(#test_cases))
|
||||
return #test_cases > 0
|
||||
end
|
||||
|
||||
---@param ctx ProblemContext
|
||||
---@param contest_config ContestConfig
|
||||
---@param index number
|
||||
---@return boolean
|
||||
function M.run_test_case(ctx, contest_config, index)
|
||||
local test_case = test_panel_state.test_cases[index]
|
||||
if not test_case then
|
||||
return false
|
||||
end
|
||||
|
||||
logger.log(("running test case %d"):format(index))
|
||||
test_case.status = "running"
|
||||
|
||||
local result = run_single_test_case(ctx, contest_config, test_case)
|
||||
|
||||
test_case.status = result.status
|
||||
test_case.actual = result.actual
|
||||
test_case.error = result.error
|
||||
test_case.time_ms = result.time_ms
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
---@param ctx ProblemContext
|
||||
---@param contest_config ContestConfig
|
||||
---@return TestCase[]
|
||||
function M.run_all_test_cases(ctx, contest_config)
|
||||
local results = {}
|
||||
for i, _ in ipairs(test_panel_state.test_cases) do
|
||||
M.run_test_case(ctx, contest_config, i)
|
||||
table.insert(results, test_panel_state.test_cases[i])
|
||||
end
|
||||
return results
|
||||
end
|
||||
|
||||
---@return TestPanelState
|
||||
function M.get_test_panel_state()
|
||||
return test_panel_state
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -10,15 +10,7 @@
|
|||
---@field height integer
|
||||
|
||||
local M = {}
|
||||
local languages = require("cp.languages")
|
||||
|
||||
function M.clearcol()
|
||||
vim.api.nvim_set_option_value("number", false, { scope = "local" })
|
||||
vim.api.nvim_set_option_value("relativenumber", false, { scope = "local" })
|
||||
vim.api.nvim_set_option_value("statuscolumn", "", { scope = "local" })
|
||||
vim.api.nvim_set_option_value("signcolumn", "no", { scope = "local" })
|
||||
vim.api.nvim_set_option_value("foldcolumn", "0", { scope = "local" })
|
||||
end
|
||||
local constants = require("cp.constants")
|
||||
|
||||
---@return WindowState
|
||||
function M.save_layout()
|
||||
|
|
@ -79,7 +71,7 @@ function M.restore_layout(state, tile_fn)
|
|||
local source_file
|
||||
if source_files ~= "" then
|
||||
local files = vim.split(source_files, "\n")
|
||||
local valid_extensions = vim.tbl_keys(languages.filetype_to_language)
|
||||
local valid_extensions = vim.tbl_keys(constants.filetype_to_language)
|
||||
for _, file in ipairs(files) do
|
||||
local ext = vim.fn.fnamemodify(file, ":e")
|
||||
if vim.tbl_contains(valid_extensions, ext) then
|
||||
|
|
@ -136,12 +128,10 @@ local function default_tile(source_buf, input_buf, output_buf)
|
|||
vim.cmd.vsplit()
|
||||
vim.api.nvim_set_current_buf(output_buf)
|
||||
vim.bo.filetype = "cp"
|
||||
M.clearcol()
|
||||
vim.cmd(("vertical resize %d"):format(math.floor(vim.o.columns * 0.3)))
|
||||
vim.cmd.split()
|
||||
vim.api.nvim_set_current_buf(input_buf)
|
||||
vim.bo.filetype = "cp"
|
||||
M.clearcol()
|
||||
vim.cmd.wincmd("h")
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ if vim.g.loaded_cp then
|
|||
end
|
||||
vim.g.loaded_cp = 1
|
||||
|
||||
local platforms = { "atcoder", "codeforces", "cses" }
|
||||
local actions = { "run", "debug", "next", "prev" }
|
||||
local constants = require("cp.constants")
|
||||
local platforms = constants.PLATFORMS
|
||||
local actions = constants.ACTIONS
|
||||
|
||||
vim.api.nvim_create_user_command("CP", function(opts)
|
||||
local cp = require("cp")
|
||||
|
|
@ -13,8 +14,7 @@ end, {
|
|||
nargs = "*",
|
||||
desc = "Competitive programming helper",
|
||||
complete = function(ArgLead, CmdLine, _)
|
||||
local languages_module = require("cp.languages")
|
||||
local languages = vim.tbl_keys(languages_module.canonical_filetypes)
|
||||
local languages = vim.tbl_keys(constants.canonical_filetypes)
|
||||
|
||||
if ArgLead:match("^--lang=") then
|
||||
local lang_completions = {}
|
||||
|
|
@ -45,10 +45,10 @@ end, {
|
|||
|
||||
if num_args == 2 then
|
||||
local candidates = { "--lang" }
|
||||
vim.list_extend(candidates, actions)
|
||||
local cp = require("cp")
|
||||
local context = cp.get_current_context()
|
||||
if context.platform and context.contest_id then
|
||||
vim.list_extend(candidates, actions)
|
||||
local cache = require("cp.cache")
|
||||
cache.load()
|
||||
local contest_data = cache.get_contest_data(context.platform, context.contest_id)
|
||||
|
|
|
|||
|
|
@ -69,7 +69,5 @@ follows:
|
|||
- finer-tuned problem limits (i.e. per-problem codeforces time, memory)
|
||||
- better highlighting
|
||||
- test case management
|
||||
- USACO support
|
||||
- new video with functionality, notify discord members
|
||||
- note that codeforces support is scuffed: https://codeforces.com/blog/entry/146423
|
||||
- codeforces: use round number & api not the contest id
|
||||
|
|
|
|||
|
|
@ -169,24 +169,21 @@ def main() -> None:
|
|||
print(json.dumps(result))
|
||||
sys.exit(1)
|
||||
|
||||
test_cases: list[dict[str, str]] = []
|
||||
for input_data, output_data in tests:
|
||||
test_cases.append({"input": input_data, "output": output_data})
|
||||
|
||||
if test_cases:
|
||||
combined_input: str = (
|
||||
str(len(test_cases))
|
||||
+ "\n"
|
||||
+ "\n".join(tc["input"] for tc in test_cases)
|
||||
individual_test_cases: list[dict[str, str]] = []
|
||||
for index, (input_data, output_data) in enumerate(tests, 1):
|
||||
individual_test_cases.append(
|
||||
{"index": index, "input": input_data, "output": output_data}
|
||||
)
|
||||
combined_output: str = "\n".join(tc["output"] for tc in test_cases)
|
||||
test_cases = [{"input": combined_input, "output": combined_output}]
|
||||
|
||||
combined_input = "\n".join(tc["input"] for tc in individual_test_cases)
|
||||
combined_output = "\n".join(tc["output"] for tc in individual_test_cases)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"problem_id": problem_id,
|
||||
"url": url,
|
||||
"test_cases": test_cases,
|
||||
"test_cases": individual_test_cases,
|
||||
"combined": {"input": combined_input, "output": combined_output},
|
||||
}
|
||||
print(json.dumps(result))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue