diff --git a/README.md b/README.md index aded27b..33ed2a9 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,15 @@ https://github.com/user-attachments/assets/e81d8dfb-578f-4a79-9989-210164fc0148 ## Features - **Multi-platform support**: AtCoder, CodeChef, Codeforces, USACO, CSES, Kattis -- **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 +- **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 agnostic**: Works with any language -- **Diff viewer**: Compare expected vs actual output with 3 diff modes +- **Language agnosticism**: Configure with any language +- **Security**: Passwords go untampered ## Installation @@ -37,30 +38,31 @@ luarocks install cp.nvim ## Quick Start -cp.nvim follows a simple principle: **solve locally, submit remotely**. +1. Find a contest: -### Basic Usage +``` +:CP pick +``` -1. Find a contest or problem -2. Set up contests locally +2. View the problem: - ``` - :CP codeforces 1848 - ``` +``` +:CP open +``` 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 @@ -69,7 +71,17 @@ cp.nvim follows a simple principle: **solve locally, submit remotely**. :CP panel --debug ``` -5. Submit on the original website +6. Submit: + +``` +:CP submit +``` + +7. View contest standings: + +``` +:CP open standings +``` ## Documentation @@ -78,7 +90,7 @@ cp.nvim follows a simple principle: **solve locally, submit remotely**. ``` See -[my config](https://github.com/barrettruth/dots/blob/main/.config/nvim/lua/plugins/cp.lua) +[my config](https://github.com/barrettruth/nix/blob/5d0ede3668eb7f5ad2b4475267fc0458f9fa4527/config/nvim/lua/plugins/dev.lua#L165) for the setup in the video shown above. ## Motivation diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 670bdab..9a3f3a5 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -49,7 +49,8 @@ REQUIREMENTS *cp-requirements* - Neovim 0.10.0+ - Unix-like operating system -- uv package manager (https://docs.astral.sh/uv/) +- (Optional) git 1.7.9+ (credential storage) +- (Optional) uv package manager (https://docs.astral.sh/uv/) ============================================================================== SETUP *cp-setup* @@ -998,8 +999,15 @@ CREDENTIALS *cp-credentials* Manage stored login credentials for platform submission. -Credentials are stored under _credentials in the main cache file -(stdpath('data')/cp-nvim.json). Use :CP cache read to inspect them. +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 +< :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 7ff1824..14659b2 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -42,13 +42,10 @@ 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 @@ -56,8 +53,11 @@ function M.load() end if vim.fn.filereadable(cache_file) == 0 then - vim.fn.writefile({}, cache_file) - vim.fn.setfperm(cache_file, 'rw-------') + 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) loaded = true return end @@ -70,26 +70,7 @@ function M.load() end local ok, decoded = pcall(vim.json.decode, table.concat(content, '\n')) - 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 + if ok and type(decoded) == 'table' then cache_data = decoded else cache_data = {} @@ -98,17 +79,16 @@ 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') - vim.fn.writefile(lines, cache_file) - vim.fn.setfperm(cache_file, 'rw-------') + local tmpfile = vim.fn.tempname() + vim.fn.writefile(lines, tmpfile) + vim.fn.setfperm(tmpfile, 'rw-------') + vim.uv.fs_rename(tmpfile, cache_file) end) end @@ -445,31 +425,6 @@ 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 4f22038..1d1fa36 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 - cache.set_credentials(platform, credentials) + git_credential.store(platform, credentials) logger.log( display .. ' login successful', { level = vim.log.levels.INFO, override = true } ) else local err = result.error or 'unknown error' - cache.clear_credentials(platform) + git_credential.reject(platform, credentials) logger.log( display .. ' login failed: ' .. (constants.LOGIN_ERRORS[err] or err), { level = vim.log.levels.ERROR } @@ -71,10 +71,17 @@ 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 - cache.load() - local existing = cache.get_credentials(platform) or {} + local existing = git_credential.get(platform) or {} if existing.username and existing.password then local scraper = require('cp.scraper') @@ -91,7 +98,7 @@ function M.login(platform) { level = vim.log.levels.INFO, override = true } ) else - cache.clear_credentials(platform) + git_credential.reject(platform, existing) prompt_and_login(platform, display) end end) @@ -112,16 +119,28 @@ 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 - cache.load() - cache.clear_credentials(platform) + local existing = git_credential.get(platform) + if existing then + git_credential.reject(platform, existing) + end 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 - vim.fn.writefile({ vim.fn.json_encode(data) }, cookie_file) - vim.fn.setfperm(cookie_file, 'rw-------') + 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) 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 new file mode 100644 index 0000000..388512a --- /dev/null +++ b/lua/cp/git_credential.lua @@ -0,0 +1,107 @@ +---@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 f37a6c5..2987205 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] ~') - utils.setup_python_env() - + local nvim_ver = vim.version() + local nvim_str = ('%d.%d.%d'):format(nvim_ver.major, nvim_ver.minor, nvim_ver.patch) if vim.fn.has('nvim-0.10.0') == 1 then - vim.health.ok('Neovim 0.10.0+ detected') + vim.health.ok('Neovim >= 0.10.0: ' .. nvim_str) else - vim.health.error('cp.nvim requires Neovim 0.10.0+') + vim.health.error('Neovim >= 0.10.0 required, found ' .. nvim_str) end local uname = vim.uv.os_uname() @@ -18,6 +18,24 @@ 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 .. ')') @@ -51,18 +69,30 @@ local function check() end 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 + 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 timeout_cap = utils.timeout_capability() - if timeout_cap.ok then - vim.health.ok('GNU timeout found: ' .. timeout_cap.path) + 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 else - vim.health.error('GNU timeout not found: ' .. (timeout_cap.reason or '')) + vim.health.warn('git not found (required for credential storage)') end end diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 4ad46bc..474c608 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -347,7 +347,9 @@ 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 - require('cp.cache').set_credentials(platform, ev.credentials) + vim.schedule(function() + require('cp.git_credential').store(platform, ev.credentials) + end) end if ev.status ~= nil then if type(on_status) == 'function' then @@ -395,7 +397,9 @@ function M.submit( stdin = vim.json.encode(credentials), on_event = function(ev) if ev.credentials ~= nil and next(ev.credentials) ~= nil then - require('cp.cache').set_credentials(platform, ev.credentials) + vim.schedule(function() + require('cp.git_credential').store(platform, ev.credentials) + end) 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 529456d..50d0bf2 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 = cache.get_credentials(platform) + local saved = git_credential.get(platform) if saved and saved.username and saved.password then callback(saved) return @@ -42,6 +42,14 @@ 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() @@ -109,12 +117,12 @@ function M.submit(opts) function(result) vim.schedule(function() if result and result.success then - cache.set_credentials(platform, creds) + git_credential.store(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 - cache.clear_credentials(platform) + git_credential.reject(platform, creds) 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 8325369..1c21f48 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -366,6 +366,10 @@ 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 b1430f2..6f83533 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -8,6 +8,7 @@ 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 e98990c..4d36206 100644 --- a/scrapers/base.py +++ b/scrapers/base.py @@ -3,6 +3,7 @@ import json import os import re import sys +import tempfile from abc import ABC, abstractmethod from pathlib import Path from typing import Any @@ -20,6 +21,18 @@ 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()) @@ -29,22 +42,20 @@ 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) + _COOKIE_FILE.parent.mkdir(parents=True, exist_ok=True, mode=0o700) try: existing = json.loads(_COOKIE_FILE.read_text()) except Exception: existing = {} existing[platform] = data - _COOKIE_FILE.write_text(json.dumps(existing)) - _COOKIE_FILE.chmod(0o600) + _atomic_write(_COOKIE_FILE, json.dumps(existing)) def clear_platform_cookies(platform: str) -> None: try: existing = json.loads(_COOKIE_FILE.read_text()) existing.pop(platform, None) - _COOKIE_FILE.write_text(json.dumps(existing)) - _COOKIE_FILE.chmod(0o600) + _atomic_write(_COOKIE_FILE, json.dumps(existing)) except Exception: pass diff --git a/scrapers/cses.py b/scrapers/cses.py index 33fffb5..0b5d8b3 100644 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -8,7 +8,7 @@ from typing import Any import httpx -from .base import BaseScraper, extract_precision +from .base import BaseScraper, extract_precision, load_platform_cookies, save_platform_cookies from .timeouts import HTTP_TIMEOUT from .models import ( ContestListResult, @@ -248,35 +248,20 @@ class CSESScraper(BaseScraper): if not username or not password: return self._login_error("Missing username or password") - token = credentials.get("token") + token = load_platform_cookies("cses") 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="", - credentials={ - "username": username, - "password": password, - "token": token, - }, - ) + return LoginResult(success=True, error="") 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") - return LoginResult( - success=True, - error="", - credentials={ - "username": username, - "password": password, - "token": token, - }, - ) + save_platform_cookies("cses", token) + return LoginResult(success=True, error="") async def stream_tests_for_category_async(self, category_id: str) -> None: async with httpx.AsyncClient( @@ -423,7 +408,7 @@ class CSESScraper(BaseScraper): return self._submit_error("Missing credentials. Use :CP login cses") async with httpx.AsyncClient(follow_redirects=True) as client: - token = credentials.get("token") + token = load_platform_cookies("cses") if token: print(json.dumps({"status": "checking_login"}), flush=True) @@ -435,18 +420,7 @@ class CSESScraper(BaseScraper): token = await self._web_login(client, username, password) if not token: return self._submit_error("bad_credentials") - print( - json.dumps( - { - "credentials": { - "username": username, - "password": password, - "token": token, - } - } - ), - flush=True, - ) + save_platform_cookies("cses", token) print(json.dumps({"status": "submitting"}), flush=True)