diff --git a/after/ftplugin/cptest.lua b/after/ftplugin/cptest.lua new file mode 100644 index 0000000..166db14 --- /dev/null +++ b/after/ftplugin/cptest.lua @@ -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 diff --git a/doc/cp.txt b/doc/cp.txt index ad69836..e28d6b7 100644 --- a/doc/cp.txt +++ b/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 + 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 : toggle : 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 / Navigate to next test case +k / Navigate to previous test case + Toggle selection of current test case + Run selected test cases +a Run all test cases +r Re-run failed test cases only +c Clear all test results +q / 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: diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index 516ddb3..a379415 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -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" }, diff --git a/lua/cp/config.lua b/lua/cp/config.lua index d32e26c..f9e8abe 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -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 diff --git a/lua/cp/languages.lua b/lua/cp/constants.lua similarity index 73% rename from lua/cp/languages.lua rename to lua/cp/constants.lua index 134d312..e397c8f 100644 --- a/lua/cp/languages.lua +++ b/lua/cp/constants.lua @@ -1,5 +1,8 @@ local M = {} +M.PLATFORMS = { "atcoder", "codeforces", "cses" } +M.ACTIONS = { "run", "debug", "test", "next", "prev" } + M.CPP = "cpp" M.PYTHON = "python" diff --git a/lua/cp/execute.lua b/lua/cp/execute.lua index 78358bc..d341489 100644 --- a/lua/cp/execute.lua +++ b/lua/cp/execute.lua @@ -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 ---@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 diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 6190ae9..5fc55b2 100644 --- a/lua/cp/init.lua +++ b/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", "", 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 diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index bf40059..5fc6059 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -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) diff --git a/lua/cp/snippets.lua b/lua/cp/snippets.lua index eaadab9..d9805b4 100644 --- a/lua/cp/snippets.lua +++ b/lua/cp/snippets.lua @@ -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) diff --git a/lua/cp/test.lua b/lua/cp/test.lua new file mode 100644 index 0000000..8468717 --- /dev/null +++ b/lua/cp/test.lua @@ -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 diff --git a/lua/cp/window.lua b/lua/cp/window.lua index 9431a24..df549a0 100644 --- a/lua/cp/window.lua +++ b/lua/cp/window.lua @@ -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 diff --git a/plugin/cp.lua b/plugin/cp.lua index ee9e817..735ff89 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -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) diff --git a/readme.md b/readme.md index bbec5a4..121fd4b 100644 --- a/readme.md +++ b/readme.md @@ -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 diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 63bd3db..46d673d 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -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))