From ca5d1145dd596964d5fc18302f87390add54e1b9 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Sun, 22 Feb 2026 17:14:29 -0500 Subject: [PATCH] cp.nvim --- init.lua | 8 ++ lazy-lock.json | 13 +-- lua/cp/scraper.lua | 238 +++++++++++++++++++++++++++++++++++++++++ lua/plugins/editor.lua | 50 +++++++++ 4 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 lua/cp/scraper.lua diff --git a/init.lua b/init.lua index cab1c4a..8bdb898 100644 --- a/init.lua +++ b/init.lua @@ -1,6 +1,14 @@ vim.g.mapleader = ' ' vim.g.maplocalleader = ',' +local home = os.getenv('HOME') or '' +local local_bin = home .. '/.local/bin' +if not (os.getenv('PATH') or ''):find(local_bin, 1, true) then + local new_path = local_bin .. ':' .. (os.getenv('PATH') or '') + vim.env.PATH = new_path + vim.uv.os_setenv('PATH', new_path) +end + function _G.map(mode, lhs, rhs, opts) vim.keymap.set(mode, lhs, rhs, vim.tbl_extend('keep', opts or {}, { silent = true })) end diff --git a/lazy-lock.json b/lazy-lock.json index 54cd768..f0f44da 100644 --- a/lazy-lock.json +++ b/lazy-lock.json @@ -1,16 +1,17 @@ { - "diffs.nvim": { "branch": "main", "commit": "b1abfe4f4a164ad776148ca36f852df4f1e4014e" }, + "cp.nvim": { "branch": "main", "commit": "ff5ba39a592d079780819bb8226dcb35741349a4" }, + "diffs.nvim": { "branch": "main", "commit": "dfebc68a1fc3e93dae5b6d49133243ec1886cb19" }, "flash.nvim": { "branch": "main", "commit": "fcea7ff883235d9024dc41e638f164a450c14ca2" }, - "fzf-lua": { "branch": "main", "commit": "b2c0603216adb92c6bba81053bc996d7ae95b77a" }, + "fzf-lua": { "branch": "main", "commit": "9004cbb4c065a32b690e909c49903967b45301eb" }, "gitsigns.nvim": { "branch": "main", "commit": "9f3c6dd7868bcc116e9c1c1929ce063b978fa519" }, "gruvbox.nvim": { "branch": "main", "commit": "561126520034a1dac2f78ab063db025d12555998" }, - "lazy.nvim": { "branch": "main", "commit": "306a05526ada86a7b30af95c5cc81ffba93fef97" }, + "lazy.nvim": { "branch": "main", "commit": "85c7ff3711b730b4030d03144f6db6375044ae82" }, "lualine.nvim": { "branch": "master", "commit": "47f91c416daef12db467145e16bed5bbfe00add8" }, - "nonicons.nvim": { "branch": "main", "commit": "62549ecb9906e4216398c44af96719ca4cc670ef" }, + "nonicons.nvim": { "branch": "main", "commit": "5426ec037f2a295ae687fa9d4def290fb044e3e8" }, "nvim-autopairs": { "branch": "master", "commit": "59bce2eef357189c3305e25bc6dd2d138c1683f5" }, - "nvim-lspconfig": { "branch": "master", "commit": "44acfe887d4056f704ccc4f17513ed41c9e2b2e6" }, + "nvim-lspconfig": { "branch": "master", "commit": "5a855bcfec7973767a1a472335684bbd71d2fa2b" }, "nvim-surround": { "branch": "main", "commit": "1098d7b3c34adcfa7feb3289ee434529abd4afd1" }, - "nvim-treesitter": { "branch": "master", "commit": "42fc28ba918343ebfd5565147a42a26580579482" }, + "nvim-treesitter": { "branch": "main", "commit": "dc42c209f3820bdfaae0956f15de29689aa6b451" }, "nvim-treesitter-textobjects": { "branch": "main", "commit": "a0e182ae21fda68c59d1f36c9ed45600aef50311" }, "nvim-ufo": { "branch": "main", "commit": "ab3eb124062422d276fae49e0dd63b3ad1062cfc" }, "nvim-web-devicons": { "branch": "master", "commit": "746ffbb17975ebd6c40142362eee1b0249969c5c" }, diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua new file mode 100644 index 0000000..659983f --- /dev/null +++ b/lua/cp/scraper.lua @@ -0,0 +1,238 @@ +local M = {} + +local constants = require('cp.constants') +local logger = require('cp.log') +local utils = require('cp.utils') + +local function syshandle(result) + if result.code ~= 0 then + local msg = 'Scraper failed: ' .. (result.stderr or 'Unknown error') + return { success = false, error = msg } + end + + local ok, data = pcall(vim.json.decode, result.stdout) + if not ok then + local msg = 'Failed to parse scraper output: ' .. tostring(data) + logger.log(msg, vim.log.levels.ERROR) + return { success = false, error = msg } + end + + return { success = true, data = data } +end + +local function spawn_env_list(env_map) + local out = {} + for key, value in pairs(env_map) do + out[#out + 1] = tostring(key) .. '=' .. tostring(value) + end + table.sort(out) + return out +end + +---@param platform string +---@param subcommand string +---@param args string[] +---@param opts { sync?: boolean, ndjson?: boolean, on_event?: fun(ev: table), on_exit?: fun(result: table) } +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) + if opts and opts.on_exit then + opts.on_exit({ success = false, error = msg }) + end + return { success = false, error = msg } + end + + local plugin_path = utils.get_plugin_path() + local cmd = utils.get_python_cmd(platform, plugin_path) + vim.list_extend(cmd, { subcommand }) + vim.list_extend(cmd, args) + + logger.log('scraper cmd: ' .. table.concat(cmd, ' ')) + + local env = vim.fn.environ() + env.VIRTUAL_ENV = '' + env.PYTHONPATH = '' + env.CONDA_PREFIX = '' + + if opts and opts.ndjson then + local uv = vim.loop + local stdout = uv.new_pipe(false) + local stderr = uv.new_pipe(false) + local buf = '' + + local handle + handle = uv.spawn(cmd[1], { + args = vim.list_slice(cmd, 2), + stdio = { nil, stdout, stderr }, + env = spawn_env_list(env), + cwd = plugin_path, + }, function(code, signal) + if buf ~= '' and opts.on_event then + local ok_tail, ev_tail = pcall(vim.json.decode, buf) + if ok_tail then + opts.on_event(ev_tail) + end + buf = '' + end + if opts.on_exit then + opts.on_exit({ success = (code == 0), code = code, signal = signal }) + 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:close() + end + end) + + if not handle then + logger.log('Failed to start scraper process', vim.log.levels.ERROR) + return { success = false, error = 'spawn failed' } + end + + uv.read_start(stdout, function(_, data) + if data == nil then + if buf ~= '' and opts.on_event then + local ok_tail, ev_tail = pcall(vim.json.decode, buf) + if ok_tail then + opts.on_event(ev_tail) + end + buf = '' + end + return + end + buf = buf .. data + while true do + local s, e = buf:find('\n', 1, true) + if not s then + break + end + local line = buf:sub(1, s - 1) + buf = buf:sub(e + 1) + local ok, ev = pcall(vim.json.decode, line) + if ok and opts.on_event then + opts.on_event(ev) + end + end + end) + + uv.read_start(stderr, function(_, _) end) + return + end + + local sysopts = { text = true, timeout = 30000, env = env, cwd = plugin_path } + if opts and opts.sync then + local result = vim.system(cmd, sysopts):wait() + return syshandle(result) + else + vim.system(cmd, sysopts, function(result) + if opts and opts.on_exit then + return opts.on_exit(syshandle(result)) + end + end) + end +end + +function M.scrape_contest_metadata(platform, contest_id, callback) + run_scraper(platform, 'metadata', { contest_id }, { + on_exit = function(result) + if not result or not result.success then + logger.log( + ("Failed to scrape metadata for %s contest '%s'."):format( + constants.PLATFORM_DISPLAY_NAMES[platform], + contest_id + ), + vim.log.levels.ERROR + ) + return + end + local data = result.data or {} + if not data.problems or #data.problems == 0 then + logger.log( + ("No problems returned for %s contest '%s'."):format( + constants.PLATFORM_DISPLAY_NAMES[platform], + contest_id + ), + vim.log.levels.ERROR + ) + return + end + if type(callback) == 'function' then + callback(data) + end + end, + }) +end + +function M.scrape_contest_list(platform) + local result = run_scraper(platform, 'contests', {}, { sync = true }) + if not result or not result.success or not (result.data and result.data.contests) then + logger.log( + ('Could not scrape contests list for platform %s: %s'):format( + platform, + (result and result.error) or 'unknown' + ), + vim.log.levels.ERROR + ) + return {} + end + return result.data.contests +end + +---@param platform string +---@param contest_id string +---@param callback fun(data: table)|nil +function M.scrape_all_tests(platform, contest_id, callback) + run_scraper(platform, 'tests', { contest_id }, { + ndjson = true, + on_event = function(ev) + if ev.done then + return + end + if ev.error and ev.problem_id then + logger.log( + ("Failed to load tests for problem '%s' in contest '%s': %s"):format( + ev.problem_id, + contest_id, + ev.error + ), + vim.log.levels.WARN + ) + return + end + if not ev.problem_id or not ev.tests then + return + end + vim.schedule(function() + require('cp.utils').ensure_dirs() + local config = require('cp.config') + local base_name = config.default_filename(contest_id, ev.problem_id) + for i, t in ipairs(ev.tests) do + local input_file = 'io/' .. base_name .. '.' .. i .. '.cpin' + local expected_file = 'io/' .. base_name .. '.' .. i .. '.cpout' + local input_content = t.input:gsub('\r', '') + local expected_content = t.expected:gsub('\r', '') + vim.fn.writefile(vim.split(input_content, '\n'), input_file) + vim.fn.writefile(vim.split(expected_content, '\n'), expected_file) + end + if type(callback) == 'function' then + callback({ + combined = ev.combined, + tests = ev.tests, + timeout_ms = ev.timeout_ms or 0, + memory_mb = ev.memory_mb or 0, + interactive = ev.interactive or false, + multi_test = ev.multi_test or false, + problem_id = ev.problem_id, + }) + end + end) + end, + }) +end + +return M diff --git a/lua/plugins/editor.lua b/lua/plugins/editor.lua index a43b2cd..6430d7b 100644 --- a/lua/plugins/editor.lua +++ b/lua/plugins/editor.lua @@ -35,6 +35,56 @@ return { map('n', 'zM', require('ufo').closeAllFolds) end, }, + { + 'barrettruth/cp.nvim', + dependencies = { 'ibhagwan/fzf-lua' }, + init = function() + -- Keep uv cache in-project so cp.nvim scraping works in restricted environments. + if vim.env.UV_CACHE_DIR == nil or vim.env.UV_CACHE_DIR == '' then + local uv_cache_dir = vim.fn.getcwd() .. '/.uv-cache' + vim.fn.mkdir(uv_cache_dir, 'p') + vim.env.UV_CACHE_DIR = uv_cache_dir + end + + vim.g.cp = { + languages = { + python = { + extension = 'py', + commands = { + run = { 'python3', '{source}' }, + debug = { 'python3', '{source}' }, + }, + }, + }, + platforms = { + codeforces = { + enabled_languages = { 'python' }, + default_language = 'python', + }, + atcoder = { + enabled_languages = { 'python' }, + default_language = 'python', + }, + cses = { + enabled_languages = { 'python' }, + default_language = 'python', + }, + }, + ui = { + picker = 'fzf-lua', + }, + } + end, + config = function() + map('n', 'cr', 'CP run', { desc = 'CP run' }) + map('n', 'cp', 'CP panel', { desc = 'CP panel' }) + map('n', 'ce', 'CP edit', { desc = 'CP edit tests' }) + map('n', 'cn', 'CP next', { desc = 'CP next problem' }) + map('n', 'cN', 'CP prev', { desc = 'CP previous problem' }) + map('n', 'cc', 'CP pick', { desc = 'CP contest picker' }) + map('n', 'ci', 'CP interact', { desc = 'CP interact' }) + end, + }, { 'supermaven-inc/supermaven-nvim', opts = {