From 127089c57f439a3634ef91c5c94aaffdd1152db7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:18:34 -0500 Subject: [PATCH 1/5] fix(submit): harden atcoder and codeforces submit flow (#304) ## Problem AtCoder file upload always wrote a `.cpp` temp file regardless of language. CF submit used `solve_cloudflare=True` on the submit page, causing a spurious "No Cloudflare challenge found" error; `_wait_for_gate_reload` in `login_action` was dead code. Stale cookies caused silent auth failures with no recovery path. The `uv.spawn` ndjson path for submit had no overall timeout. ## Solution Replace AtCoder's temp file with `page.set_input_files` using an in-memory buffer and correct extension via `_LANGUAGE_ID_EXTENSION`. Replace CF's temp-file/fallback dance with a direct `textarea[name="source"]` fill and set `solve_cloudflare=False` on the submit fetch. Add a login fast-path that skips the homepage check when cookies exist, with automatic stale-cookie recovery via `_retried` flag on redirect-to-login detection. Remove `_wait_for_gate_reload`. Fix `_ensure_browser` to propagate install errors. Add a 120s kill timer to the ndjson `uv.spawn` submit path in `scraper.lua`. --- lua/cp/scraper.lua | 29 +++++++++++++ scrapers/atcoder.py | 98 ++++++++++++++++++++++++------------------ scrapers/codeforces.py | 91 ++++++++++++++++----------------------- 3 files changed, 122 insertions(+), 96 deletions(-) diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index fc8ba69..6980c7d 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -85,6 +85,7 @@ local function run_scraper(platform, subcommand, args, opts) local stderr = uv.new_pipe(false) local buf = '' + local timer = nil local handle handle = uv.spawn(cmd[1], { args = vim.list_slice(cmd, 2), @@ -92,6 +93,10 @@ local function run_scraper(platform, subcommand, args, opts) env = spawn_env_list(env), cwd = plugin_path, }, function(code, signal) + if timer and not timer:is_closing() then + timer:stop() + timer:close() + end if buf ~= '' and opts.on_event then local ok_tail, ev_tail = pcall(vim.json.decode, buf) if ok_tail then @@ -124,6 +129,30 @@ local function run_scraper(platform, subcommand, args, opts) return { success = false, error = 'spawn failed' } end + if subcommand == 'submit' then + timer = uv.new_timer() + timer:start(120000, 0, function() + timer:stop() + timer:close() + if stdin_pipe and not stdin_pipe:is_closing() then + stdin_pipe:close() + end + if not stdout:is_closing() then + stdout:close() + end + if not stderr:is_closing() then + stderr:close() + end + if handle and not handle:is_closing() then + handle:kill(15) + handle:close() + end + if opts.on_exit then + opts.on_exit({ success = false, error = 'submit timed out' }) + end + end) + end + if stdin_pipe then uv.write(stdin_pipe, opts.stdin, function() uv.shutdown(stdin_pipe, function() diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 45a2195..8b3db72 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -6,7 +6,6 @@ import os import re import subprocess import sys -import tempfile import time from typing import Any @@ -38,6 +37,11 @@ from .timeouts import ( HTTP_TIMEOUT, ) +_LANGUAGE_ID_EXTENSION = { + "6017": "cc", + "6082": "py", +} + MIB_TO_MB = 1.048576 BASE_URL = "https://atcoder.jp" ARCHIVE_URL = f"{BASE_URL}/contests/archive" @@ -274,23 +278,20 @@ def _ensure_browser() -> None: from patchright._impl._driver import compute_driver_executable # type: ignore[import-untyped,unresolved-import] node, cli = compute_driver_executable() - browser_info = subprocess.run( - [node, cli, "install", "--dry-run", "chromium"], - capture_output=True, - text=True, - ) - for line in browser_info.stdout.splitlines(): - if "Install location:" in line: - install_dir = line.split(":", 1)[1].strip() - if not os.path.isdir(install_dir): - print(json.dumps({"status": "installing_browser"}), flush=True) - subprocess.run( - [node, cli, "install", "chromium"], - check=True, - ) - break except Exception: - pass + return + browser_info = subprocess.run( + [node, cli, "install", "--dry-run", "chromium"], + capture_output=True, + text=True, + ) + for line in browser_info.stdout.splitlines(): + if "Install location:" in line: + install_dir = line.split(":", 1)[1].strip() + if not os.path.isdir(install_dir): + print(json.dumps({"status": "installing_browser"}), flush=True) + subprocess.run([node, cli, "install", "chromium"], check=True) + break def _submit_headless( @@ -299,6 +300,7 @@ def _submit_headless( source_code: str, language_id: str, credentials: dict[str, str], + _retried: bool = False, ) -> "SubmitResult": from pathlib import Path @@ -321,9 +323,10 @@ def _submit_headless( except Exception: pass - logged_in = False + logged_in = cookie_cache.exists() and not _retried login_error: str | None = None submit_error: str | None = None + needs_relogin = False def check_login(page): nonlocal logged_in @@ -345,7 +348,10 @@ def _submit_headless( login_error = str(e) def submit_action(page): - nonlocal submit_error + nonlocal submit_error, needs_relogin + if "/login" in page.url: + needs_relogin = True + return try: _solve_turnstile(page) page.select_option( @@ -356,16 +362,16 @@ def _submit_headless( f'select[name="data.LanguageId"] option[value="{language_id}"]' ).wait_for(state="attached", timeout=BROWSER_ELEMENT_WAIT) page.select_option('select[name="data.LanguageId"]', language_id) - with tempfile.NamedTemporaryFile( - mode="w", suffix=".cpp", delete=False, prefix="atcoder_" - ) as tf: - tf.write(source_code) - tmp_path = tf.name - try: - page.set_input_files("#input-open-file", tmp_path) - page.wait_for_timeout(BROWSER_SETTLE_DELAY) - finally: - os.unlink(tmp_path) + ext = _LANGUAGE_ID_EXTENSION.get(language_id, "txt") + page.set_input_files( + "#input-open-file", + { + "name": f"solution.{ext}", + "mimeType": "text/plain", + "buffer": source_code.encode(), + }, + ) + page.wait_for_timeout(BROWSER_SETTLE_DELAY) page.locator('button[type="submit"]').click() page.wait_for_url( lambda url: "/submissions/me" in url, timeout=BROWSER_NAV_TIMEOUT @@ -378,14 +384,13 @@ def _submit_headless( headless=True, timeout=BROWSER_SESSION_TIMEOUT, google_search=False, - cookies=saved_cookies, + cookies=saved_cookies if (cookie_cache.exists() and not _retried) else [], ) as session: - print(json.dumps({"status": "checking_login"}), flush=True) - session.fetch( - f"{BASE_URL}/home", - page_action=check_login, - network_idle=True, - ) + if not (cookie_cache.exists() and not _retried): + print(json.dumps({"status": "checking_login"}), flush=True) + session.fetch( + f"{BASE_URL}/home", page_action=check_login, network_idle=True + ) if not logged_in: print(json.dumps({"status": "logging_in"}), flush=True) @@ -413,12 +418,23 @@ def _submit_headless( except Exception: pass - if submit_error: - return SubmitResult(success=False, error=submit_error) - - return SubmitResult( - success=True, error="", submission_id="", verdict="submitted" + if needs_relogin and not _retried: + cookie_cache.unlink(missing_ok=True) + return _submit_headless( + contest_id, + problem_id, + source_code, + language_id, + credentials, + _retried=True, ) + + if submit_error: + return SubmitResult(success=False, error=submit_error) + + return SubmitResult( + success=True, error="", submission_id="", verdict="submitted" + ) except Exception as e: return SubmitResult(success=False, error=str(e)) diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index fd0c129..d2c3164 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -2,9 +2,7 @@ import asyncio import json -import os import re -import tempfile from typing import Any import requests @@ -21,10 +19,8 @@ from .models import ( TestCase, ) from .timeouts import ( - BROWSER_ELEMENT_WAIT, BROWSER_NAV_TIMEOUT, BROWSER_SESSION_TIMEOUT, - BROWSER_SETTLE_DELAY, HTTP_TIMEOUT, ) @@ -307,24 +303,13 @@ class CodeforcesScraper(BaseScraper): ) -def _wait_for_gate_reload(page, wait_selector: str) -> None: - from .atcoder import _solve_turnstile - - if "Verification" not in page.title(): - return - _solve_turnstile(page) - page.wait_for_function( - f"() => !!document.querySelector('{wait_selector}')", - timeout=BROWSER_ELEMENT_WAIT, - ) - - def _submit_headless( contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str], + _retried: bool = False, ) -> SubmitResult: from pathlib import Path @@ -349,9 +334,10 @@ def _submit_headless( except Exception: pass - logged_in = False + logged_in = cookie_cache.exists() and not _retried login_error: str | None = None submit_error: str | None = None + needs_relogin = False def check_login(page): nonlocal logged_in @@ -362,10 +348,6 @@ def _submit_headless( def login_action(page): nonlocal login_error - try: - _wait_for_gate_reload(page, "#enterForm") - except Exception: - pass try: page.fill( 'input[name="handleOrEmail"]', @@ -383,28 +365,21 @@ def _submit_headless( login_error = str(e) def submit_action(page): - nonlocal submit_error + nonlocal submit_error, needs_relogin + if "/enter" in page.url or "/login" in page.url: + needs_relogin = True + return try: _solve_turnstile(page) except Exception: pass - tmp_path: str | None = None try: page.select_option( 'select[name="submittedProblemIndex"]', problem_id.upper(), ) page.select_option('select[name="programTypeId"]', language_id) - with tempfile.NamedTemporaryFile( - mode="w", suffix=".cpp", delete=False, prefix="cf_" - ) as tf: - tf.write(source_code) - tmp_path = tf.name - try: - page.set_input_files('input[name="sourceFile"]', tmp_path) - page.wait_for_timeout(BROWSER_SETTLE_DELAY) - except Exception: - page.fill('textarea[name="source"]', source_code) + page.fill('textarea[name="source"]', source_code) page.locator("form.submit-form input.submit").click(no_wait_after=True) try: page.wait_for_url( @@ -419,26 +394,21 @@ def _submit_headless( submit_error = "Submit failed: page did not navigate" except Exception as e: submit_error = str(e) - finally: - if tmp_path: - try: - os.unlink(tmp_path) - except OSError: - pass try: with StealthySession( headless=True, timeout=BROWSER_SESSION_TIMEOUT, google_search=False, - cookies=saved_cookies, + cookies=saved_cookies if (cookie_cache.exists() and not _retried) else [], ) as session: - print(json.dumps({"status": "checking_login"}), flush=True) - session.fetch( - f"{BASE_URL}/", - page_action=check_login, - network_idle=True, - ) + if not (cookie_cache.exists() and not _retried): + print(json.dumps({"status": "checking_login"}), flush=True) + session.fetch( + f"{BASE_URL}/", + page_action=check_login, + network_idle=True, + ) if not logged_in: print(json.dumps({"status": "logging_in"}), flush=True) @@ -456,7 +426,7 @@ def _submit_headless( session.fetch( f"{BASE_URL}/contest/{contest_id}/submit", page_action=submit_action, - solve_cloudflare=True, + solve_cloudflare=False, ) try: @@ -466,15 +436,26 @@ def _submit_headless( except Exception: pass - if submit_error: - return SubmitResult(success=False, error=submit_error) - - return SubmitResult( - success=True, - error="", - submission_id="", - verdict="submitted", + if needs_relogin and not _retried: + cookie_cache.unlink(missing_ok=True) + return _submit_headless( + contest_id, + problem_id, + source_code, + language_id, + credentials, + _retried=True, ) + + if submit_error: + return SubmitResult(success=False, error=submit_error) + + return SubmitResult( + success=True, + error="", + submission_id="", + verdict="submitted", + ) except Exception as e: return SubmitResult(success=False, error=str(e)) From 29af2df858bd9f0b1633097c953d5bb37be2a0e7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 12:54:37 -0500 Subject: [PATCH 2/5] refactor(logger): table-based LogOpts; add sync, on_done to test stream Problem: `logger.log` positional args were hard to extend, and adding `sync` support for pre-block notifications required a clean API. Test stream completion had no user-visible signal. `setup_contest` could silently overwrite files when a user's `filename` config returned colliding paths. Solution: Replace `(msg, level, override)` with `(msg, LogOpts?)` where `LogOpts` carries `level`, `override`, and `sync`. Sync path calls `vim.notify` directly; async path uses `vim.schedule` as before. Add `on_done` callback to `scrape_all_tests`, fired via `on_exit` and surfaced as a "Loaded N tests." notification. Detect filename collisions in `proceed()` before touching the filesystem. Migrate all call sites. --- lua/cp/commands/cache.lua | 12 +++++------- lua/cp/commands/init.lua | 6 +++--- lua/cp/commands/picker.lua | 10 +++++----- lua/cp/credentials.lua | 12 ++++++------ lua/cp/init.lua | 2 +- lua/cp/log.lua | 23 ++++++++++++++++++---- lua/cp/pickers/init.lua | 16 +++------------ lua/cp/race.lua | 21 ++++++++++---------- lua/cp/restore.lua | 2 +- lua/cp/runner/execute.lua | 2 +- lua/cp/runner/run.lua | 10 ++++------ lua/cp/scraper.lua | 22 +++++++++++++-------- lua/cp/setup.lua | 21 +++++++++++--------- lua/cp/stress.lua | 10 +++++----- lua/cp/submit.lua | 12 ++++++------ lua/cp/ui/edit.lua | 10 +++++----- lua/cp/ui/views.lua | 40 +++++++++++++++++++------------------- lua/cp/utils.lua | 12 ++++++------ 18 files changed, 126 insertions(+), 117 deletions(-) diff --git a/lua/cp/commands/cache.lua b/lua/cp/commands/cache.lua index aba8bf5..f91aa75 100644 --- a/lua/cp/commands/cache.lua +++ b/lua/cp/commands/cache.lua @@ -47,26 +47,24 @@ function M.handle_cache_command(cmd) constants.PLATFORM_DISPLAY_NAMES[cmd.platform], cmd.contest ), - vim.log.levels.INFO, - true + { level = vim.log.levels.INFO, override = true } ) else - logger.log(("Unknown platform '%s'."):format(cmd.platform), vim.log.levels.ERROR) + logger.log(("Unknown platform '%s'."):format(cmd.platform), { level = vim.log.levels.ERROR }) end elseif cmd.platform then if vim.tbl_contains(platforms, cmd.platform) then cache.clear_platform(cmd.platform) logger.log( ("Cache cleared for platform '%s'"):format(constants.PLATFORM_DISPLAY_NAMES[cmd.platform]), - vim.log.levels.INFO, - true + { level = vim.log.levels.INFO, override = true } ) else - logger.log(("Unknown platform '%s'."):format(cmd.platform), vim.log.levels.ERROR) + logger.log(("Unknown platform '%s'."):format(cmd.platform), { level = vim.log.levels.ERROR }) end else cache.clear_all() - logger.log('Cache cleared', vim.log.levels.INFO, true) + logger.log('Cache cleared', { level = vim.log.levels.INFO, override = true }) end end end diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 56a473f..64e37fb 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -287,7 +287,7 @@ function M.handle_command(opts) local cmd = parse_command(opts.fargs) if cmd.type == 'error' then - logger.log(cmd.message, vim.log.levels.ERROR) + logger.log(cmd.message, { level = vim.log.levels.ERROR }) return end @@ -336,7 +336,7 @@ function M.handle_command(opts) local problem_id = cmd.problem_id if not (platform and contest_id) then - logger.log('No contest is currently active.', vim.log.levels.ERROR) + logger.log('No contest is currently active.', { level = vim.log.levels.ERROR }) return end @@ -351,7 +351,7 @@ function M.handle_command(opts) contest_id, problem_id ), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end diff --git a/lua/cp/commands/picker.lua b/lua/cp/commands/picker.lua index dc49bea..e01b680 100644 --- a/lua/cp/commands/picker.lua +++ b/lua/cp/commands/picker.lua @@ -12,7 +12,7 @@ function M.handle_pick_action(language) if not (config.ui and config.ui.picker) then logger.log( 'No picker configured. Set ui.picker = "{telescope,fzf-lua}" in your config.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -25,13 +25,13 @@ function M.handle_pick_action(language) if not ok then logger.log( 'telescope.nvim is not available. Install telescope.nvim xor change your picker config.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end local ok_cp, telescope_picker = pcall(require, 'cp.pickers.telescope') if not ok_cp then - logger.log('Failed to load telescope integration.', vim.log.levels.ERROR) + logger.log('Failed to load telescope integration.', { level = vim.log.levels.ERROR }) return end @@ -41,13 +41,13 @@ function M.handle_pick_action(language) if not ok then logger.log( 'fzf-lua is not available. Install fzf-lua or change your picker config', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end local ok_cp, fzf_picker = pcall(require, 'cp.pickers.fzf_lua') if not ok_cp then - logger.log('Failed to load fzf-lua integration.', vim.log.levels.ERROR) + logger.log('Failed to load fzf-lua integration.', { level = vim.log.levels.ERROR }) return end diff --git a/lua/cp/credentials.lua b/lua/cp/credentials.lua index 8f80743..497e544 100644 --- a/lua/cp/credentials.lua +++ b/lua/cp/credentials.lua @@ -7,37 +7,37 @@ local state = require('cp.state') function M.login(platform) platform = platform or state.get_platform() if not platform then - logger.log('No platform specified. Usage: :CP login ', vim.log.levels.ERROR) + logger.log('No platform specified. Usage: :CP login ', { level = vim.log.levels.ERROR }) return end vim.ui.input({ prompt = platform .. ' username: ' }, function(username) if not username or username == '' then - logger.log('Cancelled', vim.log.levels.WARN) + logger.log('Cancelled', { level = vim.log.levels.WARN }) return end vim.fn.inputsave() local password = vim.fn.inputsecret(platform .. ' password: ') vim.fn.inputrestore() if not password or password == '' then - logger.log('Cancelled', vim.log.levels.WARN) + logger.log('Cancelled', { level = vim.log.levels.WARN }) return end cache.load() cache.set_credentials(platform, { username = username, password = password }) - logger.log(platform .. ' credentials saved', vim.log.levels.INFO, true) + logger.log(platform .. ' credentials saved', { level = vim.log.levels.INFO, override = true }) end) end function M.logout(platform) platform = platform or state.get_platform() if not platform then - logger.log('No platform specified. Usage: :CP logout ', vim.log.levels.ERROR) + logger.log('No platform specified. Usage: :CP logout ', { level = vim.log.levels.ERROR }) return end cache.load() cache.clear_credentials(platform) - logger.log(platform .. ' credentials cleared', vim.log.levels.INFO, true) + logger.log(platform .. ' credentials cleared', { level = vim.log.levels.INFO, override = true }) end return M diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 088272a..4ffb530 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -7,7 +7,7 @@ local logger = require('cp.log') M.helpers = helpers if vim.fn.has('nvim-0.10.0') == 0 then - logger.log('Requires nvim-0.10.0+', vim.log.levels.ERROR) + logger.log('Requires nvim-0.10.0+', { level = vim.log.levels.ERROR }) return {} end diff --git a/lua/cp/log.lua b/lua/cp/log.lua index 02bc5f4..74a8859 100644 --- a/lua/cp/log.lua +++ b/lua/cp/log.lua @@ -1,12 +1,27 @@ local M = {} -function M.log(msg, level, override) +---@class LogOpts +---@field level? integer +---@field override? boolean +---@field sync? boolean + +---@param msg string +---@param opts? LogOpts +function M.log(msg, opts) local debug = require('cp.config').get_config().debug or false - level = level or vim.log.levels.INFO + opts = opts or {} + local level = opts.level or vim.log.levels.INFO + local override = opts.override or false + local sync = opts.sync or false if level >= vim.log.levels.WARN or override or debug then - vim.schedule(function() + local notify = function() vim.notify(('[cp.nvim]: %s'):format(msg), level) - end) + end + if sync then + notify() + else + vim.schedule(notify) + end end end diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index c634c64..70d191b 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -42,24 +42,14 @@ function M.get_platform_contests(platform, refresh) local picker_contests = cache.get_contest_summaries(platform) if refresh or vim.tbl_isempty(picker_contests) then - logger.log( - ('Loading %s contests...'):format(constants.PLATFORM_DISPLAY_NAMES[platform]), - vim.log.levels.INFO, - true - ) + local display_name = constants.PLATFORM_DISPLAY_NAMES[platform] + logger.log(('Fetching %s contests...'):format(display_name), { level = vim.log.levels.INFO, override = true, sync = true }) local contests = scraper.scrape_contest_list(platform) cache.set_contest_summaries(platform, contests) picker_contests = cache.get_contest_summaries(platform) - logger.log( - ('Loaded %d %s contests.'):format( - #picker_contests, - constants.PLATFORM_DISPLAY_NAMES[platform] - ), - vim.log.levels.INFO, - true - ) + logger.log(('Fetched %d %s contests.'):format(#picker_contests, display_name), { level = vim.log.levels.INFO, override = true }) end return picker_contests diff --git a/lua/cp/race.lua b/lua/cp/race.lua index 0e303ee..fc32f41 100644 --- a/lua/cp/race.lua +++ b/lua/cp/race.lua @@ -22,15 +22,15 @@ end function M.start(platform, contest_id, language) if not platform or not vim.tbl_contains(constants.PLATFORMS, platform) then - logger.log('Invalid platform', vim.log.levels.ERROR) + logger.log('Invalid platform', { level = vim.log.levels.ERROR }) return end if not contest_id or contest_id == '' then - logger.log('Contest ID required', vim.log.levels.ERROR) + logger.log('Contest ID required', { level = vim.log.levels.ERROR }) return end if race_state.timer then - logger.log('Race already active. Use :CP race stop first.', vim.log.levels.WARN) + logger.log('Race already active. Use :CP race stop first.', { level = vim.log.levels.WARN }) return end @@ -38,7 +38,7 @@ function M.start(platform, contest_id, language) local start_time = cache.get_contest_start_time(platform, contest_id) if not start_time then - logger.log('Fetching contest list...', vim.log.levels.INFO, true) + logger.log('Fetching contest list...', { level = vim.log.levels.INFO, override = true }) local contests = scraper.scrape_contest_list(platform) if contests and #contests > 0 then cache.set_contest_summaries(platform, contests) @@ -52,14 +52,14 @@ function M.start(platform, contest_id, language) constants.PLATFORM_DISPLAY_NAMES[platform] or platform, contest_id ), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end local remaining = start_time - os.time() if remaining <= 0 then - logger.log('Contest has already started, setting up...', vim.log.levels.INFO, true) + logger.log('Contest has already started, setting up...', { level = vim.log.levels.INFO, override = true }) require('cp.setup').setup_contest(platform, contest_id, nil, language) return end @@ -75,8 +75,7 @@ function M.start(platform, contest_id, language) contest_id, format_countdown(remaining) ), - vim.log.levels.INFO, - true + { level = vim.log.levels.INFO, override = true } ) local timer = vim.uv.new_timer() @@ -97,7 +96,7 @@ function M.start(platform, contest_id, language) race_state.contest_id = nil race_state.language = nil race_state.start_time = nil - logger.log('Contest started!', vim.log.levels.INFO, true) + logger.log('Contest started!', { level = vim.log.levels.INFO, override = true }) require('cp.setup').setup_contest(p, c, nil, l) else vim.notify( @@ -116,7 +115,7 @@ end function M.stop() local timer = race_state.timer if not timer then - logger.log('No active race', vim.log.levels.WARN) + logger.log('No active race', { level = vim.log.levels.WARN }) return end timer:stop() @@ -126,7 +125,7 @@ function M.stop() race_state.contest_id = nil race_state.language = nil race_state.start_time = nil - logger.log('Race cancelled', vim.log.levels.INFO, true) + logger.log('Race cancelled', { level = vim.log.levels.INFO, override = true }) end function M.status() diff --git a/lua/cp/restore.lua b/lua/cp/restore.lua index fde3fd5..8b9eeab 100644 --- a/lua/cp/restore.lua +++ b/lua/cp/restore.lua @@ -11,7 +11,7 @@ function M.restore_from_current_file() local current_file = (vim.uv.fs_realpath(vim.fn.expand('%:p')) or vim.fn.expand('%:p')) local file_state = cache.get_file_state(current_file) if not file_state then - logger.log('No cached state found for current file.', vim.log.levels.ERROR) + logger.log('No cached state found for current file.', { level = vim.log.levels.ERROR }) return false end diff --git a/lua/cp/runner/execute.lua b/lua/cp/runner/execute.lua index 9e40b4f..41a7b51 100644 --- a/lua/cp/runner/execute.lua +++ b/lua/cp/runner/execute.lua @@ -52,7 +52,7 @@ function M.compile(compile_cmd, substitutions, on_complete) r.stdout = ansi.bytes_to_string(r.stdout or '') if r.code == 0 then - logger.log(('Compilation successful in %.1fms.'):format(dt), vim.log.levels.INFO) + logger.log(('Compilation successful in %.1fms.'):format(dt), { level = vim.log.levels.INFO }) else logger.log(('Compilation failed in %.1fms.'):format(dt)) end diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index 306a1f8..2b374b0 100644 --- a/lua/cp/runner/run.lua +++ b/lua/cp/runner/run.lua @@ -245,7 +245,7 @@ function M.load_test_cases() state.get_problem_id() ) - logger.log(('Loaded %d test case(s)'):format(#tcs), vim.log.levels.INFO) + logger.log(('Loaded %d test case(s)'):format(#tcs), { level = vim.log.levels.INFO }) return #tcs > 0 end @@ -259,7 +259,7 @@ function M.run_combined_test(debug, on_complete) ) if not combined then - logger.log('No combined test found', vim.log.levels.ERROR) + logger.log('No combined test found', { level = vim.log.levels.ERROR }) on_complete(nil) return end @@ -330,8 +330,7 @@ function M.run_all_test_cases(indices, debug, on_each, on_done) if #to_run == 0 then logger.log( ('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', 0), - vim.log.levels.INFO, - true + { level = vim.log.levels.INFO, override = true } ) on_done(panel_state.test_cases) return @@ -349,8 +348,7 @@ function M.run_all_test_cases(indices, debug, on_each, on_done) if remaining == 0 then logger.log( ('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', total), - vim.log.levels.INFO, - true + { level = vim.log.levels.INFO, override = true } ) on_done(panel_state.test_cases) end diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 6980c7d..5e0d5c1 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -16,7 +16,7 @@ local function syshandle(result) end local msg = 'Failed to parse scraper output: ' .. tostring(data) - logger.log(msg, vim.log.levels.ERROR) + logger.log(msg, { level = vim.log.levels.ERROR }) return { success = false, error = msg } end @@ -37,7 +37,7 @@ end local function run_scraper(platform, subcommand, args, opts) if not utils.setup_python_env() then local msg = 'no Python environment available (install uv or nix)' - logger.log(msg, vim.log.levels.ERROR) + logger.log(msg, { level = vim.log.levels.ERROR }) if opts and opts.on_exit then opts.on_exit({ success = false, error = msg }) end @@ -125,7 +125,7 @@ local function run_scraper(platform, subcommand, args, opts) if stdin_pipe and not stdin_pipe:is_closing() then stdin_pipe:close() end - logger.log('Failed to start scraper process', vim.log.levels.ERROR) + logger.log('Failed to start scraper process', { level = vim.log.levels.ERROR }) return { success = false, error = 'spawn failed' } end @@ -221,7 +221,7 @@ function M.scrape_contest_metadata(platform, contest_id, callback) constants.PLATFORM_DISPLAY_NAMES[platform], contest_id ), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -232,7 +232,7 @@ function M.scrape_contest_metadata(platform, contest_id, callback) constants.PLATFORM_DISPLAY_NAMES[platform], contest_id ), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -251,7 +251,7 @@ function M.scrape_contest_list(platform) platform, (result and result.error) or 'unknown' ), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return {} end @@ -261,9 +261,15 @@ end ---@param platform string ---@param contest_id string ---@param callback fun(data: table)|nil -function M.scrape_all_tests(platform, contest_id, callback) +---@param on_done fun()|nil +function M.scrape_all_tests(platform, contest_id, callback, on_done) run_scraper(platform, 'tests', { contest_id }, { ndjson = true, + on_exit = function() + if type(on_done) == 'function' then + vim.schedule(on_done) + end + end, on_event = function(ev) if ev.done then return @@ -275,7 +281,7 @@ function M.scrape_all_tests(platform, contest_id, callback) contest_id, ev.error ), - vim.log.levels.WARN + { level = vim.log.levels.WARN } ) return end diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 19fa776..e27ceb0 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -16,7 +16,7 @@ local function apply_template(bufnr, lang_id, platform) end local path = vim.fn.expand(eff.template) if vim.fn.filereadable(path) ~= 1 then - logger.log(('[cp.nvim] template not readable: %s'):format(path), vim.log.levels.WARN) + logger.log(('[cp.nvim] template not readable: %s'):format(path), { level = vim.log.levels.WARN }) return end local lines = vim.fn.readfile(path) @@ -112,11 +112,12 @@ local function start_tests(platform, contest_id, problems) return not vim.tbl_isempty(cache.get_test_cases(platform, contest_id, p.id)) end, problems) if cached_len ~= #problems then + local to_fetch = #problems - cached_len logger.log(('Fetching %s/%s problem tests...'):format(cached_len, #problems)) scraper.scrape_all_tests(platform, contest_id, function(ev) local cached_tests = {} if not ev.interactive and vim.tbl_isempty(ev.tests) then - logger.log(("No tests found for problem '%s'."):format(ev.problem_id), vim.log.levels.WARN) + logger.log(("No tests found for problem '%s'."):format(ev.problem_id), { level = vim.log.levels.WARN }) end for i, t in ipairs(ev.tests) do cached_tests[i] = { index = i, input = t.input, expected = t.expected } @@ -142,6 +143,8 @@ local function start_tests(platform, contest_id, problems) require('cp.utils').update_buffer_content(io_state.input_buf, input_lines, nil, nil) end end + end, function() + logger.log(('Loaded %d test%s.'):format(to_fetch, to_fetch == 1 and '' or 's'), { level = vim.log.levels.INFO, override = true }) end) end end @@ -160,7 +163,7 @@ function M.setup_contest(platform, contest_id, problem_id, language) if language then local lang_result = config_module.get_language_for_platform(platform, language) if not lang_result.valid then - logger.log(lang_result.error, vim.log.levels.ERROR) + logger.log(lang_result.error, { level = vim.log.levels.ERROR }) return end end @@ -206,7 +209,7 @@ function M.setup_contest(platform, contest_id, problem_id, language) token = vim.uv.hrtime(), }) - logger.log('Fetching contests problems...', vim.log.levels.INFO, true) + logger.log('Fetching contests problems...', { level = vim.log.levels.INFO, override = true }) scraper.scrape_contest_metadata( platform, contest_id, @@ -242,7 +245,7 @@ end function M.setup_problem(problem_id, language) local platform = state.get_platform() if not platform then - logger.log('No platform/contest/problem configured.', vim.log.levels.ERROR) + logger.log('No platform/contest/problem configured.', { level = vim.log.levels.ERROR }) return end @@ -263,7 +266,7 @@ function M.setup_problem(problem_id, language) if language then local lang_result = config_module.get_language_for_platform(platform, language) if not lang_result.valid then - logger.log(lang_result.error, vim.log.levels.ERROR) + logger.log(lang_result.error, { level = vim.log.levels.ERROR }) return end end @@ -397,7 +400,7 @@ function M.navigate_problem(direction, language) local contest_id = state.get_contest_id() local current_problem_id = state.get_problem_id() if not platform or not contest_id or not current_problem_id then - logger.log('No platform configured.', vim.log.levels.ERROR) + logger.log('No platform configured.', { level = vim.log.levels.ERROR }) return end @@ -409,7 +412,7 @@ function M.navigate_problem(direction, language) constants.PLATFORM_DISPLAY_NAMES[platform], contest_id ), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -433,7 +436,7 @@ function M.navigate_problem(direction, language) if language then local lang_result = config_module.get_language_for_platform(platform, language) if not lang_result.valid then - logger.log(lang_result.error, vim.log.levels.ERROR) + logger.log(lang_result.error, { level = vim.log.levels.ERROR }) return end lang = language diff --git a/lua/cp/stress.lua b/lua/cp/stress.lua index 3e51881..9063d0d 100644 --- a/lua/cp/stress.lua +++ b/lua/cp/stress.lua @@ -36,7 +36,7 @@ local function compile_cpp(source, output) if result.code ~= 0 then logger.log( ('Failed to compile %s: %s'):format(source, result.stderr or ''), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return false end @@ -76,7 +76,7 @@ function M.toggle(generator_cmd, brute_cmd) end if state.get_active_panel() then - logger.log('Another panel is already active.', vim.log.levels.WARN) + logger.log('Another panel is already active.', { level = vim.log.levels.WARN }) return end @@ -93,14 +93,14 @@ function M.toggle(generator_cmd, brute_cmd) if not gen_file then logger.log( 'No generator found. Pass generator as first arg or add gen.{py,cc,cpp}.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end if not brute_file then logger.log( 'No brute solution found. Pass brute as second arg or add brute.{py,cc,cpp}.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -140,7 +140,7 @@ function M.toggle(generator_cmd, brute_cmd) local binary = state.get_binary_file() if not binary or binary == '' then - logger.log('No binary produced.', vim.log.levels.ERROR) + logger.log('No binary produced.', { level = vim.log.levels.ERROR }) restore_session() return end diff --git a/lua/cp/submit.lua b/lua/cp/submit.lua index 7dc9a71..d89d2a4 100644 --- a/lua/cp/submit.lua +++ b/lua/cp/submit.lua @@ -19,7 +19,7 @@ local function prompt_credentials(platform, callback) end vim.ui.input({ prompt = platform .. ' username: ' }, function(username) if not username or username == '' then - logger.log('Submit cancelled', vim.log.levels.WARN) + logger.log('Submit cancelled', { level = vim.log.levels.WARN }) return end vim.fn.inputsave() @@ -27,7 +27,7 @@ local function prompt_credentials(platform, callback) vim.fn.inputrestore() vim.cmd.redraw() if not password or password == '' then - logger.log('Submit cancelled', vim.log.levels.WARN) + logger.log('Submit cancelled', { level = vim.log.levels.WARN }) return end local creds = { username = username, password = password } @@ -42,13 +42,13 @@ function M.submit(opts) local problem_id = state.get_problem_id() local language = (opts and opts.language) or state.get_language() if not platform or not contest_id or not problem_id or not language then - logger.log('No active problem. Use :CP first.', vim.log.levels.ERROR) + logger.log('No active problem. Use :CP first.', { level = vim.log.levels.ERROR }) return end local source_file = state.get_source_file() if not source_file or vim.fn.filereadable(source_file) ~= 1 then - logger.log('Source file not found', vim.log.levels.ERROR) + logger.log('Source file not found', { level = vim.log.levels.ERROR }) return end @@ -73,11 +73,11 @@ function M.submit(opts) function(result) vim.schedule(function() if result and result.success then - logger.log('Submitted successfully', vim.log.levels.INFO, true) + logger.log('Submitted successfully', { level = vim.log.levels.INFO, override = true }) else logger.log( 'Submit failed: ' .. (result and result.error or 'unknown error'), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) end end) diff --git a/lua/cp/ui/edit.lua b/lua/cp/ui/edit.lua index 20d4e83..5d7f764 100644 --- a/lua/cp/ui/edit.lua +++ b/lua/cp/ui/edit.lua @@ -90,7 +90,7 @@ local function delete_current_test() return end if #edit_state.test_buffers == 1 then - logger.log('Problems must have at least one test case.', vim.log.levels.ERROR) + logger.log('Problems must have at least one test case.', { level = vim.log.levels.ERROR }) return end @@ -311,7 +311,7 @@ setup_keybindings = function(buf) end if is_tracked then - logger.log('Test buffer closed unexpectedly. Exiting editor.', vim.log.levels.WARN) + logger.log('Test buffer closed unexpectedly. Exiting editor.', { level = vim.log.levels.WARN }) M.toggle_edit() end end) @@ -368,7 +368,7 @@ function M.toggle_edit(test_index) state.get_platform(), state.get_contest_id(), state.get_problem_id() if not platform or not contest_id or not problem_id then - logger.log('No problem context. Run :CP first.', vim.log.levels.ERROR) + logger.log('No problem context. Run :CP first.', { level = vim.log.levels.ERROR }) return end @@ -376,7 +376,7 @@ function M.toggle_edit(test_index) local test_cases = cache.get_test_cases(platform, contest_id, problem_id) if not test_cases or #test_cases == 0 then - logger.log('No test cases available for editing.', vim.log.levels.ERROR) + logger.log('No test cases available for editing.', { level = vim.log.levels.ERROR }) return end @@ -389,7 +389,7 @@ function M.toggle_edit(test_index) if target_index < 1 or target_index > #test_cases then logger.log( ('Test %d does not exist (only %d tests available)'):format(target_index, #test_cases), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index 5e658c4..c2053b0 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -53,7 +53,7 @@ function M.toggle_interactive(interactor_cmd) end if state.get_active_panel() then - logger.log('Another panel is already active.', vim.log.levels.WARN) + logger.log('Another panel is already active.', { level = vim.log.levels.WARN }) return end @@ -62,7 +62,7 @@ function M.toggle_interactive(interactor_cmd) if not platform or not contest_id or not problem_id then logger.log( 'No platform/contest/problem configured. Use :CP [...] first.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -74,7 +74,7 @@ function M.toggle_interactive(interactor_cmd) and contest_data.index_map and not contest_data.problems[contest_data.index_map[problem_id]].interactive then - logger.log('This problem is not interactive. Use :CP {run,panel}.', vim.log.levels.ERROR) + logger.log('This problem is not interactive. Use :CP {run,panel}.', { level = vim.log.levels.ERROR }) return end @@ -103,7 +103,7 @@ function M.toggle_interactive(interactor_cmd) local binary = state.get_binary_file() if not binary or binary == '' then - logger.log('No binary produced.', vim.log.levels.ERROR) + logger.log('No binary produced.', { level = vim.log.levels.ERROR }) restore_session() return end @@ -117,7 +117,7 @@ function M.toggle_interactive(interactor_cmd) if vim.fn.executable(interactor) ~= 1 then logger.log( ("Interactor '%s' is not executable."):format(interactor_cmd), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) restore_session() return @@ -354,7 +354,7 @@ function M.ensure_io_view() if not platform or not contest_id or not problem_id then logger.log( 'No platform/contest/problem configured. Use :CP [...] first.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -383,7 +383,7 @@ function M.ensure_io_view() and contest_data.index_map and contest_data.problems[contest_data.index_map[problem_id]].interactive then - logger.log('This problem is not interactive. Use :CP {run,panel}.', vim.log.levels.ERROR) + logger.log('This problem is not interactive. Use :CP {run,panel}.', { level = vim.log.levels.ERROR }) return end @@ -594,12 +594,12 @@ end function M.run_io_view(test_indices_arg, debug, mode) if io_view_running then - logger.log('Tests already running', vim.log.levels.WARN) + logger.log('Tests already running', { level = vim.log.levels.WARN }) return end io_view_running = true - logger.log(('%s tests...'):format(debug and 'Debugging' or 'Running'), vim.log.levels.INFO, true) + logger.log(('%s tests...'):format(debug and 'Debugging' or 'Running'), { level = vim.log.levels.INFO, override = true }) mode = mode or 'combined' @@ -608,7 +608,7 @@ function M.run_io_view(test_indices_arg, debug, mode) if not platform or not contest_id or not problem_id then logger.log( 'No platform/contest/problem configured. Use :CP [...] first.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) io_view_running = false return @@ -617,7 +617,7 @@ function M.run_io_view(test_indices_arg, debug, mode) cache.load() local contest_data = cache.get_contest_data(platform, contest_id) if not contest_data or not contest_data.index_map then - logger.log('No test cases available.', vim.log.levels.ERROR) + logger.log('No test cases available.', { level = vim.log.levels.ERROR }) io_view_running = false return end @@ -634,13 +634,13 @@ function M.run_io_view(test_indices_arg, debug, mode) if mode == 'combined' then local combined = cache.get_combined_test(platform, contest_id, problem_id) if not combined then - logger.log('No combined test available', vim.log.levels.ERROR) + logger.log('No combined test available', { level = vim.log.levels.ERROR }) io_view_running = false return end else if not run.load_test_cases() then - logger.log('No test cases available', vim.log.levels.ERROR) + logger.log('No test cases available', { level = vim.log.levels.ERROR }) io_view_running = false return end @@ -660,7 +660,7 @@ function M.run_io_view(test_indices_arg, debug, mode) idx, #test_state.test_cases ), - vim.log.levels.WARN + { level = vim.log.levels.WARN } ) io_view_running = false return @@ -721,7 +721,7 @@ function M.run_io_view(test_indices_arg, debug, mode) if mode == 'combined' then local combined = cache.get_combined_test(platform, contest_id, problem_id) if not combined then - logger.log('No combined test found', vim.log.levels.ERROR) + logger.log('No combined test found', { level = vim.log.levels.ERROR }) io_view_running = false return end @@ -730,7 +730,7 @@ function M.run_io_view(test_indices_arg, debug, mode) run.run_combined_test(debug, function(result) if not result then - logger.log('Failed to run combined test', vim.log.levels.ERROR) + logger.log('Failed to run combined test', { level = vim.log.levels.ERROR }) io_view_running = false return end @@ -771,7 +771,7 @@ function M.toggle_panel(panel_opts) end if state.get_active_panel() then - logger.log('another panel is already active', vim.log.levels.ERROR) + logger.log('another panel is already active', { level = vim.log.levels.ERROR }) return end @@ -780,7 +780,7 @@ function M.toggle_panel(panel_opts) if not platform or not contest_id then logger.log( 'No platform/contest configured. Use :CP [...] first.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -792,7 +792,7 @@ function M.toggle_panel(panel_opts) and contest_data.index_map and contest_data.problems[contest_data.index_map[state.get_problem_id()]].interactive then - logger.log('This is an interactive problem. Use :CP interact instead.', vim.log.levels.WARN) + logger.log('This is an interactive problem. Use :CP interact instead.', { level = vim.log.levels.WARN }) return end @@ -803,7 +803,7 @@ function M.toggle_panel(panel_opts) logger.log(('run panel: checking test cases for %s'):format(input_file or 'none')) if not run.load_test_cases() then - logger.log('no test cases found', vim.log.levels.WARN) + logger.log('no test cases found', { level = vim.log.levels.WARN }) return end diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index 3111fec..4e88cc2 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -152,7 +152,7 @@ local function discover_nix_submit_cmd() :wait() if result.code ~= 0 then - logger.log('nix build #submitEnv failed: ' .. (result.stderr or ''), vim.log.levels.WARN) + logger.log('nix build #submitEnv failed: ' .. (result.stderr or ''), { level = vim.log.levels.WARN }) return false end @@ -160,7 +160,7 @@ local function discover_nix_submit_cmd() local submit_cmd = store_path .. '/bin/cp-nvim-submit' if vim.fn.executable(submit_cmd) ~= 1 then - logger.log('nix submit cmd not executable at ' .. submit_cmd, vim.log.levels.WARN) + logger.log('nix submit cmd not executable at ' .. submit_cmd, { level = vim.log.levels.WARN }) return false end @@ -216,7 +216,7 @@ local function discover_nix_python() :wait() if result.code ~= 0 then - logger.log('nix build #pythonEnv failed: ' .. (result.stderr or ''), vim.log.levels.WARN) + logger.log('nix build #pythonEnv failed: ' .. (result.stderr or ''), { level = vim.log.levels.WARN }) return false end @@ -224,7 +224,7 @@ local function discover_nix_python() local python_path = store_path .. '/bin/python3' if vim.fn.executable(python_path) ~= 1 then - logger.log('nix python not executable at ' .. python_path, vim.log.levels.WARN) + logger.log('nix python not executable at ' .. python_path, { level = vim.log.levels.WARN }) return false end @@ -270,7 +270,7 @@ function M.setup_python_env() if result.code ~= 0 then logger.log( 'Failed to setup Python environment: ' .. (result.stderr or ''), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return false end @@ -292,7 +292,7 @@ function M.setup_python_env() logger.log( 'No Python environment available. Install uv (https://docs.astral.sh/uv/) or use nix.', - vim.log.levels.WARN + { level = vim.log.levels.WARN } ) return false end From 86b3eb9582cc1fbb182e78e861ff789ce4716036 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 13:00:24 -0500 Subject: [PATCH 3/5] fix(setup): guard against overwriting existing problem files If the destination source file already exists on disk and belongs to a different platform/contest/problem in the cache, abort with an error rather than silently clobbering it. --- lua/cp/setup.lua | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index e27ceb0..48bdf33 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -278,6 +278,19 @@ function M.setup_problem(problem_id, language) return end + if vim.fn.filereadable(source_file) == 1 then + local existing = cache.get_file_state(vim.fn.fnamemodify(source_file, ':p')) + if existing and (existing.platform ~= platform or existing.contest_id ~= (state.get_contest_id() or '') or existing.problem_id ~= problem_id) then + logger.log( + ('File %q already exists for %s/%s %s.'):format( + source_file, existing.platform, existing.contest_id, existing.problem_id + ), + { level = vim.log.levels.ERROR } + ) + return + end + end + local contest_dir = vim.fn.fnamemodify(source_file, ':h') local is_new_dir = vim.fn.isdirectory(contest_dir) == 0 vim.fn.mkdir(contest_dir, 'p') From df934efb193f3f7edc6df797e745b7f0955527d5 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 13:26:29 -0500 Subject: [PATCH 4/5] fix(submit): clear cached credentials on login failure Problem: bad credentials were saved to cache before validation, so subsequent `:CP submit` calls silently reused them instead of re-prompting the user. Solution: match `"Login failed"` in the submit error callback and call `cache.clear_credentials()` so the next attempt re-prompts. --- lua/cp/submit.lua | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lua/cp/submit.lua b/lua/cp/submit.lua index d89d2a4..979c383 100644 --- a/lua/cp/submit.lua +++ b/lua/cp/submit.lua @@ -75,10 +75,11 @@ function M.submit(opts) if result and result.success then logger.log('Submitted successfully', { level = vim.log.levels.INFO, override = true }) else - logger.log( - 'Submit failed: ' .. (result and result.error or 'unknown error'), - { level = vim.log.levels.ERROR } - ) + local err = result and result.error or 'unknown error' + if err:match('^Login failed') then + cache.clear_credentials(platform) + end + logger.log('Submit failed: ' .. err, { level = vim.log.levels.ERROR }) end end) end From ccaedfebc166c0a082854e0adf4a3ce4076d9261 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 14:10:05 -0500 Subject: [PATCH 5/5] refactor(commands): move login/logout under platform namespace Problem: `:CP login codeforces` was inconsistent with every other command which uses `:CP ` ordering. Solution: restructure to `:CP codeforces login` / `:CP codeforces logout`. Remove `login`/`logout` from top-level `ACTIONS` and move parsing into the platform branch of `parse_command`. Update completion to offer `login`/`logout` as platform subcommands. --- lua/cp/commands/init.lua | 5 +++-- lua/cp/constants.lua | 2 -- plugin/cp.lua | 8 +++----- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 64e37fb..4c594bd 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -83,8 +83,6 @@ local function parse_command(args) else return { type = 'action', action = 'interact' } end - elseif first == 'login' or first == 'logout' then - return { type = 'action', action = first, platform = args[2] } elseif first == 'stress' then return { type = 'action', @@ -245,6 +243,9 @@ local function parse_command(args) message = 'Too few arguments - specify a contest.', } elseif #args == 2 then + if args[2] == 'login' or args[2] == 'logout' then + return { type = 'action', action = args[2], platform = first } + end return { type = 'contest_setup', platform = first, diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index 5d1c3c5..21e8f62 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -13,8 +13,6 @@ M.ACTIONS = { 'race', 'stress', 'submit', - 'login', - 'logout', } M.PLATFORM_DISPLAY_NAMES = { diff --git a/plugin/cp.lua b/plugin/cp.lua index db9a8bd..60efb7a 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -43,7 +43,6 @@ end, { vim.list_extend(candidates, platforms) table.insert(candidates, 'cache') table.insert(candidates, 'pick') - if platform and contest_id then vim.list_extend(candidates, actions) local cache = require('cp.cache') @@ -60,10 +59,11 @@ end, { return filter_candidates(candidates) elseif num_args == 3 then if vim.tbl_contains(platforms, args[2]) then + local candidates = { 'login', 'logout' } local cache = require('cp.cache') cache.load() - local contests = cache.get_cached_contest_ids(args[2]) - return filter_candidates(contests) + vim.list_extend(candidates, cache.get_cached_contest_ids(args[2])) + return filter_candidates(candidates) elseif args[2] == 'cache' then return filter_candidates({ 'clear', 'read' }) elseif args[2] == 'stress' or args[2] == 'interact' then @@ -103,8 +103,6 @@ end, { end end return filter_candidates(candidates) - elseif args[2] == 'login' or args[2] == 'logout' then - return filter_candidates(platforms) elseif args[2] == 'race' then local candidates = { 'stop' } vim.list_extend(candidates, platforms)