From fb240fd501da993a79ae0dbe014ef965320b2c2b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 18 Sep 2025 10:20:20 -0400 Subject: [PATCH] feat: :CP test refactor --- doc/cp.txt | 48 +++++----------- lua/cp/cache.lua | 3 +- lua/cp/config.lua | 4 -- lua/cp/constants.lua | 2 +- lua/cp/execute.lua | 9 ++- lua/cp/init.lua | 134 ++++++++++++------------------------------- lua/cp/scrape.lua | 55 +++++++++--------- lua/cp/test.lua | 6 +- plugin/cp.lua | 33 +---------- readme.md | 3 +- 10 files changed, 97 insertions(+), 200 deletions(-) diff --git a/doc/cp.txt b/doc/cp.txt index 3cbdce5..47a64ce 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -49,17 +49,10 @@ Setup Commands ~ Action Commands ~ -:CP run Compile and run current problem with test input. - Shows execution time and output comparison. - Requires contest setup first. - -:CP debug Compile with debug flags and run current problem. - Includes sanitizers and debug symbols. - Requires contest setup first. - -:CP test Toggle test panel for individual test case +:CP test [--debug] Toggle test panel for individual test case debugging. Shows per-test results with three-pane layout for easy Expected/Actual comparison. + Use --debug flag to compile with debug flags Requires contest setup first. Navigation Commands ~ @@ -123,7 +116,6 @@ Optional configuration with lazy.nvim: > end, }, snippets = { ... }, -- LuaSnip snippets - tile = function(source_buf, input_buf, output_buf) ... end, filename = function(contest, contest_id, problem_id, config, language) ... end, } } @@ -139,8 +131,6 @@ Optional configuration with lazy.nvim: > during operation. • {scrapers} (`table`) Per-platform scraper control. Default enables all platforms. - • {tile}? (`function`) Custom window arrangement function. - `function(source_buf, input_buf, output_buf)` • {filename}? (`function`) Custom filename generation function. `function(contest, contest_id, problem_id, config, language)` Should return full filename with extension. @@ -170,9 +160,7 @@ Optional configuration with lazy.nvim: > *cp.Hooks* Fields: ~ - • {before_run}? (`function`) Called before `:CP run`. - `function(ctx: ProblemContext)` - • {before_debug}? (`function`) Called before `:CP debug`. + • {before_debug}? (`function`) Called before debug compilation. `function(ctx: ProblemContext)` • {setup_code}? (`function`) Called after source file is opened. Used to configure buffer settings. @@ -247,44 +235,38 @@ Example: Setting up and solving AtCoder contest ABC324 < This creates a.cc and scrapes test cases 4. Code your solution, then test: > - :CP run -< -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 +5. If needed, debug with sanitizers: > + :CP test --debug < -7. Move to next problem: > +6. Move to next problem: > :CP next < This automatically sets up problem B -7. Continue solving problems with :CP next/:CP prev navigation -8. Submit solutions on AtCoder website +6. Continue solving problems with :CP next/:CP prev navigation +7. Submit solutions on AtCoder website Example: Quick setup for single Codeforces problem > :CP codeforces 1933 a " One command setup - :CP run " Test immediately + :CP test " Test immediately < 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. - -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* -:CP test Toggle test panel on/off. When activated, +:CP test [--debug] Toggle test panel on/off. When activated, replaces current layout with test interface. Automatically compiles and runs all tests. - Toggle again to restore original layout. + Use --debug flag to compile with debug symbols + and sanitizers. Toggle again to restore original + layout. Interface ~ @@ -293,8 +275,6 @@ The test panel uses a three-pane layout for easy comparison: > ┌─ Test List ─────────────────────────────────────────────────┐ │ 1. PASS 12ms │ │> 2. FAIL 45ms │ - │ 3. 8ms │ - │ 4. │ │ │ │ ── Input ── │ │ 5 3 │ @@ -317,7 +297,7 @@ q Exit test panel (restore layout) Execution Details ~ Test cases are executed individually using the same compilation and -execution pipeline as |:CP-run|, but with isolated input/output for +execution pipeline, but with isolated input/output for precise failure analysis. All tests are automatically run when the panel opens. diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index a379415..3db7845 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -15,7 +15,8 @@ ---@class CachedTestCase ---@field index? number ---@field input string ----@field output string +---@field expected? string +---@field output? string local M = {} diff --git a/lua/cp/config.lua b/lua/cp/config.lua index d220207..c13f2dc 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -37,7 +37,6 @@ ---@field hooks Hooks ---@field debug boolean ---@field scrapers table ----@field tile? fun(source_buf: number, input_buf: number, output_buf: number) ---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string ---@class cp.UserConfig @@ -46,7 +45,6 @@ ---@field hooks? Hooks ---@field debug? boolean ---@field scrapers? table ----@field tile? fun(source_buf: number, input_buf: number, output_buf: number) ---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string local M = {} @@ -67,7 +65,6 @@ M.defaults = { return platform, true end) :totable(), - tile = nil, filename = nil, } @@ -85,7 +82,6 @@ function M.setup(user_config) hooks = { user_config.hooks, { "table", "nil" }, true }, debug = { user_config.debug, { "boolean", "nil" }, true }, scrapers = { user_config.scrapers, { "table", "nil" }, true }, - tile = { user_config.tile, { "function", "nil" }, true }, filename = { user_config.filename, { "function", "nil" }, true }, }) diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index 59693a8..7d5b155 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -1,7 +1,7 @@ local M = {} M.PLATFORMS = { "atcoder", "codeforces", "cses" } -M.ACTIONS = { "run", "debug", "test", "next", "prev" } +M.ACTIONS = { "test", "next", "prev" } M.CPP = "cpp" M.PYTHON = "python" diff --git a/lua/cp/execute.lua b/lua/cp/execute.lua index 9398d2c..58e17e5 100644 --- a/lua/cp/execute.lua +++ b/lua/cp/execute.lua @@ -193,8 +193,9 @@ end ---@param ctx ProblemContext ---@param contest_config ContestConfig +---@param is_debug? boolean ---@return boolean success -function M.compile_problem(ctx, contest_config) +function M.compile_problem(ctx, contest_config, is_debug) vim.validate({ ctx = { ctx, "table" }, contest_config = { contest_config, "table" }, @@ -214,13 +215,15 @@ function M.compile_problem(ctx, contest_config) version = tostring(language_config.version), } - if language_config.compile then + local compile_cmd = (is_debug and language_config.debug) and language_config.debug or language_config.compile + if compile_cmd then + language_config.compile = compile_cmd 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") + logger.log(("compilation successful (%s)"):format(is_debug and "debug mode" or "test mode")) end return true diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 07f8d3c..2aaf6be 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -4,7 +4,6 @@ local config_module = require("cp.config") local snippets = require("cp.snippets") local execute = require("cp.execute") local scrape = require("cp.scrape") -local window = require("cp.window") local logger = require("cp.log") local problem = require("cp.problem") local cache = require("cp.cache") @@ -133,13 +132,6 @@ local function setup_problem(contest_id, problem_id, language) config.hooks.setup_code(ctx) end - 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(src_buf, input_buf, output_buf) - logger.log(("switched to problem %s"):format(ctx.problem_name)) end @@ -152,60 +144,7 @@ local function get_current_problem() return filename end -local function run_problem() - local problem_id = get_current_problem() - if not problem_id then - return - end - - logger.log(("running problem: %s"):format(problem_id)) - - if not state.platform then - logger.log( - "No contest configured. Use :CP to set up first.", - vim.log.levels.ERROR - ) - return - end - - local contest_config = config.contests[state.platform] - local ctx = problem.create_context(state.platform, state.contest_id, state.problem_id, config) - - if config.hooks and config.hooks.before_run then - config.hooks.before_run(ctx) - end - - vim.schedule(function() - execute.run_problem(ctx, contest_config, false) - vim.cmd.checktime() - end) -end - -local function debug_problem() - local problem_id = get_current_problem() - if not problem_id then - return - end - - if not state.platform then - logger.log("no platform set", vim.log.levels.ERROR) - return - end - - local contest_config = config.contests[state.platform] - local ctx = problem.create_context(state.platform, state.contest_id, state.problem_id, config) - - if config.hooks and config.hooks.before_debug then - config.hooks.before_debug(ctx) - end - - vim.schedule(function() - execute.run_problem(ctx, contest_config, true) - vim.cmd.checktime() - end) -end - -local function toggle_test_panel() +local function toggle_test_panel(is_debug) if state.test_panel_active then if state.saved_session then vim.cmd(("source %s"):format(state.saved_session)) @@ -225,11 +164,6 @@ local function toggle_test_panel() 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 @@ -347,14 +281,14 @@ local function toggle_test_panel() return end - local expected_lines = {} - local expected_text = current_test.expected - for _, line in ipairs(vim.split(expected_text, "\n", { plain = true, trimempty = true })) do - table.insert(expected_lines, line) - end + local expected_lines = vim.split(expected_text, "\n", { plain = true, trimempty = true }) vim.api.nvim_buf_set_lines(test_buffers.expected_buf, 0, -1, false, expected_lines) + + if vim.fn.has("nvim-0.8.0") == 1 then + vim.api.nvim_set_option_value("winbar", "Expected", { win = test_windows.expected_win }) + end end local function update_actual_pane() @@ -366,27 +300,32 @@ local function toggle_test_panel() end local actual_lines = {} + local enable_diff = false if current_test.actual then - 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_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_set_option_value("diff", false, { win = test_windows.expected_win }) - vim.api.nvim_set_option_value("diff", false, { win = test_windows.actual_win }) - end + actual_lines = vim.split(current_test.actual, "\n", { plain = true, trimempty = true }) + enable_diff = current_test.status == "fail" else - table.insert(actual_lines, "(not run yet)") - - 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 }) + actual_lines = { "(not run yet)" } end vim.api.nvim_buf_set_lines(test_buffers.actual_buf, 0, -1, false, actual_lines) + + if vim.fn.has("nvim-0.8.0") == 1 then + vim.api.nvim_set_option_value("winbar", "Actual", { win = test_windows.actual_win }) + end + + vim.api.nvim_set_option_value("diff", enable_diff, { win = test_windows.expected_win }) + vim.api.nvim_set_option_value("diff", enable_diff, { win = test_windows.actual_win }) + + if enable_diff then + vim.api.nvim_win_call(test_windows.expected_win, function() + vim.cmd("diffthis") + end) + vim.api.nvim_win_call(test_windows.actual_win, function() + vim.cmd("diffthis") + end) + end end local function refresh_test_panel() @@ -430,9 +369,13 @@ local function toggle_test_panel() end, { buffer = buf, silent = true }) end + if is_debug and config.hooks and config.hooks.before_debug then + config.hooks.before_debug(ctx) + end + local execute_module = require("cp.execute") local contest_config = config.contests[state.platform] - if execute_module.compile_problem(ctx, contest_config) then + if execute_module.compile_problem(ctx, contest_config, is_debug) then test_module.run_all_test_cases(ctx, contest_config) end @@ -515,6 +458,7 @@ local function parse_command(args) end local language = nil + local debug = false for i, arg in ipairs(args) do local lang_match = arg:match("^--lang=(.+)$") @@ -526,17 +470,19 @@ local function parse_command(args) else return { type = "error", message = "--lang requires a value" } end + elseif arg == "--debug" then + debug = true end end local filtered_args = vim.tbl_filter(function(arg) - return not (arg:match("^--lang") or arg == language) + return not (arg:match("^--lang") or arg == language or arg == "--debug") end, args) local first = filtered_args[1] if vim.tbl_contains(actions, first) then - return { type = "action", action = first, language = language } + return { type = "action", action = first, language = language, debug = debug } end if vim.tbl_contains(platforms, first) then @@ -604,12 +550,8 @@ function M.handle_command(opts) end if cmd.type == "action" then - if cmd.action == "run" then - run_problem() - elseif cmd.action == "debug" then - debug_problem() - elseif cmd.action == "test" then - toggle_test_panel() + if cmd.action == "test" then + toggle_test_panel(cmd.debug) 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 4dc7855..0c088f1 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -1,3 +1,14 @@ +---@class ScraperTestCase +---@field input string +---@field expected string + +---@class ScraperResult +---@field success boolean +---@field problem_id string +---@field url? string +---@field tests? ScraperTestCase[] +---@field error? string + local M = {} local logger = require("cp.log") local cache = require("cp.cache") @@ -139,7 +150,7 @@ function M.scrape_contest_metadata(platform, contest_id) end ---@param ctx ProblemContext ----@return {success: boolean, problem_id: string, test_count?: number, test_cases?: table[], url?: string, error?: string} +---@return {success: boolean, problem_id: string, test_count?: number, test_cases?: ScraperTestCase[], url?: string, error?: string} function M.scrape_problem(ctx) vim.validate({ ctx = { ctx, "table" }, @@ -249,40 +260,32 @@ function M.scrape_problem(ctx) return data end - if data.test_cases and #data.test_cases > 0 then + if data.tests and #data.tests > 0 then local base_name = vim.fn.fnamemodify(ctx.input_file, ":r") - for i, test_case in ipairs(data.test_cases) do + for i, test_case in ipairs(data.tests) 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", "") + local expected_content = test_case.expected:gsub("\r", "") 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" - ) - - -- with atcoder, we combine together multiple test cases - -- TODO: per-platform settings to do this (i.e. do we stitch?) - if ctx.contest == "atcoder" then - combined_input = tostring(#data.test_cases) .. "\n" .. combined_input - end + local combined_input = table.concat( + vim.tbl_map(function(tc) + return tc.input + end, data.tests), + "\n" + ) + local combined_output = table.concat( + vim.tbl_map(function(tc) + return tc.expected + end, data.tests), + "\n" + ) vim.fn.writefile(vim.split(combined_input, "\n", true), ctx.input_file) vim.fn.writefile(vim.split(combined_output, "\n", true), ctx.expected_file) @@ -291,8 +294,8 @@ function M.scrape_problem(ctx) return { success = true, problem_id = ctx.problem_name, - test_count = data.test_cases and #data.test_cases or 0, - test_cases = data.test_cases, + test_count = data.tests and #data.tests or 0, + test_cases = data.tests, url = data.url, } end diff --git a/lua/cp/test.lua b/lua/cp/test.lua index c8144ff..6e47ae3 100644 --- a/lua/cp/test.lua +++ b/lua/cp/test.lua @@ -68,7 +68,8 @@ local function parse_test_cases_from_cache(platform, contest_id, problem_id) 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)) + local expected = test_case.expected or test_case.output + table.insert(test_cases, create_test_case(index, test_case.input, expected)) end return test_cases @@ -171,9 +172,6 @@ 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, { diff --git a/plugin/cp.lua b/plugin/cp.lua index 735ff89..f112ae4 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -14,37 +14,14 @@ end, { nargs = "*", desc = "Competitive programming helper", complete = function(ArgLead, CmdLine, _) - local languages = vim.tbl_keys(constants.canonical_filetypes) - - if ArgLead:match("^--lang=") then - local lang_completions = {} - for _, lang in ipairs(languages) do - table.insert(lang_completions, "--lang=" .. lang) - end - return vim.tbl_filter(function(completion) - return completion:find(ArgLead, 1, true) == 1 - end, lang_completions) - end - - if ArgLead:match("^%-") and not ArgLead:match("^--lang") then - return vim.tbl_filter(function(completion) - return completion:find(ArgLead, 1, true) == 1 - end, { "--lang" }) - end - local args = vim.split(vim.trim(CmdLine), "%s+") local num_args = #args if CmdLine:sub(-1) == " " then num_args = num_args + 1 end - local lang_flag_present = vim.tbl_contains(args, "--lang") - or vim.iter(args):any(function(arg) - return arg:match("^--lang=") - end) - if num_args == 2 then - local candidates = { "--lang" } + local candidates = {} local cp = require("cp") local context = cp.get_current_context() if context.platform and context.contest_id then @@ -63,17 +40,13 @@ end, { return vim.tbl_filter(function(cmd) return cmd:find(ArgLead, 1, true) == 1 end, candidates) - elseif args[#args - 1] == "--lang" then - return vim.tbl_filter(function(lang) - return lang:find(ArgLead, 1, true) == 1 - end, languages) - elseif num_args == 4 and not lang_flag_present then + elseif num_args == 4 then if vim.tbl_contains(platforms, args[2]) then local cache = require("cp.cache") cache.load() local contest_data = cache.get_contest_data(args[2], args[3]) if contest_data and contest_data.problems then - local candidates = { "--lang" } + local candidates = {} for _, problem in ipairs(contest_data.problems) do table.insert(candidates, problem.id) end diff --git a/readme.md b/readme.md index 2c4cf19..19fffc3 100644 --- a/readme.md +++ b/readme.md @@ -66,6 +66,7 @@ follows: ## TODO +- fzf/telescope integration (whichever available) +- autocomplete with --lang and --debug - finer-tuned problem limits (i.e. per-problem codeforces time, memory) -- better highlighting - notify discord members