From 67d2a8054cbccde8356b4fc7728eb9455070b566 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 15 Sep 2025 07:05:31 -0500 Subject: [PATCH 01/14] feat: local state over vim.g --- lua/cp/health.lua | 14 +-- lua/cp/init.lua | 240 +++++++++++++++++++++++++++++++++++++++------- plugin/cp.lua | 8 +- readme.md | 4 + 4 files changed, 224 insertions(+), 42 deletions(-) diff --git a/lua/cp/health.lua b/lua/cp/health.lua index d902934..738e9e2 100644 --- a/lua/cp/health.lua +++ b/lua/cp/health.lua @@ -62,12 +62,14 @@ end local function check_config() vim.health.ok("Plugin ready") - if vim.g.cp and vim.g.cp.platform then - local info = vim.g.cp.platform - if vim.g.cp.contest_id then - info = info .. " " .. vim.g.cp.contest_id - if vim.g.cp.problem_id then - info = info .. " " .. vim.g.cp.problem_id + local cp = require("cp") + local context = cp.get_current_context() + if context.platform then + local info = context.platform + if context.contest_id then + info = info .. " " .. context.contest_id + if context.problem_id then + info = info .. " " .. context.problem_id end end vim.health.info("Current context: " .. info) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index b86dc98..92d7614 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -14,7 +14,6 @@ if not vim.fn.has("nvim-0.10.0") then return {} end -vim.g.cp = vim.g.cp or {} local user_config = {} local config = config_module.setup(user_config) logger.set_config(config) @@ -28,10 +27,41 @@ local state = { saved_layout = nil, saved_session = nil, temp_output = nil, + test_cases = nil, + test_states = {}, } local platforms = { "atcoder", "codeforces", "cses" } -local actions = { "run", "debug", "diff", "next", "prev" } +local actions = { "run", "debug", "test", "next", "prev" } + +local function get_current_problem_key() + if not state.platform or not state.contest_id then + return nil + end + if state.platform == "cses" then + return state.contest_id + else + return state.contest_id .. "_" .. (state.problem_id or "") + end +end + +local function get_test_states() + local problem_key = get_current_problem_key() + if not problem_key then + return {} + end + + if not state.test_states[problem_key] then + state.test_states[problem_key] = {} + if state.test_cases then + for i = 1, #state.test_cases do + state.test_states[problem_key][i] = true + end + end + end + + return state.test_states[problem_key] +end local function set_platform(platform) if not vim.tbl_contains(platforms, platform) then @@ -79,6 +109,11 @@ local function setup_problem(contest_id, problem_id) state.contest_id = contest_id state.problem_id = problem_id + local cached_test_cases = cache.get_test_cases(state.platform, contest_id, problem_id) + if cached_test_cases then + state.test_cases = cached_test_cases + end + local ctx = problem.create_context(state.platform, contest_id, problem_id, config) local scrape_result = scrape.scrape_problem(ctx) @@ -86,9 +121,15 @@ local function setup_problem(contest_id, problem_id) if not scrape_result.success then logger.log("scraping failed: " .. (scrape_result.error or "unknown error"), vim.log.levels.WARN) logger.log("you can manually add test cases to io/ directory", vim.log.levels.INFO) + state.test_cases = nil else local test_count = scrape_result.test_count or 0 logger.log(("scraped %d test case(s) for %s"):format(test_count, scrape_result.problem_id)) + state.test_cases = scrape_result.test_cases + + if scrape_result.test_cases then + cache.set_test_cases(state.platform, contest_id, problem_id, scrape_result.test_cases) + end end vim.cmd.e(ctx.source_file) @@ -186,36 +227,86 @@ local function debug_problem() end) end -local function diff_problem() - if state.diff_mode then - local tile_fn = config.tile or window.default_tile - window.restore_layout(state.saved_layout, tile_fn) - state.diff_mode = false - state.saved_layout = nil - logger.log("exited diff mode") - else - 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) - - if vim.fn.filereadable(ctx.expected_file) == 0 then - logger.log(("No expected output file found: %s"):format(ctx.expected_file), vim.log.levels.ERROR) - return - end - - state.saved_layout = window.save_layout() - - local result = vim.system({ "awk", "/^\\[[^]]*\\]:/ {exit} {print}", ctx.output_file }, { text = true }):wait() - local actual_output = result.stdout - - window.setup_diff_layout(actual_output, ctx.expected_file, ctx.input_file) - - state.diff_mode = true - logger.log("entered diff mode") +local function test_problem() + local problem_id = get_current_problem() + if not problem_id then + return end + + if not state.test_cases then + logger.log("No test case data available. Try scraping the problem first.", vim.log.levels.ERROR) + return + end + + local ctx = problem.create_context(state.platform, state.contest_id, state.problem_id, config) + local contest_config = config.contests[state.platform] + + local test_results = execute.run_individual_tests(ctx, state.test_cases, contest_config, false) + + if test_results.compile_error then + logger.log("Compilation failed: " .. test_results.compile_error, vim.log.levels.ERROR) + return + end + + local buf_name = ("cp-test://%s"):format(problem_id) + local existing_buf = vim.fn.bufnr(buf_name) + local buf + + if existing_buf ~= -1 then + buf = existing_buf + else + buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(buf, buf_name) + end + + local lines = {} + local passed = 0 + local total = #test_results.results + + local test_states = get_test_states() + + for _, result in ipairs(test_results.results) do + local status_icon = result.status == "PASS" and "✓" or "✗" + local enabled_icon = test_states[result.id] and "[x]" or "[ ]" + local time_str = ("%.1fms"):format(result.time_ms) + + table.insert(lines, ("%s Test %d %s %s (%s) {{{"):format(enabled_icon, result.id, status_icon, result.status, time_str)) + table.insert(lines, " Input:") + for _, line in ipairs(vim.split(result.input, "\n")) do + table.insert(lines, " " .. line) + end + + if result.status == "PASS" then + table.insert(lines, " Output:") + for _, line in ipairs(vim.split(result.actual, "\n")) do + table.insert(lines, " " .. line) + end + passed = passed + 1 + else + table.insert(lines, " Expected:") + for _, line in ipairs(vim.split(result.expected, "\n")) do + table.insert(lines, " " .. line) + end + table.insert(lines, " Got:") + for _, line in ipairs(vim.split(result.actual, "\n")) do + table.insert(lines, " " .. line) + end + end + + table.insert(lines, "}}}") + table.insert(lines, "") + end + + table.insert(lines, ("Summary: %d/%d passed"):format(passed, total)) + + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].filetype = "cp-test" + vim.bo[buf].modifiable = false + + vim.cmd.split() + vim.api.nvim_set_current_buf(buf) + + logger.log(("Test results: %d/%d passed"):format(passed, total)) end ---@param delta number 1 for next, -1 for prev @@ -323,8 +414,8 @@ function M.handle_command(opts) run_problem() elseif cmd.action == "debug" then debug_problem() - elseif cmd.action == "diff" then - diff_problem() + elseif cmd.action == "test" then + test_problem() elseif cmd.action == "next" then navigate_problem(1) elseif cmd.action == "prev" then @@ -400,6 +491,81 @@ function M.handle_command(opts) end end +function M.toggle_test(test_id) + local test_states = get_test_states() + test_states[test_id] = not test_states[test_id] + + local problem_key = get_current_problem_key() + if problem_key then + state.test_states[problem_key] = test_states + end + + test_problem() +end + +function M.run_single_test(test_id) + if not state.test_cases or not state.test_cases[test_id] then + logger.log("Test case not found", vim.log.levels.ERROR) + return + end + + local ctx = problem.create_context(state.platform, state.contest_id, state.problem_id, config) + local contest_config = config.contests[state.platform] + + local single_test = { state.test_cases[test_id] } + local test_results = execute.run_individual_tests(ctx, single_test, contest_config, false) + + if test_results.compile_error then + logger.log("Compilation failed: " .. test_results.compile_error, vim.log.levels.ERROR) + return + end + + local result = test_results.results[1] + if result then + logger.log(("Test %d: %s (%.1fms)"):format(test_id, result.status, result.time_ms)) + end +end + +function M.run_all_enabled_tests() + if not state.test_cases then + logger.log("No test cases available", vim.log.levels.ERROR) + return + end + + local test_states = get_test_states() + local enabled_tests = {} + + for i, test_case in ipairs(state.test_cases) do + if test_states[i] then + table.insert(enabled_tests, test_case) + end + end + + if #enabled_tests == 0 then + logger.log("No tests enabled", vim.log.levels.WARN) + return + end + + local ctx = problem.create_context(state.platform, state.contest_id, state.problem_id, config) + local contest_config = config.contests[state.platform] + + local test_results = execute.run_individual_tests(ctx, enabled_tests, contest_config, false) + + if test_results.compile_error then + logger.log("Compilation failed: " .. test_results.compile_error, vim.log.levels.ERROR) + return + end + + local passed = 0 + for _, result in ipairs(test_results.results) do + if result.status == "PASS" then + passed = passed + 1 + end + end + + logger.log(("Enabled tests: %d/%d passed"):format(passed, #enabled_tests)) +end + function M.setup(opts) opts = opts or {} user_config = opts @@ -411,6 +577,14 @@ function M.setup(opts) end end +function M.get_current_context() + return { + platform = state.platform, + contest_id = state.contest_id, + problem_id = state.problem_id, + } +end + function M.is_initialized() return true end diff --git a/plugin/cp.lua b/plugin/cp.lua index 690823a..fc57b96 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -4,7 +4,7 @@ end vim.g.loaded_cp = 1 local platforms = { "atcoder", "codeforces", "cses" } -local actions = { "run", "debug", "diff", "next", "prev" } +local actions = { "run", "debug", "test", "next", "prev" } vim.api.nvim_create_user_command("CP", function(opts) local cp = require("cp") @@ -22,10 +22,12 @@ end, { local candidates = {} vim.list_extend(candidates, platforms) vim.list_extend(candidates, actions) - if vim.g.cp and vim.g.cp.platform and vim.g.cp.contest_id then + local cp = require("cp") + local context = cp.get_current_context() + if context.platform and context.contest_id then local cache = require("cp.cache") cache.load() - local contest_data = cache.get_contest_data(vim.g.cp.platform, vim.g.cp.contest_id) + local contest_data = cache.get_contest_data(context.platform, context.contest_id) if contest_data and contest_data.problems then for _, problem in ipairs(contest_data.problems) do table.insert(candidates, problem.id) diff --git a/readme.md b/readme.md index f6b43ed..5adcaec 100644 --- a/readme.md +++ b/readme.md @@ -56,6 +56,10 @@ follows: 4. Submit the problem (on the remote!) +## Similar Projects + +- [competitest.nvim](https://github.com/xeluxee/competitest.nvim) + ## TODO - finer-tuned problem limits (i.e. per-problem codeforces time, memory) From 1ef68a4847a6f1ed10d60b788bbc7b03e41b636d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 15 Sep 2025 08:11:15 -0500 Subject: [PATCH 02/14] feat: first draft of arbitrary compile mode --- after/ftplugin/cp-test.lua | 45 +++++++++++++++ lua/cp/cache.lua | 22 ++++++++ lua/cp/config.lua | 46 ++++++++++++---- lua/cp/execute.lua | 109 +++++++++++++++++++++++++++++++++++-- lua/cp/init.lua | 5 +- lua/cp/scrape.lua | 1 + 6 files changed, 212 insertions(+), 16 deletions(-) create mode 100644 after/ftplugin/cp-test.lua diff --git a/after/ftplugin/cp-test.lua b/after/ftplugin/cp-test.lua new file mode 100644 index 0000000..faed7af --- /dev/null +++ b/after/ftplugin/cp-test.lua @@ -0,0 +1,45 @@ +vim.opt_local.number = false +vim.opt_local.relativenumber = false +vim.opt_local.statuscolumn = "" +vim.opt_local.signcolumn = "no" +vim.opt_local.wrap = false +vim.opt_local.linebreak = false +vim.opt_local.foldmethod = "marker" +vim.opt_local.foldmarker = "{{{,}}}" +vim.opt_local.foldlevel = 0 +vim.opt_local.foldtext = "" + +local function get_test_id_from_line() + local line = vim.api.nvim_get_current_line() + local test_id = line:match("%[.%] Test (%d+)") + return test_id and tonumber(test_id) +end + +local function toggle_test() + local test_id = get_test_id_from_line() + if not test_id then + return + end + + local cp = require("cp") + cp.toggle_test(test_id) +end + +local function run_single_test() + local test_id = get_test_id_from_line() + if not test_id then + return + end + + local cp = require("cp") + cp.run_single_test(test_id) +end + +local function run_all_enabled_tests() + local cp = require("cp") + cp.run_all_enabled_tests() +end + +vim.keymap.set("n", "t", toggle_test, { buffer = true, desc = "Toggle test enabled/disabled" }) +vim.keymap.set("n", "r", run_single_test, { buffer = true, desc = "Run single test" }) +vim.keymap.set("n", "R", run_all_enabled_tests, { buffer = true, desc = "Run all enabled tests" }) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index 70a55c3..565afc4 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -87,4 +87,26 @@ function M.clear_contest_data(platform, contest_id) end end +function M.get_test_cases(platform, contest_id, problem_id) + local problem_key = problem_id and (contest_id .. "_" .. problem_id) or contest_id + if not cache_data[platform] or not cache_data[platform][problem_key] then + return nil + end + return cache_data[platform][problem_key].test_cases +end + +function M.set_test_cases(platform, contest_id, problem_id, test_cases) + local problem_key = problem_id and (contest_id .. "_" .. problem_id) or contest_id + if not cache_data[platform] then + cache_data[platform] = {} + end + if not cache_data[platform][problem_key] then + cache_data[platform][problem_key] = {} + end + + cache_data[platform][problem_key].test_cases = test_cases + cache_data[platform][problem_key].test_cases_cached_at = os.time() + M.save() +end + return M diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 9f39c60..48a245e 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -12,19 +12,48 @@ local M = {} M.defaults = { contests = { default = { - cpp_version = 20, - compile_flags = { "-O2", "-DLOCAL", "-Wall", "-Wextra" }, - debug_flags = { "-g3", "-fsanitize=address,undefined", "-DLOCAL" }, + cpp = { + compile = { + "g++", + "-std=c++{version}", + "-O2", + "-DLOCAL", + "-Wall", + "-Wextra", + "{source}", + "-o", + "{binary}", + }, + run = { "{binary}" }, + debug = { + "g++", + "-std=c++{version}", + "-g3", + "-fsanitize=address,undefined", + "-DLOCAL", + "{source}", + "-o", + "{binary}", + }, + executable = nil, + version = 20, + }, + python = { + compile = nil, + run = { "{source}" }, + debug = { "{source}" }, + executable = "python3", + }, timeout_ms = 2000, }, atcoder = { - cpp_version = 23, + cpp = { version = 23 }, }, codeforces = { - cpp_version = 23, + cpp = { version = 23 }, }, cses = { - cpp_version = 20, + cpp = { version = 20 }, }, }, snippets = {}, @@ -42,11 +71,6 @@ M.defaults = { ---@return table local function extend_contest_config(base_config, contest_config) local result = vim.tbl_deep_extend("force", base_config, contest_config) - - local std_flag = ("-std=c++%d"):format(result.cpp_version) - result.compile_flags = vim.list_extend({ std_flag }, result.compile_flags) - result.debug_flags = vim.list_extend({ std_flag }, result.debug_flags) - return result end diff --git a/lua/cp/execute.lua b/lua/cp/execute.lua index 774346b..2182939 100644 --- a/lua/cp/execute.lua +++ b/lua/cp/execute.lua @@ -1,5 +1,43 @@ local M = {} +local filetype_to_language = { + cpp = "cpp", + cxx = "cpp", + cc = "cpp", + c = "cpp", + py = "python", + py3 = "python", + rs = "rust", + java = "java", + js = "javascript", + go = "go", +} + +local function get_language_from_file(source_file) + local extension = vim.fn.fnamemodify(source_file, ":e") + return filetype_to_language[extension] or "cpp" +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 signal_codes = { [128] = "SIGILL", [130] = "SIGINT", @@ -22,15 +60,19 @@ local function ensure_directories() vim.system({ "mkdir", "-p", "build", "io" }):wait() end -local function compile_cpp(source_path, binary_path, flags) - local compile_cmd = { "g++", unpack(flags), source_path, "-o", binary_path } +local function compile_generic(language_config, substitutions) + if not language_config.compile then + return { code = 0, stderr = "" } + end + + local compile_cmd = substitute_template(language_config.compile, substitutions) return vim.system(compile_cmd, { text = true }):wait() end -local function execute_binary(binary_path, input_data, timeout_ms) +local function execute_command(cmd, input_data, timeout_ms) local start_time = vim.loop.hrtime() - local result = vim.system({ binary_path }, { + local result = vim.system(cmd, { stdin = input_data, timeout = timeout_ms, text = true, @@ -123,4 +165,63 @@ function M.run_problem(ctx, contest_config, is_debug) end end +function M.run_individual_tests(ctx, test_cases, contest_config, is_debug) + ensure_directories() + + if not test_cases or #test_cases == 0 then + return {} + end + + local flags = is_debug and contest_config.debug_flags or contest_config.compile_flags + local compile_result = compile_cpp(ctx.source_file, ctx.binary_file, flags) + if compile_result.code ~= 0 then + return { + compile_error = compile_result.stderr, + results = {}, + } + end + + local results = {} + for i, test_case in ipairs(test_cases) do + local exec_result = execute_binary(ctx.binary_file, test_case.input, contest_config.timeout_ms) + + local actual_lines = vim.split(exec_result.stdout, "\n") + while #actual_lines > 0 and actual_lines[#actual_lines] == "" do + table.remove(actual_lines) + end + + local expected_lines = vim.split(test_case.output, "\n") + while #expected_lines > 0 and expected_lines[#expected_lines] == "" do + table.remove(expected_lines) + end + + local matches = #actual_lines == #expected_lines + if matches then + for j, line in ipairs(actual_lines) do + if line ~= expected_lines[j] then + matches = false + break + end + end + end + + table.insert(results, { + id = i, + status = exec_result.code == 0 and (matches and "PASS" or "FAIL") or "ERROR", + time_ms = exec_result.time_ms, + input = test_case.input, + expected = test_case.output, + actual = exec_result.stdout, + exit_code = exec_result.code, + timed_out = exec_result.timed_out, + enabled = true, + }) + end + + return { + compile_error = nil, + results = results, + } +end + return M diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 92d7614..90abffb 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -270,7 +270,10 @@ local function test_problem() local enabled_icon = test_states[result.id] and "[x]" or "[ ]" local time_str = ("%.1fms"):format(result.time_ms) - table.insert(lines, ("%s Test %d %s %s (%s) {{{"):format(enabled_icon, result.id, status_icon, result.status, time_str)) + table.insert( + lines, + ("%s Test %d %s %s (%s) {{{"):format(enabled_icon, result.id, status_icon, result.status, time_str) + ) table.insert(lines, " Input:") for _, line in ipairs(vim.split(result.input, "\n")) do table.insert(lines, " " .. line) diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index 82e2e4f..138dacc 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -209,6 +209,7 @@ function M.scrape_problem(ctx) success = true, problem_id = ctx.problem_name, test_count = data.test_cases and #data.test_cases or 0, + test_cases = data.test_cases, url = data.url, } end From 2b1367602475cb9617bac0f5ecb47953ef38436b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 15 Sep 2025 08:16:41 -0500 Subject: [PATCH 03/14] feat(doc): update docs with language config --- doc/cp.txt | 7 +++--- lua/cp/execute.lua | 60 +++++++++++++++++++++++++++++++++++----------- readme.md | 3 ++- 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/doc/cp.txt b/doc/cp.txt index 7570cc1..7b848ab 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -9,12 +9,13 @@ cp.nvim is a competitive programming plugin that automates problem setup, compilation, and testing workflow for online judges. Supported platforms: AtCoder, Codeforces, CSES +Supported languages: C++, Python REQUIREMENTS *cp-requirements* - Neovim 0.10.0+ - uv package manager (https://docs.astral.sh/uv/) -- C++ compiler (g++/clang++) +- Language runtime/compiler (g++, python3) Optional: - LuaSnip for template expansion (https://github.com/L3MON4D3/LuaSnip) @@ -51,8 +52,8 @@ Action Commands ~ :CP debug Compile with debug flags and run current problem. Includes sanitizers and debug symbols. -:CP diff Enter diff mode to compare actual vs expected - output. Run again to exit diff mode. +:CP test Open enhanced test viewer showing individual + test case results with pass/fail status. Navigation Commands ~ diff --git a/lua/cp/execute.lua b/lua/cp/execute.lua index 2182939..87daa60 100644 --- a/lua/cp/execute.lua +++ b/lua/cp/execute.lua @@ -7,10 +7,6 @@ local filetype_to_language = { c = "cpp", py = "python", py3 = "python", - rs = "rust", - java = "java", - js = "javascript", - go = "go", } local function get_language_from_file(source_file) @@ -138,20 +134,36 @@ end function M.run_problem(ctx, contest_config, is_debug) ensure_directories() - local flags = is_debug and contest_config.debug_flags or contest_config.compile_flags + local language = get_language_from_file(ctx.source_file) + local language_config = contest_config[language] - local compile_result = compile_cpp(ctx.source_file, ctx.binary_file, flags) - if compile_result.code ~= 0 then - vim.fn.writefile({ compile_result.stderr }, ctx.output_file) + if not language_config then + vim.fn.writefile({ "Error: No configuration for language: " .. language }, ctx.output_file) return end + local substitutions = { + source = ctx.source_file, + binary = ctx.binary_file, + version = tostring(language_config.version or ""), + } + + 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) + if compile_result.code ~= 0 then + vim.fn.writefile({ compile_result.stderr }, ctx.output_file) + return + end + end + local input_data = "" if vim.fn.filereadable(ctx.input_file) == 1 then input_data = table.concat(vim.fn.readfile(ctx.input_file), "\n") .. "\n" end - local exec_result = execute_binary(ctx.binary_file, input_data, contest_config.timeout_ms) + local run_cmd = build_command(language_config.run, language_config.executable, substitutions) + local exec_result = execute_command(run_cmd, input_data, contest_config.timeout_ms) local formatted_output = format_output(exec_result, ctx.expected_file, is_debug) local output_buf = vim.fn.bufnr(ctx.output_file) @@ -172,18 +184,38 @@ function M.run_individual_tests(ctx, test_cases, contest_config, is_debug) return {} end - local flags = is_debug and contest_config.debug_flags or contest_config.compile_flags - local compile_result = compile_cpp(ctx.source_file, ctx.binary_file, flags) - if compile_result.code ~= 0 then + local language = get_language_from_file(ctx.source_file) + local language_config = contest_config[language] + + if not language_config then return { - compile_error = compile_result.stderr, + compile_error = "Error: No configuration for language: " .. language, results = {}, } end + local substitutions = { + source = ctx.source_file, + binary = ctx.binary_file, + version = tostring(language_config.version or ""), + } + + 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) + if compile_result.code ~= 0 then + return { + compile_error = compile_result.stderr, + results = {}, + } + end + end + + local run_cmd = build_command(language_config.run, language_config.executable, substitutions) + local results = {} for i, test_case in ipairs(test_cases) do - local exec_result = execute_binary(ctx.binary_file, test_case.input, contest_config.timeout_ms) + local exec_result = execute_command(run_cmd, test_case.input, contest_config.timeout_ms) local actual_lines = vim.split(exec_result.stdout, "\n") while #actual_lines > 0 and actual_lines[#actual_lines] == "" do diff --git a/readme.md b/readme.md index 5adcaec..6b6bf83 100644 --- a/readme.md +++ b/readme.md @@ -9,9 +9,10 @@ https://private-user-images.githubusercontent.com/62671086/489116291-391976d1-c2 ## Features - Support for multiple online judges ([AtCoder](https://atcoder.jp/), [Codeforces](https://codeforces.com/), [CSES](https://cses.fi)) +- Multi-language support (C++, Python) - Automatic problem scraping and test case management - Integrated build, run, and debug commands -- Diff mode for comparing output with expected results +- Enhanced test viewer with individual test case management - LuaSnip integration for contest-specific snippets ## Requirements From 64a7fc114486993ce5f4901737c54ff52c991e41 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 15 Sep 2025 08:18:52 -0500 Subject: [PATCH 04/14] feat(debug): debug logs --- lua/cp/execute.lua | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/lua/cp/execute.lua b/lua/cp/execute.lua index 87daa60..791dbe3 100644 --- a/lua/cp/execute.lua +++ b/lua/cp/execute.lua @@ -1,4 +1,5 @@ local M = {} +local logger = require("cp.log") local filetype_to_language = { cpp = "cpp", @@ -11,7 +12,9 @@ local filetype_to_language = { local function get_language_from_file(source_file) local extension = vim.fn.fnamemodify(source_file, ":e") - return filetype_to_language[extension] or "cpp" + local language = filetype_to_language[extension] or "cpp" + logger.log(("detected language: %s (extension: %s)"):format(language, extension)) + return language end local function substitute_template(cmd_template, substitutions) @@ -58,14 +61,29 @@ end local function compile_generic(language_config, substitutions) if not language_config.compile then + logger.log("no compilation step required") return { code = 0, stderr = "" } end local compile_cmd = substitute_template(language_config.compile, substitutions) - return vim.system(compile_cmd, { text = true }):wait() + logger.log(("compiling: %s"):format(table.concat(compile_cmd, " "))) + + local start_time = vim.loop.hrtime() + local result = vim.system(compile_cmd, { text = true }):wait() + local compile_time = (vim.loop.hrtime() - start_time) / 1000000 + + if result.code == 0 then + logger.log(("compilation successful (%.1fms)"):format(compile_time)) + else + logger.log(("compilation failed (%.1fms): %s"):format(compile_time, result.stderr), vim.log.levels.WARN) + end + + return result end local function execute_command(cmd, input_data, timeout_ms) + logger.log(("executing: %s"):format(table.concat(cmd, " "))) + local start_time = vim.loop.hrtime() local result = vim.system(cmd, { @@ -79,6 +97,14 @@ local function execute_command(cmd, input_data, timeout_ms) local actual_code = result.code or 0 + if result.code == 124 then + logger.log(("execution timed out after %.1fms"):format(execution_time), vim.log.levels.WARN) + elseif actual_code ~= 0 then + logger.log(("execution failed (exit code %d, %.1fms)"):format(actual_code, execution_time), vim.log.levels.WARN) + else + logger.log(("execution successful (%.1fms)"):format(execution_time)) + end + return { stdout = result.stdout or "", stderr = result.stderr or "", @@ -184,6 +210,8 @@ function M.run_individual_tests(ctx, test_cases, contest_config, is_debug) return {} end + logger.log(("running %d individual tests"):format(#test_cases)) + local language = get_language_from_file(ctx.source_file) local language_config = contest_config[language] From 0c6ed73ddeee3b2f51a7a43a078bf819db2a3dae Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 15 Sep 2025 08:20:11 -0500 Subject: [PATCH 05/14] feat(debug): log execution time --- lua/cp/execute.lua | 11 +++++++++++ lua/cp/init.lua | 7 +++++++ 2 files changed, 18 insertions(+) diff --git a/lua/cp/execute.lua b/lua/cp/execute.lua index 791dbe3..186dd8b 100644 --- a/lua/cp/execute.lua +++ b/lua/cp/execute.lua @@ -278,6 +278,17 @@ function M.run_individual_tests(ctx, test_cases, contest_config, is_debug) }) end + local passed = 0 + local total_time = 0 + for _, result in ipairs(results) do + if result.status == "PASS" then + passed = passed + 1 + end + total_time = total_time + result.time_ms + end + + logger.log(("test results: %d/%d passed, total execution time %.1fms"):format(passed, #results, total_time)) + return { compile_error = nil, results = results, diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 90abffb..8e1e884 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -83,6 +83,9 @@ local function setup_problem(contest_id, problem_id) return end + local problem_name = state.platform == "cses" and contest_id or (contest_id .. (problem_id or "")) + logger.log(("setting up problem: %s"):format(problem_name)) + local metadata_result = scrape.scrape_contest_metadata(state.platform, contest_id) if not metadata_result.success then logger.log( @@ -185,6 +188,8 @@ local function run_problem() return end + logger.log(("running problem: %s"):format(problem_id)) + if config.hooks and config.hooks.before_run then config.hooks.before_run(problem_id) end @@ -233,6 +238,8 @@ local function test_problem() return end + logger.log(("opening test viewer for problem: %s"):format(problem_id)) + if not state.test_cases then logger.log("No test case data available. Try scraping the problem first.", vim.log.levels.ERROR) return From 259ffcaab1699b094a66ee9d9ff8ff73fe6c6665 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 15 Sep 2025 08:23:56 -0500 Subject: [PATCH 06/14] feat: use new filetype structure --- after/ftplugin/cpin.lua | 6 ++++++ after/ftplugin/cpout.lua | 7 +++++++ ftdetect/cp.lua | 6 ++++++ lua/cp/problem.lua | 4 ++-- readme.md | 1 + 5 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 after/ftplugin/cpin.lua create mode 100644 after/ftplugin/cpout.lua create mode 100644 ftdetect/cp.lua diff --git a/after/ftplugin/cpin.lua b/after/ftplugin/cpin.lua new file mode 100644 index 0000000..afa4526 --- /dev/null +++ b/after/ftplugin/cpin.lua @@ -0,0 +1,6 @@ +vim.opt_local.number = false +vim.opt_local.relativenumber = false +vim.opt_local.statuscolumn = "" +vim.opt_local.signcolumn = "no" +vim.opt_local.wrap = true +vim.opt_local.linebreak = true \ No newline at end of file diff --git a/after/ftplugin/cpout.lua b/after/ftplugin/cpout.lua new file mode 100644 index 0000000..b574d3d --- /dev/null +++ b/after/ftplugin/cpout.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.wrap = true +vim.opt_local.linebreak = true +vim.opt_local.modifiable = false \ No newline at end of file diff --git a/ftdetect/cp.lua b/ftdetect/cp.lua new file mode 100644 index 0000000..b503a34 --- /dev/null +++ b/ftdetect/cp.lua @@ -0,0 +1,6 @@ +vim.filetype.add({ + extension = { + cpin = "cpin", + cpout = "cpout", + }, +}) \ No newline at end of file diff --git a/lua/cp/problem.lua b/lua/cp/problem.lua index 4aa73d2..60406fd 100644 --- a/lua/cp/problem.lua +++ b/lua/cp/problem.lua @@ -27,8 +27,8 @@ function M.create_context(contest, contest_id, problem_id, config) problem_id = problem_id, source_file = source_file, binary_file = ("build/%s.run"):format(base_name), - input_file = ("io/%s.in"):format(base_name), - output_file = ("io/%s.out"):format(base_name), + input_file = ("io/%s.cpin"):format(base_name), + output_file = ("io/%s.cpout"):format(base_name), expected_file = ("io/%s.expected"):format(base_name), problem_name = base_name, } diff --git a/readme.md b/readme.md index 6b6bf83..8cafc5e 100644 --- a/readme.md +++ b/readme.md @@ -67,3 +67,4 @@ follows: - better highlighting - test case management - USACO support +- new video with functionality, notify discord members From cf192fad83391d8542f4a7ab2f8846c965059ad9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 15 Sep 2025 09:25:58 -0400 Subject: [PATCH 07/14] fix: newline joining on test cases --- lua/cp/scrape.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index 138dacc..8b99f3b 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -188,7 +188,7 @@ function M.scrape_problem(ctx) local all_inputs = {} local all_outputs = {} - for _, test_case in ipairs(data.test_cases) do + for i, test_case in ipairs(data.test_cases) do local input_lines = vim.split(test_case.input:gsub("\r", ""):gsub("\n+$", ""), "\n") local output_lines = vim.split(test_case.output:gsub("\r", ""):gsub("\n+$", ""), "\n") @@ -199,6 +199,11 @@ function M.scrape_problem(ctx) for _, line in ipairs(output_lines) do table.insert(all_outputs, line) end + + if i < #data.test_cases then + table.insert(all_inputs, "") + table.insert(all_outputs, "") + end end vim.fn.writefile(all_inputs, ctx.input_file) From e10eab22d6aeff0c6229fc6a3835eed9bd9d8cdc Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 15 Sep 2025 10:37:40 -0400 Subject: [PATCH 08/14] fix: revert multiple test cases --- after/ftplugin/cp-test.lua | 45 ---------- doc/cp.txt | 4 +- lua/cp/execute.lua | 91 ------------------- lua/cp/init.lua | 179 +++++++------------------------------ lua/cp/scrape.lua | 26 +----- plugin/cp.lua | 2 +- scrapers/codeforces.py | 33 +++---- 7 files changed, 51 insertions(+), 329 deletions(-) delete mode 100644 after/ftplugin/cp-test.lua diff --git a/after/ftplugin/cp-test.lua b/after/ftplugin/cp-test.lua deleted file mode 100644 index faed7af..0000000 --- a/after/ftplugin/cp-test.lua +++ /dev/null @@ -1,45 +0,0 @@ -vim.opt_local.number = false -vim.opt_local.relativenumber = false -vim.opt_local.statuscolumn = "" -vim.opt_local.signcolumn = "no" -vim.opt_local.wrap = false -vim.opt_local.linebreak = false -vim.opt_local.foldmethod = "marker" -vim.opt_local.foldmarker = "{{{,}}}" -vim.opt_local.foldlevel = 0 -vim.opt_local.foldtext = "" - -local function get_test_id_from_line() - local line = vim.api.nvim_get_current_line() - local test_id = line:match("%[.%] Test (%d+)") - return test_id and tonumber(test_id) -end - -local function toggle_test() - local test_id = get_test_id_from_line() - if not test_id then - return - end - - local cp = require("cp") - cp.toggle_test(test_id) -end - -local function run_single_test() - local test_id = get_test_id_from_line() - if not test_id then - return - end - - local cp = require("cp") - cp.run_single_test(test_id) -end - -local function run_all_enabled_tests() - local cp = require("cp") - cp.run_all_enabled_tests() -end - -vim.keymap.set("n", "t", toggle_test, { buffer = true, desc = "Toggle test enabled/disabled" }) -vim.keymap.set("n", "r", run_single_test, { buffer = true, desc = "Run single test" }) -vim.keymap.set("n", "R", run_all_enabled_tests, { buffer = true, desc = "Run all enabled tests" }) diff --git a/doc/cp.txt b/doc/cp.txt index 7b848ab..306c70a 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -52,8 +52,8 @@ Action Commands ~ :CP debug Compile with debug flags and run current problem. Includes sanitizers and debug symbols. -:CP test Open enhanced test viewer showing individual - test case results with pass/fail status. +:CP diff Enter diff mode to compare actual vs expected + output. Run again to exit diff mode. Navigation Commands ~ diff --git a/lua/cp/execute.lua b/lua/cp/execute.lua index 186dd8b..233363e 100644 --- a/lua/cp/execute.lua +++ b/lua/cp/execute.lua @@ -203,96 +203,5 @@ function M.run_problem(ctx, contest_config, is_debug) end end -function M.run_individual_tests(ctx, test_cases, contest_config, is_debug) - ensure_directories() - - if not test_cases or #test_cases == 0 then - return {} - end - - logger.log(("running %d individual tests"):format(#test_cases)) - - local language = get_language_from_file(ctx.source_file) - local language_config = contest_config[language] - - if not language_config then - return { - compile_error = "Error: No configuration for language: " .. language, - results = {}, - } - end - - local substitutions = { - source = ctx.source_file, - binary = ctx.binary_file, - version = tostring(language_config.version or ""), - } - - 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) - if compile_result.code ~= 0 then - return { - compile_error = compile_result.stderr, - results = {}, - } - end - end - - local run_cmd = build_command(language_config.run, language_config.executable, substitutions) - - local results = {} - for i, test_case in ipairs(test_cases) do - local exec_result = execute_command(run_cmd, test_case.input, contest_config.timeout_ms) - - local actual_lines = vim.split(exec_result.stdout, "\n") - while #actual_lines > 0 and actual_lines[#actual_lines] == "" do - table.remove(actual_lines) - end - - local expected_lines = vim.split(test_case.output, "\n") - while #expected_lines > 0 and expected_lines[#expected_lines] == "" do - table.remove(expected_lines) - end - - local matches = #actual_lines == #expected_lines - if matches then - for j, line in ipairs(actual_lines) do - if line ~= expected_lines[j] then - matches = false - break - end - end - end - - table.insert(results, { - id = i, - status = exec_result.code == 0 and (matches and "PASS" or "FAIL") or "ERROR", - time_ms = exec_result.time_ms, - input = test_case.input, - expected = test_case.output, - actual = exec_result.stdout, - exit_code = exec_result.code, - timed_out = exec_result.timed_out, - enabled = true, - }) - end - - local passed = 0 - local total_time = 0 - for _, result in ipairs(results) do - if result.status == "PASS" then - passed = passed + 1 - end - total_time = total_time + result.time_ms - end - - logger.log(("test results: %d/%d passed, total execution time %.1fms"):format(passed, #results, total_time)) - - return { - compile_error = nil, - results = results, - } -end return M diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 8e1e884..149a879 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -32,7 +32,7 @@ local state = { } local platforms = { "atcoder", "codeforces", "cses" } -local actions = { "run", "debug", "test", "next", "prev" } +local actions = { "run", "debug", "diff", "next", "prev" } local function get_current_problem_key() if not state.platform or not state.contest_id then @@ -232,91 +232,46 @@ local function debug_problem() end) end -local function test_problem() +local function diff_problem() + if state.diff_mode then + vim.cmd.diffoff() + if state.saved_session then + vim.fn.delete(state.saved_session) + state.saved_session = nil + end + if state.temp_output then + vim.fn.delete(state.temp_output) + state.temp_output = nil + end + state.diff_mode = false + return + end + local problem_id = get_current_problem() if not problem_id then return end - logger.log(("opening test viewer for problem: %s"):format(problem_id)) - - if not state.test_cases then - logger.log("No test case data available. Try scraping the problem first.", vim.log.levels.ERROR) - return - end - local ctx = problem.create_context(state.platform, state.contest_id, state.problem_id, config) - local contest_config = config.contests[state.platform] - local test_results = execute.run_individual_tests(ctx, state.test_cases, contest_config, false) - - if test_results.compile_error then - logger.log("Compilation failed: " .. test_results.compile_error, vim.log.levels.ERROR) + if vim.fn.filereadable(ctx.expected_file) == 0 then + logger.log("no expected output file found", vim.log.levels.WARN) return end - local buf_name = ("cp-test://%s"):format(problem_id) - local existing_buf = vim.fn.bufnr(buf_name) - local buf - - if existing_buf ~= -1 then - buf = existing_buf - else - buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_name(buf, buf_name) + if vim.fn.filereadable(ctx.output_file) == 0 then + logger.log("no output file found. run the problem first", vim.log.levels.WARN) + return end - local lines = {} - local passed = 0 - local total = #test_results.results + state.saved_session = vim.fn.tempname() + vim.cmd(("mksession! %s"):format(state.saved_session)) - local test_states = get_test_states() - - for _, result in ipairs(test_results.results) do - local status_icon = result.status == "PASS" and "✓" or "✗" - local enabled_icon = test_states[result.id] and "[x]" or "[ ]" - local time_str = ("%.1fms"):format(result.time_ms) - - table.insert( - lines, - ("%s Test %d %s %s (%s) {{{"):format(enabled_icon, result.id, status_icon, result.status, time_str) - ) - table.insert(lines, " Input:") - for _, line in ipairs(vim.split(result.input, "\n")) do - table.insert(lines, " " .. line) - end - - if result.status == "PASS" then - table.insert(lines, " Output:") - for _, line in ipairs(vim.split(result.actual, "\n")) do - table.insert(lines, " " .. line) - end - passed = passed + 1 - else - table.insert(lines, " Expected:") - for _, line in ipairs(vim.split(result.expected, "\n")) do - table.insert(lines, " " .. line) - end - table.insert(lines, " Got:") - for _, line in ipairs(vim.split(result.actual, "\n")) do - table.insert(lines, " " .. line) - end - end - - table.insert(lines, "}}}") - table.insert(lines, "") - end - - table.insert(lines, ("Summary: %d/%d passed"):format(passed, total)) - - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.bo[buf].filetype = "cp-test" - vim.bo[buf].modifiable = false - - vim.cmd.split() - vim.api.nvim_set_current_buf(buf) - - logger.log(("Test results: %d/%d passed"):format(passed, total)) + vim.cmd("silent only") + vim.cmd(("edit %s"):format(ctx.expected_file)) + vim.cmd.diffthis() + vim.cmd(("vertical diffsplit %s"):format(ctx.output_file)) + state.diff_mode = true end ---@param delta number 1 for next, -1 for prev @@ -424,8 +379,8 @@ function M.handle_command(opts) run_problem() elseif cmd.action == "debug" then debug_problem() - elseif cmd.action == "test" then - test_problem() + elseif cmd.action == "diff" then + diff_problem() elseif cmd.action == "next" then navigate_problem(1) elseif cmd.action == "prev" then @@ -501,80 +456,6 @@ function M.handle_command(opts) end end -function M.toggle_test(test_id) - local test_states = get_test_states() - test_states[test_id] = not test_states[test_id] - - local problem_key = get_current_problem_key() - if problem_key then - state.test_states[problem_key] = test_states - end - - test_problem() -end - -function M.run_single_test(test_id) - if not state.test_cases or not state.test_cases[test_id] then - logger.log("Test case not found", vim.log.levels.ERROR) - return - end - - local ctx = problem.create_context(state.platform, state.contest_id, state.problem_id, config) - local contest_config = config.contests[state.platform] - - local single_test = { state.test_cases[test_id] } - local test_results = execute.run_individual_tests(ctx, single_test, contest_config, false) - - if test_results.compile_error then - logger.log("Compilation failed: " .. test_results.compile_error, vim.log.levels.ERROR) - return - end - - local result = test_results.results[1] - if result then - logger.log(("Test %d: %s (%.1fms)"):format(test_id, result.status, result.time_ms)) - end -end - -function M.run_all_enabled_tests() - if not state.test_cases then - logger.log("No test cases available", vim.log.levels.ERROR) - return - end - - local test_states = get_test_states() - local enabled_tests = {} - - for i, test_case in ipairs(state.test_cases) do - if test_states[i] then - table.insert(enabled_tests, test_case) - end - end - - if #enabled_tests == 0 then - logger.log("No tests enabled", vim.log.levels.WARN) - return - end - - local ctx = problem.create_context(state.platform, state.contest_id, state.problem_id, config) - local contest_config = config.contests[state.platform] - - local test_results = execute.run_individual_tests(ctx, enabled_tests, contest_config, false) - - if test_results.compile_error then - logger.log("Compilation failed: " .. test_results.compile_error, vim.log.levels.ERROR) - return - end - - local passed = 0 - for _, result in ipairs(test_results.results) do - if result.status == "PASS" then - passed = passed + 1 - end - end - - logger.log(("Enabled tests: %d/%d passed"):format(passed, #enabled_tests)) -end function M.setup(opts) opts = opts or {} diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index 8b99f3b..b1c85ca 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -185,29 +185,11 @@ function M.scrape_problem(ctx) end if data.test_cases and #data.test_cases > 0 then - local all_inputs = {} - local all_outputs = {} + local combined_input = data.test_cases[1].input:gsub("\r", "") + local combined_output = data.test_cases[1].output:gsub("\r", "") - for i, test_case in ipairs(data.test_cases) do - local input_lines = vim.split(test_case.input:gsub("\r", ""):gsub("\n+$", ""), "\n") - local output_lines = vim.split(test_case.output:gsub("\r", ""):gsub("\n+$", ""), "\n") - - for _, line in ipairs(input_lines) do - table.insert(all_inputs, line) - end - - for _, line in ipairs(output_lines) do - table.insert(all_outputs, line) - end - - if i < #data.test_cases then - table.insert(all_inputs, "") - table.insert(all_outputs, "") - end - end - - vim.fn.writefile(all_inputs, ctx.input_file) - vim.fn.writefile(all_outputs, ctx.expected_file) + vim.fn.writefile(vim.split(combined_input, "\n"), ctx.input_file) + vim.fn.writefile(vim.split(combined_output, "\n"), ctx.expected_file) end return { diff --git a/plugin/cp.lua b/plugin/cp.lua index fc57b96..83a2816 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -4,7 +4,7 @@ end vim.g.loaded_cp = 1 local platforms = { "atcoder", "codeforces", "cses" } -local actions = { "run", "debug", "test", "next", "prev" } +local actions = { "run", "debug", "diff", "next", "prev" } vim.api.nvim_create_user_command("CP", function(opts) local cp = require("cp") diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index 6343287..9b885ce 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -19,28 +19,23 @@ def scrape(url: str) -> list[tuple[str, str]]: input_sections = soup.find_all("div", class_="input") output_sections = soup.find_all("div", class_="output") - for inp_section, out_section in zip(input_sections, output_sections): + all_inputs = [] + all_outputs = [] + + for inp_section in input_sections: inp_pre = inp_section.find("pre") + if inp_pre: + all_inputs.append(inp_pre.get_text().strip().replace("\r", "")) + + for out_section in output_sections: out_pre = out_section.find("pre") + if out_pre: + all_outputs.append(out_pre.get_text().strip().replace("\r", "")) - if inp_pre and out_pre: - input_lines: list[str] = [] - output_lines: list[str] = [] - - input_text_raw = inp_pre.get_text().strip().replace("\r", "") - input_lines = [ - line.strip() for line in input_text_raw.split("\n") if line.strip() - ] - - output_text_raw = out_pre.get_text().strip().replace("\r", "") - output_lines = [ - line.strip() for line in output_text_raw.split("\n") if line.strip() - ] - - if input_lines and output_lines: - input_text = "\n".join(input_lines) - output_text = "\n".join(output_lines) - tests.append((input_text, output_text)) + if all_inputs and all_outputs: + combined_input = "\n".join(all_inputs) + combined_output = "\n".join(all_outputs) + tests.append((combined_input, combined_output)) return tests From beb87e853c732428e2c3c82c3e4664dc97e543c2 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 15 Sep 2025 10:39:02 -0400 Subject: [PATCH 09/14] fix --- after/ftplugin/cpin.lua | 2 +- after/ftplugin/cpout.lua | 2 +- ftdetect/cp.lua | 2 +- lua/cp/execute.lua | 1 - lua/cp/init.lua | 1 - 5 files changed, 3 insertions(+), 5 deletions(-) diff --git a/after/ftplugin/cpin.lua b/after/ftplugin/cpin.lua index afa4526..76a9f86 100644 --- a/after/ftplugin/cpin.lua +++ b/after/ftplugin/cpin.lua @@ -3,4 +3,4 @@ vim.opt_local.relativenumber = false vim.opt_local.statuscolumn = "" vim.opt_local.signcolumn = "no" vim.opt_local.wrap = true -vim.opt_local.linebreak = true \ No newline at end of file +vim.opt_local.linebreak = true diff --git a/after/ftplugin/cpout.lua b/after/ftplugin/cpout.lua index b574d3d..3f0bcdc 100644 --- a/after/ftplugin/cpout.lua +++ b/after/ftplugin/cpout.lua @@ -4,4 +4,4 @@ vim.opt_local.statuscolumn = "" vim.opt_local.signcolumn = "no" vim.opt_local.wrap = true vim.opt_local.linebreak = true -vim.opt_local.modifiable = false \ No newline at end of file +vim.opt_local.modifiable = false diff --git a/ftdetect/cp.lua b/ftdetect/cp.lua index b503a34..2b6b593 100644 --- a/ftdetect/cp.lua +++ b/ftdetect/cp.lua @@ -3,4 +3,4 @@ vim.filetype.add({ cpin = "cpin", cpout = "cpout", }, -}) \ No newline at end of file +}) diff --git a/lua/cp/execute.lua b/lua/cp/execute.lua index 233363e..00e9332 100644 --- a/lua/cp/execute.lua +++ b/lua/cp/execute.lua @@ -203,5 +203,4 @@ function M.run_problem(ctx, contest_config, is_debug) end end - return M diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 149a879..72b8bb7 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -456,7 +456,6 @@ function M.handle_command(opts) end end - function M.setup(opts) opts = opts or {} user_config = opts From 5db19acc53954a5aeac0b1fe7030ed47422a0666 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 15 Sep 2025 10:52:28 -0400 Subject: [PATCH 10/14] fix scraper --- lua/cp/scrape.lua | 12 ++++++------ scrapers/codeforces.py | 31 ++++++++++++++++++++++--------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index b1c85ca..8e5c387 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -74,9 +74,9 @@ function M.scrape_contest_metadata(platform, contest_id) local args if platform == "cses" then - args = { "uv", "run", scraper_path, "metadata" } + args = { "uv", "run", "--directory", plugin_path, scraper_path, "metadata" } else - args = { "uv", "run", scraper_path, "metadata", contest_id } + args = { "uv", "run", "--directory", plugin_path, scraper_path, "metadata", contest_id } end local result = vim.system(args, { @@ -152,9 +152,9 @@ function M.scrape_problem(ctx) local args if ctx.contest == "cses" then - args = { "uv", "run", scraper_path, "tests", ctx.contest_id } + args = { "uv", "run", "--directory", plugin_path, scraper_path, "tests", ctx.contest_id } else - args = { "uv", "run", 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, { @@ -188,8 +188,8 @@ function M.scrape_problem(ctx) local combined_input = data.test_cases[1].input:gsub("\r", "") local combined_output = data.test_cases[1].output:gsub("\r", "") - vim.fn.writefile(vim.split(combined_input, "\n"), ctx.input_file) - vim.fn.writefile(vim.split(combined_output, "\n"), ctx.expected_file) + vim.fn.writefile(vim.split(combined_input, "\n", true), ctx.input_file) + vim.fn.writefile(vim.split(combined_output, "\n", true), ctx.expected_file) end return { diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index 9b885ce..cf98f07 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -25,12 +25,24 @@ def scrape(url: str) -> list[tuple[str, str]]: for inp_section in input_sections: inp_pre = inp_section.find("pre") if inp_pre: - all_inputs.append(inp_pre.get_text().strip().replace("\r", "")) + divs = inp_pre.find_all("div") + if divs: + lines = [div.get_text().strip() for div in divs] + text = "\n".join(lines) + else: + text = inp_pre.get_text().replace("\r", "") + all_inputs.append(text) for out_section in output_sections: out_pre = out_section.find("pre") if out_pre: - all_outputs.append(out_pre.get_text().strip().replace("\r", "")) + divs = out_pre.find_all("div") + if divs: + lines = [div.get_text().strip() for div in divs] + text = "\n".join(lines) + else: + text = out_pre.get_text().replace("\r", "") + all_outputs.append(text) if all_inputs and all_outputs: combined_input = "\n".join(all_inputs) @@ -103,11 +115,12 @@ def main() -> None: print(json.dumps(result)) sys.exit(1) + mode: str = sys.argv[1] if mode == "metadata": if len(sys.argv) != 3: - result = { + result: dict[str, str | bool] = { "success": False, "error": "Usage: codeforces.py metadata ", } @@ -118,14 +131,14 @@ def main() -> None: problems: list[dict[str, str]] = scrape_contest_problems(contest_id) if not problems: - result = { + result: dict[str, str | bool] = { "success": False, "error": f"No problems found for contest {contest_id}", } print(json.dumps(result)) sys.exit(1) - result = { + result: dict[str, str | bool | list] = { "success": True, "contest_id": contest_id, "problems": problems, @@ -134,7 +147,7 @@ def main() -> None: elif mode == "tests": if len(sys.argv) != 4: - result = { + result: dict[str, str | bool] = { "success": False, "error": "Usage: codeforces.py tests ", } @@ -149,7 +162,7 @@ def main() -> None: tests: list[tuple[str, str]] = scrape_sample_tests(url) if not tests: - result = { + result: dict[str, str | bool] = { "success": False, "error": f"No tests found for {contest_id} {problem_letter}", "problem_id": problem_id, @@ -162,7 +175,7 @@ def main() -> None: for input_data, output_data in tests: test_cases.append({"input": input_data, "output": output_data}) - result = { + result: dict[str, str | bool | list] = { "success": True, "problem_id": problem_id, "url": url, @@ -171,7 +184,7 @@ def main() -> None: print(json.dumps(result)) else: - result = { + result: dict[str, str | bool] = { "success": False, "error": f"Unknown mode: {mode}. Use 'metadata' or 'tests'", } From 64d6576bcfa76fdfc912c03dfc60bca100e7edf4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 15 Sep 2025 10:53:00 -0400 Subject: [PATCH 11/14] fix(ci): format --- scrapers/codeforces.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index cf98f07..4610ae9 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -115,7 +115,6 @@ def main() -> None: print(json.dumps(result)) sys.exit(1) - mode: str = sys.argv[1] if mode == "metadata": From 8b0c6994d83a7f2e00a91c25ee166c841ff16617 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 15 Sep 2025 10:53:35 -0400 Subject: [PATCH 12/14] fix(ci): unused vars --- lua/cp/init.lua | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 72b8bb7..27de29e 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -45,24 +45,6 @@ local function get_current_problem_key() end end -local function get_test_states() - local problem_key = get_current_problem_key() - if not problem_key then - return {} - end - - if not state.test_states[problem_key] then - state.test_states[problem_key] = {} - if state.test_cases then - for i = 1, #state.test_cases do - state.test_states[problem_key][i] = true - end - end - end - - return state.test_states[problem_key] -end - local function set_platform(platform) if not vim.tbl_contains(platforms, platform) then logger.log(("unknown platform. Available: [%s]"):format(table.concat(platforms, ", ")), vim.log.levels.ERROR) From 73c5158e29dd1200cd14dc19855e5130ec9bdf31 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 15 Sep 2025 10:57:39 -0400 Subject: [PATCH 13/14] fix(ci): selene typing --- lua/cp/scrape.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index 8e5c387..bde861a 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -119,7 +119,7 @@ function M.scrape_contest_metadata(platform, contest_id) end ---@param ctx ProblemContext ----@return {success: boolean, problem_id: string, test_count?: number, url?: string, error?: string} +---@return {success: boolean, problem_id: string, test_count?: number, test_cases?: table[], url?: string, error?: string} function M.scrape_problem(ctx) ensure_io_directory() From 1c000942ed3d9cec07201b9d544bffac3c7aef11 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 15 Sep 2025 10:58:35 -0400 Subject: [PATCH 14/14] remove unused functions --- lua/cp/init.lua | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 27de29e..01a046a 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -34,17 +34,6 @@ local state = { local platforms = { "atcoder", "codeforces", "cses" } local actions = { "run", "debug", "diff", "next", "prev" } -local function get_current_problem_key() - if not state.platform or not state.contest_id then - return nil - end - if state.platform == "cses" then - return state.contest_id - else - return state.contest_id .. "_" .. (state.problem_id or "") - end -end - local function set_platform(platform) if not vim.tbl_contains(platforms, platform) then logger.log(("unknown platform. Available: [%s]"):format(table.concat(platforms, ", ")), vim.log.levels.ERROR)