diff --git a/README.md b/README.md index 9ee67fc..aded27b 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,14 @@ https://github.com/user-attachments/assets/e81d8dfb-578f-4a79-9989-210164fc0148 ## Features - **Multi-platform support**: AtCoder, CodeChef, Codeforces, USACO, CSES, Kattis -- **Online Judge Integration**: Submit problems and view contest standings -- **Live Contest Support**: Participate in real-time contests -- **Automatic setup**: Scrape test cases and metadata in seconds -- **Streamlined Editing**: Configure coding view, edit test cases, stress-test - solutions, run interactive problems, and more -- **Rich output**: 256 color ANSI support for compiler errors and program output -- **Language agnosticism**: Configure with any language -- **Security**: Passwords go untampered +- **Automatic problem setup**: Scrape test cases and metadata in seconds +- **Dual view modes**: Lightweight I/O view for quick feedback, full panel for + detailed analysis +- **Test case management**: Quickly view, edit, add, & remove test cases +- **Rich test output**: 256 color ANSI support for compiler errors and program + output +- **Language agnostic**: Works with any language +- **Diff viewer**: Compare expected vs actual output with 3 diff modes ## Installation @@ -37,31 +37,30 @@ luarocks install cp.nvim ## Quick Start -1. Find a contest: +cp.nvim follows a simple principle: **solve locally, submit remotely**. -``` -:CP pick -``` +### Basic Usage -2. View the problem: +1. Find a contest or problem +2. Set up contests locally -``` -:CP open -``` + ``` + :CP codeforces 1848 + ``` 3. Code and test -``` -:CP run -``` + ``` + :CP run + ``` 4. Navigate between problems -``` -:CP next -:CP prev -:CP e1 -``` + ``` + :CP next + :CP prev + :CP e1 + ``` 5. Debug and edit test cases @@ -70,17 +69,7 @@ luarocks install cp.nvim :CP panel --debug ``` -6. Submit: - -``` -:CP submit -``` - -7. View contest standings: - -``` -:CP open standings -``` +5. Submit on the original website ## Documentation @@ -89,7 +78,7 @@ luarocks install cp.nvim ``` See -[my config](https://github.com/barrettruth/nix/blob/5d0ede3668eb7f5ad2b4475267fc0458f9fa4527/config/nvim/lua/plugins/dev.lua#L165) +[my config](https://github.com/barrettruth/dots/blob/main/.config/nvim/lua/plugins/cp.lua) for the setup in the video shown above. ## Motivation diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 9a3f3a5..670bdab 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -49,8 +49,7 @@ REQUIREMENTS *cp-requirements* - Neovim 0.10.0+ - Unix-like operating system -- (Optional) git 1.7.9+ (credential storage) -- (Optional) uv package manager (https://docs.astral.sh/uv/) +- uv package manager (https://docs.astral.sh/uv/) ============================================================================== SETUP *cp-setup* @@ -999,15 +998,8 @@ CREDENTIALS *cp-credentials* Manage stored login credentials for platform submission. -Credentials are stored via git-credential(1), using whatever credential -helper is configured in your git config (macOS Keychain, libsecret, -credential-store, etc.). Git is required. Cookie files -(~/.cache/cp-nvim/cookies.json) are unaffected. - -To inspect stored credentials: ->sh - printf 'protocol=https\nhost=cses.fi\n\n' | git credential fill -< +Credentials are stored under _credentials in the main cache file +(stdpath('data')/cp-nvim.json). Use :CP cache read to inspect them. :CP login [platform] Set or update credentials for a platform. Prompts for username diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index 14659b2..7ff1824 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -42,10 +42,13 @@ local M = {} +local CACHE_VERSION = 2 + local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json' local cache_data = {} local loaded = false +--- Load the cache from disk if not done already ---@return nil function M.load() if loaded then @@ -53,11 +56,8 @@ function M.load() end if vim.fn.filereadable(cache_file) == 0 then - vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p') - local tmpfile = vim.fn.tempname() - vim.fn.writefile({}, tmpfile) - vim.fn.setfperm(tmpfile, 'rw-------') - vim.uv.fs_rename(tmpfile, cache_file) + vim.fn.writefile({}, cache_file) + vim.fn.setfperm(cache_file, 'rw-------') loaded = true return end @@ -70,7 +70,26 @@ function M.load() end local ok, decoded = pcall(vim.json.decode, table.concat(content, '\n')) - if ok and type(decoded) == 'table' then + if not ok then + cache_data = {} + M.save() + loaded = true + return + end + + if decoded._version == 1 then + local old_creds = decoded._credentials + decoded._credentials = nil + if old_creds then + for platform, creds in pairs(old_creds) do + decoded[platform] = decoded[platform] or {} + decoded[platform]._credentials = creds + end + end + decoded._version = CACHE_VERSION + cache_data = decoded + M.save() + elseif decoded._version == CACHE_VERSION then cache_data = decoded else cache_data = {} @@ -79,16 +98,17 @@ function M.load() loaded = true end +--- Save the cache to disk, overwriting existing contents ---@return nil function M.save() vim.schedule(function() vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p') + + cache_data._version = CACHE_VERSION local encoded = vim.json.encode(cache_data) local lines = vim.split(encoded, '\n') - local tmpfile = vim.fn.tempname() - vim.fn.writefile(lines, tmpfile) - vim.fn.setfperm(tmpfile, 'rw-------') - vim.uv.fs_rename(tmpfile, cache_file) + vim.fn.writefile(lines, cache_file) + vim.fn.setfperm(cache_file, 'rw-------') end) end @@ -425,6 +445,31 @@ function M.get_contest_display_name(platform, contest_id) return cache_data[platform][contest_id].display_name end +---@param platform string +---@return table? +function M.get_credentials(platform) + if not cache_data[platform] then + return nil + end + return cache_data[platform]._credentials +end + +---@param platform string +---@param creds table +function M.set_credentials(platform, creds) + cache_data[platform] = cache_data[platform] or {} + cache_data[platform]._credentials = creds + M.save() +end + +---@param platform string +function M.clear_credentials(platform) + if cache_data[platform] then + cache_data[platform]._credentials = nil + end + M.save() +end + ---@return nil function M.clear_all() cache_data = {} diff --git a/lua/cp/credentials.lua b/lua/cp/credentials.lua index 1d1fa36..4f22038 100644 --- a/lua/cp/credentials.lua +++ b/lua/cp/credentials.lua @@ -1,7 +1,7 @@ local M = {} +local cache = require('cp.cache') local constants = require('cp.constants') -local git_credential = require('cp.git_credential') local logger = require('cp.log') local state = require('cp.state') @@ -40,14 +40,14 @@ local function prompt_and_login(platform, display) end, function(result) vim.schedule(function() if result.success then - git_credential.store(platform, credentials) + cache.set_credentials(platform, credentials) logger.log( display .. ' login successful', { level = vim.log.levels.INFO, override = true } ) else local err = result.error or 'unknown error' - git_credential.reject(platform, credentials) + cache.clear_credentials(platform) logger.log( display .. ' login failed: ' .. (constants.LOGIN_ERRORS[err] or err), { level = vim.log.levels.ERROR } @@ -71,17 +71,10 @@ function M.login(platform) return end - if not git_credential.has_helper() then - logger.log( - 'No git credential helper configured. See :help cp-credentials', - { level = vim.log.levels.ERROR } - ) - return - end - local display = constants.PLATFORM_DISPLAY_NAMES[platform] or platform - local existing = git_credential.get(platform) or {} + cache.load() + local existing = cache.get_credentials(platform) or {} if existing.username and existing.password then local scraper = require('cp.scraper') @@ -98,7 +91,7 @@ function M.login(platform) { level = vim.log.levels.INFO, override = true } ) else - git_credential.reject(platform, existing) + cache.clear_credentials(platform) prompt_and_login(platform, display) end end) @@ -119,28 +112,16 @@ function M.logout(platform) ) return end - if not git_credential.has_helper() then - logger.log( - 'No git credential helper configured. See :help cp-credentials', - { level = vim.log.levels.ERROR } - ) - return - end - local display = constants.PLATFORM_DISPLAY_NAMES[platform] or platform - local existing = git_credential.get(platform) - if existing then - git_credential.reject(platform, existing) - end + cache.load() + cache.clear_credentials(platform) local cookie_file = constants.COOKIE_FILE if vim.fn.filereadable(cookie_file) == 1 then local ok, data = pcall(vim.fn.json_decode, vim.fn.readfile(cookie_file, 'b')) if ok and type(data) == 'table' then data[platform] = nil - local tmpfile = vim.fn.tempname() - vim.fn.writefile({ vim.fn.json_encode(data) }, tmpfile) - vim.fn.setfperm(tmpfile, 'rw-------') - vim.uv.fs_rename(tmpfile, cookie_file) + vim.fn.writefile({ vim.fn.json_encode(data) }, cookie_file) + vim.fn.setfperm(cookie_file, 'rw-------') end end logger.log(display .. ' credentials cleared', { level = vim.log.levels.INFO, override = true }) diff --git a/lua/cp/git_credential.lua b/lua/cp/git_credential.lua deleted file mode 100644 index 388512a..0000000 --- a/lua/cp/git_credential.lua +++ /dev/null @@ -1,107 +0,0 @@ ----@class cp.Credentials ----@field username string ----@field password string - -local M = {} - -local HOSTS = { - atcoder = 'atcoder.jp', - codechef = 'www.codechef.com', - codeforces = 'codeforces.com', - cses = 'cses.fi', - kattis = 'open.kattis.com', - usaco = 'usaco.org', -} - -local _helper_checked = false -local _helper_ok = false - ----@return boolean -function M.has_helper() - if not _helper_checked then - local r = vim - .system({ 'git', 'config', 'credential.helper' }, { text = true, timeout = 5000 }) - :wait() - _helper_ok = r.code == 0 and r.stdout ~= nil and vim.trim(r.stdout) ~= '' - _helper_checked = true - end - return _helper_ok -end - ----@param host string ----@param extra? table ----@return string -local function _build_input(host, extra) - local lines = { 'protocol=https', 'host=' .. host } - if extra then - for k, v in pairs(extra) do - table.insert(lines, k .. '=' .. v) - end - end - table.insert(lines, '') - table.insert(lines, '') - return table.concat(lines, '\n') -end - ----@param stdout string ----@return table -local function _parse_output(stdout) - local result = {} - for line in stdout:gmatch('[^\n]+') do - local k, v = line:match('^(%S+)=(.+)$') - if k and v then - result[k] = v - end - end - return result -end - ----@param platform string ----@return cp.Credentials? -function M.get(platform) - local host = HOSTS[platform] - if not host then - return nil - end - - local input = _build_input(host) - local obj = vim - .system({ 'git', 'credential', 'fill' }, { stdin = input, text = true, timeout = 5000 }) - :wait() - if obj.code ~= 0 then - return nil - end - - local parsed = _parse_output(obj.stdout or '') - if not parsed.username or not parsed.password then - return nil - end - - return { username = parsed.username, password = parsed.password } -end - ----@param platform string ----@param creds cp.Credentials -function M.store(platform, creds) - local host = HOSTS[platform] - if not host then - return - end - - local input = _build_input(host, { username = creds.username, password = creds.password }) - vim.system({ 'git', 'credential', 'approve' }, { stdin = input, text = true }):wait() -end - ----@param platform string ----@param creds cp.Credentials -function M.reject(platform, creds) - local host = HOSTS[platform] - if not host or not creds then - return - end - - local input = _build_input(host, { username = creds.username, password = creds.password }) - vim.system({ 'git', 'credential', 'reject' }, { stdin = input, text = true }):wait() -end - -return M diff --git a/lua/cp/health.lua b/lua/cp/health.lua index 2987205..f37a6c5 100644 --- a/lua/cp/health.lua +++ b/lua/cp/health.lua @@ -5,12 +5,12 @@ local utils = require('cp.utils') local function check() vim.health.start('cp.nvim [required] ~') - local nvim_ver = vim.version() - local nvim_str = ('%d.%d.%d'):format(nvim_ver.major, nvim_ver.minor, nvim_ver.patch) + utils.setup_python_env() + if vim.fn.has('nvim-0.10.0') == 1 then - vim.health.ok('Neovim >= 0.10.0: ' .. nvim_str) + vim.health.ok('Neovim 0.10.0+ detected') else - vim.health.error('Neovim >= 0.10.0 required, found ' .. nvim_str) + vim.health.error('cp.nvim requires Neovim 0.10.0+') end local uname = vim.uv.os_uname() @@ -18,24 +18,6 @@ local function check() vim.health.error('Windows is not supported') end - local time_cap = utils.time_capability() - if time_cap.ok then - vim.health.ok('GNU time found: ' .. time_cap.path) - else - vim.health.error('GNU time not found: ' .. (time_cap.reason or '')) - end - - local timeout_cap = utils.timeout_capability() - if timeout_cap.ok then - vim.health.ok('GNU timeout found: ' .. timeout_cap.path) - else - vim.health.error('GNU timeout not found: ' .. (timeout_cap.reason or '')) - end - - vim.health.start('cp.nvim [optional] ~') - - utils.setup_python_env() - if utils.is_nix_build() then local source = utils.is_nix_discovered() and 'runtime discovery' or 'flake install' vim.health.ok('Nix Python environment detected (' .. source .. ')') @@ -69,30 +51,18 @@ local function check() end end - if vim.fn.executable('git') == 1 then - local r = vim.system({ 'git', '--version' }, { text = true }):wait() - if r.code == 0 then - local major, minor, patch = r.stdout:match('(%d+)%.(%d+)%.(%d+)') - major, minor, patch = tonumber(major), tonumber(minor), tonumber(patch or 0) - local ver_str = ('%d.%d.%d'):format(major or 0, minor or 0, patch or 0) - if - major - and (major > 1 or (major == 1 and minor > 7) or (major == 1 and minor == 7 and patch >= 9)) - then - vim.health.ok('git >= 1.7.9: ' .. ver_str) - else - vim.health.warn('git >= 1.7.9 required for credential storage, found ' .. ver_str) - end - end - - local helper = vim.system({ 'git', 'config', 'credential.helper' }, { text = true }):wait() - if helper.code == 0 and helper.stdout and vim.trim(helper.stdout) ~= '' then - vim.health.ok('git credential helper: ' .. vim.trim(helper.stdout)) - else - vim.health.warn('no git credential helper configured (required for login/submit)') - end + local time_cap = utils.time_capability() + if time_cap.ok then + vim.health.ok('GNU time found: ' .. time_cap.path) else - vim.health.warn('git not found (required for credential storage)') + vim.health.error('GNU time not found: ' .. (time_cap.reason or '')) + end + + local timeout_cap = utils.timeout_capability() + if timeout_cap.ok then + vim.health.ok('GNU timeout found: ' .. timeout_cap.path) + else + vim.health.error('GNU timeout not found: ' .. (timeout_cap.reason or '')) end end diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 474c608..4ad46bc 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -347,9 +347,7 @@ function M.login(platform, credentials, on_status, callback) stdin = vim.json.encode(credentials), on_event = function(ev) if ev.credentials ~= nil and next(ev.credentials) ~= nil then - vim.schedule(function() - require('cp.git_credential').store(platform, ev.credentials) - end) + require('cp.cache').set_credentials(platform, ev.credentials) end if ev.status ~= nil then if type(on_status) == 'function' then @@ -397,9 +395,7 @@ function M.submit( stdin = vim.json.encode(credentials), on_event = function(ev) if ev.credentials ~= nil and next(ev.credentials) ~= nil then - vim.schedule(function() - require('cp.git_credential').store(platform, ev.credentials) - end) + require('cp.cache').set_credentials(platform, ev.credentials) end if ev.status ~= nil then if type(on_status) == 'function' then diff --git a/lua/cp/submit.lua b/lua/cp/submit.lua index 50d0bf2..529456d 100644 --- a/lua/cp/submit.lua +++ b/lua/cp/submit.lua @@ -1,8 +1,8 @@ local M = {} +local cache = require('cp.cache') local config = require('cp.config') local constants = require('cp.constants') -local git_credential = require('cp.git_credential') local logger = require('cp.log') local state = require('cp.state') @@ -14,7 +14,7 @@ local STATUS_MSGS = { } local function prompt_credentials(platform, callback) - local saved = git_credential.get(platform) + local saved = cache.get_credentials(platform) if saved and saved.username and saved.password then callback(saved) return @@ -42,14 +42,6 @@ end ---@param opts { language?: string }? function M.submit(opts) - if not git_credential.has_helper() then - logger.log( - 'No git credential helper configured. See :help cp-credentials', - { level = vim.log.levels.ERROR } - ) - return - end - local platform = state.get_platform() local contest_id = state.get_contest_id() local problem_id = state.get_problem_id() @@ -117,12 +109,12 @@ function M.submit(opts) function(result) vim.schedule(function() if result and result.success then - git_credential.store(platform, creds) + cache.set_credentials(platform, creds) logger.log('Submitted successfully', { level = vim.log.levels.INFO, override = true }) else local err = result and result.error or 'unknown error' if err == 'bad_credentials' or err:match('^Login failed') then - git_credential.reject(platform, creds) + cache.clear_credentials(platform) logger.log( 'Submit failed: ' .. (constants.LOGIN_ERRORS[err] or err), { level = vim.log.levels.ERROR } diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index 1c21f48..8325369 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -366,10 +366,6 @@ function M.check_required_runtime() return false, timeout.reason end - if vim.fn.executable('git') ~= 1 then - return false, 'git is required for credential storage' - end - return true end diff --git a/plugin/cp.lua b/plugin/cp.lua index 6f83533..b1430f2 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -8,7 +8,6 @@ vim.api.nvim_create_user_command('CP', function(opts) cp.handle_command(opts) end, { nargs = '*', - bar = true, desc = 'Competitive programming helper', complete = function(ArgLead, CmdLine, _) local constants = require('cp.constants') diff --git a/scrapers/base.py b/scrapers/base.py index 4d36206..e98990c 100644 --- a/scrapers/base.py +++ b/scrapers/base.py @@ -3,7 +3,6 @@ import json import os import re import sys -import tempfile from abc import ABC, abstractmethod from pathlib import Path from typing import Any @@ -21,18 +20,6 @@ from .models import ( _COOKIE_FILE = Path.home() / ".cache" / "cp-nvim" / "cookies.json" -def _atomic_write(path: Path, content: str) -> None: - fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-") - try: - os.fchmod(fd, 0o600) - with os.fdopen(fd, "w") as f: - f.write(content) - os.replace(tmp, path) - except BaseException: - os.unlink(tmp) - raise - - def load_platform_cookies(platform: str) -> Any | None: try: data = json.loads(_COOKIE_FILE.read_text()) @@ -42,20 +29,22 @@ def load_platform_cookies(platform: str) -> Any | None: def save_platform_cookies(platform: str, data: Any) -> None: - _COOKIE_FILE.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + _COOKIE_FILE.parent.mkdir(parents=True, exist_ok=True) try: existing = json.loads(_COOKIE_FILE.read_text()) except Exception: existing = {} existing[platform] = data - _atomic_write(_COOKIE_FILE, json.dumps(existing)) + _COOKIE_FILE.write_text(json.dumps(existing)) + _COOKIE_FILE.chmod(0o600) def clear_platform_cookies(platform: str) -> None: try: existing = json.loads(_COOKIE_FILE.read_text()) existing.pop(platform, None) - _atomic_write(_COOKIE_FILE, json.dumps(existing)) + _COOKIE_FILE.write_text(json.dumps(existing)) + _COOKIE_FILE.chmod(0o600) except Exception: pass diff --git a/scrapers/cses.py b/scrapers/cses.py index e28f99a..33fffb5 100644 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -8,12 +8,7 @@ from typing import Any import httpx -from .base import ( - BaseScraper, - extract_precision, - load_platform_cookies, - save_platform_cookies, -) +from .base import BaseScraper, extract_precision from .timeouts import HTTP_TIMEOUT from .models import ( ContestListResult, @@ -253,20 +248,35 @@ class CSESScraper(BaseScraper): if not username or not password: return self._login_error("Missing username or password") - token = load_platform_cookies("cses") + token = credentials.get("token") async with httpx.AsyncClient(follow_redirects=True) as client: if token: print(json.dumps({"status": "checking_login"}), flush=True) if await self._check_token(client, token): - return LoginResult(success=True, error="") + return LoginResult( + success=True, + error="", + credentials={ + "username": username, + "password": password, + "token": token, + }, + ) print(json.dumps({"status": "logging_in"}), flush=True) token = await self._web_login(client, username, password) if not token: return self._login_error("bad_credentials") - save_platform_cookies("cses", token) - return LoginResult(success=True, error="") + return LoginResult( + success=True, + error="", + credentials={ + "username": username, + "password": password, + "token": token, + }, + ) async def stream_tests_for_category_async(self, category_id: str) -> None: async with httpx.AsyncClient( @@ -413,7 +423,7 @@ class CSESScraper(BaseScraper): return self._submit_error("Missing credentials. Use :CP login cses") async with httpx.AsyncClient(follow_redirects=True) as client: - token = load_platform_cookies("cses") + token = credentials.get("token") if token: print(json.dumps({"status": "checking_login"}), flush=True) @@ -425,7 +435,18 @@ class CSESScraper(BaseScraper): token = await self._web_login(client, username, password) if not token: return self._submit_error("bad_credentials") - save_platform_cookies("cses", token) + print( + json.dumps( + { + "credentials": { + "username": username, + "password": password, + "token": token, + } + } + ), + flush=True, + ) print(json.dumps({"status": "submitting"}), flush=True)